Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
wisp.place
1import type { BlobRef } from "@atproto/api";
2import type { Record, Directory, File, Entry } from "../lexicons/types/place/wisp/fs";
3import { validateRecord } from "../lexicons/types/place/wisp/fs";
4import { gzipSync } from 'zlib';
5import { CID } from 'multiformats/cid';
6import { sha256 } from 'multiformats/hashes/sha2';
7import * as raw from 'multiformats/codecs/raw';
8import { createHash } from 'crypto';
9import * as mf from 'multiformats';
10
11export interface UploadedFile {
12 name: string;
13 content: Buffer;
14 mimeType: string;
15 size: number;
16 compressed?: boolean;
17 originalMimeType?: string;
18}
19
20export interface FileUploadResult {
21 hash: string;
22 blobRef: BlobRef;
23 encoding?: 'gzip';
24 mimeType?: string;
25 base64?: boolean;
26}
27
28export interface ProcessedDirectory {
29 directory: Directory;
30 fileCount: number;
31}
32
33/**
34 * Determine if a file should be gzip compressed based on its MIME type
35 */
36export function shouldCompressFile(mimeType: string): boolean {
37 // Compress text-based files
38 const compressibleTypes = [
39 'text/html',
40 'text/css',
41 'text/javascript',
42 'application/javascript',
43 'application/json',
44 'image/svg+xml',
45 'text/xml',
46 'application/xml',
47 'text/plain',
48 'application/x-javascript'
49 ];
50
51 // Check if mime type starts with any compressible type
52 return compressibleTypes.some(type => mimeType.startsWith(type));
53}
54
55/**
56 * Compress a file using gzip with deterministic output
57 */
58export function compressFile(content: Buffer): Buffer {
59 return gzipSync(content, {
60 level: 9
61 });
62}
63
64/**
65 * Process uploaded files into a directory structure
66 */
67export function processUploadedFiles(files: UploadedFile[]): ProcessedDirectory {
68 const entries: Entry[] = [];
69 let fileCount = 0;
70
71 // Group files by directory
72 const directoryMap = new Map<string, UploadedFile[]>();
73
74 for (const file of files) {
75 // Skip undefined/null files (defensive)
76 if (!file || !file.name) {
77 console.error('Skipping undefined or invalid file in processUploadedFiles');
78 continue;
79 }
80
81 // Remove any base folder name from the path
82 const normalizedPath = file.name.replace(/^[^\/]*\//, '');
83 const parts = normalizedPath.split('/');
84
85 if (parts.length === 1) {
86 // Root level file
87 entries.push({
88 name: parts[0],
89 node: {
90 $type: 'place.wisp.fs#file' as const,
91 type: 'file' as const,
92 blob: undefined as any // Will be filled after upload
93 }
94 });
95 fileCount++;
96 } else {
97 // File in subdirectory
98 const dirPath = parts.slice(0, -1).join('/');
99 if (!directoryMap.has(dirPath)) {
100 directoryMap.set(dirPath, []);
101 }
102 directoryMap.get(dirPath)!.push({
103 ...file,
104 name: normalizedPath
105 });
106 }
107 }
108
109 // Process subdirectories
110 for (const [dirPath, dirFiles] of directoryMap) {
111 const dirEntries: Entry[] = [];
112
113 for (const file of dirFiles) {
114 const fileName = file.name.split('/').pop()!;
115 dirEntries.push({
116 name: fileName,
117 node: {
118 $type: 'place.wisp.fs#file' as const,
119 type: 'file' as const,
120 blob: undefined as any // Will be filled after upload
121 }
122 });
123 fileCount++;
124 }
125
126 // Build nested directory structure
127 const pathParts = dirPath.split('/');
128 let currentEntries = entries;
129
130 for (let i = 0; i < pathParts.length; i++) {
131 const part = pathParts[i];
132 const isLast = i === pathParts.length - 1;
133
134 let existingEntry = currentEntries.find(e => e.name === part);
135
136 if (!existingEntry) {
137 const newDir = {
138 $type: 'place.wisp.fs#directory' as const,
139 type: 'directory' as const,
140 entries: isLast ? dirEntries : []
141 };
142
143 existingEntry = {
144 name: part,
145 node: newDir
146 };
147 currentEntries.push(existingEntry);
148 } else if ('entries' in existingEntry.node && isLast) {
149 (existingEntry.node as any).entries.push(...dirEntries);
150 }
151
152 if (existingEntry && 'entries' in existingEntry.node) {
153 currentEntries = (existingEntry.node as any).entries;
154 }
155 }
156 }
157
158 const result = {
159 directory: {
160 $type: 'place.wisp.fs#directory' as const,
161 type: 'directory' as const,
162 entries
163 },
164 fileCount
165 };
166
167 return result;
168}
169
170/**
171 * Create the manifest record for a site
172 */
173export function createManifest(
174 siteName: string,
175 root: Directory,
176 fileCount: number
177): Record {
178 const manifest = {
179 $type: 'place.wisp.fs' as const,
180 site: siteName,
181 root,
182 fileCount,
183 createdAt: new Date().toISOString()
184 };
185
186 // Validate the manifest before returning
187 const validationResult = validateRecord(manifest);
188 if (!validationResult.success) {
189 throw new Error(`Invalid manifest: ${validationResult.error?.message || 'Validation failed'}`);
190 }
191
192 return manifest;
193}
194
195/**
196 * Update file blobs in directory structure after upload
197 * Uses path-based matching to correctly match files in nested directories
198 */
199export function updateFileBlobs(
200 directory: Directory,
201 uploadResults: FileUploadResult[],
202 filePaths: string[],
203 currentPath: string = ''
204): Directory {
205 const updatedEntries = directory.entries.map(entry => {
206 if ('type' in entry.node && entry.node.type === 'file') {
207 // Build the full path for this file
208 const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
209
210 // Find exact match in filePaths (need to handle normalized paths)
211 const fileIndex = filePaths.findIndex((path) => {
212 // Normalize both paths by removing leading base folder
213 const normalizedUploadPath = path.replace(/^[^\/]*\//, '');
214 const normalizedEntryPath = fullPath;
215 return normalizedUploadPath === normalizedEntryPath || path === fullPath;
216 });
217
218 if (fileIndex !== -1 && uploadResults[fileIndex]) {
219 const result = uploadResults[fileIndex];
220 const blobRef = result.blobRef;
221
222 return {
223 ...entry,
224 node: {
225 $type: 'place.wisp.fs#file' as const,
226 type: 'file' as const,
227 blob: blobRef,
228 ...(result.encoding && { encoding: result.encoding }),
229 ...(result.mimeType && { mimeType: result.mimeType }),
230 ...(result.base64 && { base64: result.base64 })
231 }
232 };
233 } else {
234 console.error(`❌ BLOB MATCHING ERROR: Could not find blob for file: ${fullPath}`);
235 console.error(` Available paths:`, filePaths.slice(0, 10), filePaths.length > 10 ? `... and ${filePaths.length - 10} more` : '');
236 }
237 } else if ('type' in entry.node && entry.node.type === 'directory') {
238 const dirPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
239 return {
240 ...entry,
241 node: updateFileBlobs(entry.node as Directory, uploadResults, filePaths, dirPath)
242 };
243 }
244 return entry;
245 }) as Entry[];
246
247 const result = {
248 $type: 'place.wisp.fs#directory' as const,
249 type: 'directory' as const,
250 entries: updatedEntries
251 };
252
253 return result;
254}
255
256/**
257 * Compute CID (Content Identifier) for blob content
258 * Uses the same algorithm as AT Protocol: CIDv1 with raw codec and SHA-256
259 * Based on @atproto/common/src/ipld.ts sha256RawToCid implementation
260 */
261export function computeCID(content: Buffer): string {
262 // Use node crypto to compute sha256 hash (same as AT Protocol)
263 const hash = createHash('sha256').update(content).digest();
264 // Create digest object from hash bytes
265 const digest = mf.digest.create(sha256.code, hash);
266 // Create CIDv1 with raw codec
267 const cid = CID.createV1(raw.code, digest);
268 return cid.toString();
269}
270
271/**
272 * Extract blob information from a directory tree
273 * Returns a map of file paths to their blob refs and CIDs
274 */
275export function extractBlobMap(
276 directory: Directory,
277 currentPath: string = ''
278): Map<string, { blobRef: BlobRef; cid: string }> {
279 const blobMap = new Map<string, { blobRef: BlobRef; cid: string }>();
280
281 for (const entry of directory.entries) {
282 const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
283
284 if ('type' in entry.node && entry.node.type === 'file') {
285 const fileNode = entry.node as File;
286 // AT Protocol SDK returns BlobRef class instances, not plain objects
287 // The ref is a CID instance that can be converted to string
288 if (fileNode.blob && fileNode.blob.ref) {
289 const cidString = fileNode.blob.ref.toString();
290 blobMap.set(fullPath, {
291 blobRef: fileNode.blob,
292 cid: cidString
293 });
294 }
295 } else if ('type' in entry.node && entry.node.type === 'directory') {
296 const subMap = extractBlobMap(entry.node as Directory, fullPath);
297 subMap.forEach((value, key) => blobMap.set(key, value));
298 }
299 }
300
301 return blobMap;
302}