Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place

start work on actual production backend

Changed files
+644 -31
lexicons
public
src
lexicon
types
place
wisp
lib
routes
-2
api.md
··· 37 37 * 38 38 * Routes: 39 39 * GET /wisp/sites - List all sites for authenticated user 40 - * GET /wisp/fs/:site - Get site record (metadata/manifest) 41 - * GET /wisp/fs/:site/file/* - Get individual file content by path 42 40 * POST /wisp/upload-files - Upload and deploy files as a site 43 41 */
+2 -2
lexicons/fs.json
··· 18 18 }, 19 19 "file": { 20 20 "type": "object", 21 - "required": ["type", "hash"], 21 + "required": ["type", "blob"], 22 22 "properties": { 23 23 "type": { "type": "string", "const": "file" }, 24 - "hash": { "type": "string", "description": "Content blob hash" } 24 + "blob": { "type": "blob", "accept": ["*/*"], "maxSize": 1000000, "description": "Content blob ref" } 25 25 } 26 26 }, 27 27 "directory": {
+123
public/editor/editor.tsx
··· 1 + import { useState, useRef } from 'react' 2 + import { createRoot } from 'react-dom/client' 3 + 4 + import Layout from '@public/layouts' 5 + 6 + function Editor() { 7 + const [uploading, setUploading] = useState(false) 8 + const [result, setResult] = useState<any>(null) 9 + const [error, setError] = useState<string | null>(null) 10 + const folderInputRef = useRef<HTMLInputElement>(null) 11 + const siteNameRef = useRef<HTMLInputElement>(null) 12 + 13 + const handleFileUpload = async (e: React.FormEvent) => { 14 + e.preventDefault() 15 + setError(null) 16 + setResult(null) 17 + 18 + const files = folderInputRef.current?.files 19 + const siteName = siteNameRef.current?.value 20 + 21 + if (!files || files.length === 0) { 22 + setError('Please select a folder to upload') 23 + return 24 + } 25 + 26 + if (!siteName) { 27 + setError('Please enter a site name') 28 + return 29 + } 30 + 31 + setUploading(true) 32 + 33 + try { 34 + const formData = new FormData() 35 + formData.append('siteName', siteName) 36 + 37 + for (let i = 0; i < files.length; i++) { 38 + formData.append('files', files[i]) 39 + } 40 + 41 + const response = await fetch('/wisp/upload-files', { 42 + method: 'POST', 43 + body: formData 44 + }) 45 + 46 + if (!response.ok) { 47 + throw new Error(`Upload failed: ${response.statusText}`) 48 + } 49 + 50 + const data = await response.json() 51 + setResult(data) 52 + } catch (err) { 53 + setError(err instanceof Error ? err.message : 'Upload failed') 54 + } finally { 55 + setUploading(false) 56 + } 57 + } 58 + 59 + return ( 60 + <div className="w-full max-w-2xl mx-auto p-6"> 61 + <h1 className="text-3xl font-bold mb-6 text-center">Upload Folder</h1> 62 + 63 + <form onSubmit={handleFileUpload} className="space-y-4"> 64 + <div> 65 + <label htmlFor="siteName" className="block text-sm font-medium mb-2"> 66 + Site Name 67 + </label> 68 + <input 69 + ref={siteNameRef} 70 + type="text" 71 + id="siteName" 72 + placeholder="Enter site name" 73 + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 74 + /> 75 + </div> 76 + 77 + <div> 78 + <label htmlFor="folder" className="block text-sm font-medium mb-2"> 79 + Select Folder 80 + </label> 81 + <input 82 + ref={folderInputRef} 83 + type="file" 84 + id="folder" 85 + {...({ webkitdirectory: '', directory: '' } as any)} 86 + multiple 87 + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 88 + /> 89 + </div> 90 + 91 + <button 92 + type="submit" 93 + disabled={uploading} 94 + className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white font-semibold py-2 px-4 rounded-md transition-colors" 95 + > 96 + {uploading ? 'Uploading...' : 'Upload Folder'} 97 + </button> 98 + </form> 99 + 100 + {error && ( 101 + <div className="mt-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded-md"> 102 + {error} 103 + </div> 104 + )} 105 + 106 + {result && ( 107 + <div className="mt-4 p-3 bg-green-100 border border-green-400 text-green-700 rounded-md"> 108 + <h3 className="font-semibold mb-2">Upload Successful!</h3> 109 + <p>Files uploaded: {result.fileCount}</p> 110 + <p>Site name: {result.siteName}</p> 111 + <p>URI: {result.uri}</p> 112 + </div> 113 + )} 114 + </div> 115 + ) 116 + } 117 + 118 + const root = createRoot(document.getElementById('elysia')!) 119 + root.render( 120 + <Layout className="gap-6"> 121 + <Editor /> 122 + </Layout> 123 + )
+12
public/editor/index.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <title>Elysia Static</title> 7 + </head> 8 + <body> 9 + <div id="elysia"></div> 10 + <script type="module" src="./editor.tsx"></script> 11 + </body> 12 + </html>
+4 -21
src/index.ts
··· 10 10 getOAuthClient, 11 11 getCurrentKeys 12 12 } from './lib/oauth-client' 13 + import { authRoutes } from './routes/auth' 14 + import { wispRoutes } from './routes/wisp' 13 15 14 16 const config: Config = { 15 17 domain: (Bun.env.DOMAIN ?? `https://${BASE_HOST}`) as `https://${string}`, ··· 29 31 prefix: '/' 30 32 }) 31 33 ) 32 - .post('/api/auth/signin', async (c) => { 33 - try { 34 - const { handle } = await c.request.json() 35 - const state = crypto.randomUUID() 36 - const url = await client.authorize(handle, { state }) 37 - return { url: url.toString() } 38 - } catch (err) { 39 - console.error('Signin error', err) 40 - return { error: 'Authentication failed' } 41 - } 42 - }) 43 - .get('/api/auth/callback', async (c) => { 44 - const params = new URLSearchParams(c.query) 45 - const { session } = await client.callback(params) 46 - if (!session) return { error: 'Authentication failed' } 47 - 48 - const cookieSession = c.cookie 49 - cookieSession.did.value = session.did 50 - 51 - return c.redirect('/') 52 - }) 34 + .use(authRoutes(client)) 35 + .use(wispRoutes(client)) 53 36 .get('/client-metadata.json', (c) => { 54 37 return createClientMetadata(config) 55 38 })
+6 -4
src/lexicon/lexicons.ts
··· 42 42 }, 43 43 file: { 44 44 type: 'object', 45 - required: ['type', 'hash'], 45 + required: ['type', 'blob'], 46 46 properties: { 47 47 type: { 48 48 type: 'string', 49 49 const: 'file', 50 50 }, 51 - hash: { 52 - type: 'string', 53 - description: 'Content blob hash', 51 + blob: { 52 + type: 'blob', 53 + accept: ['*/*'], 54 + maxSize: 1000000, 55 + description: 'Content blob ref', 54 56 }, 55 57 }, 56 58 },
+2 -2
src/lexicon/types/place/wisp/fs.ts
··· 32 32 export interface File { 33 33 $type?: 'place.wisp.fs#file' 34 34 type: 'file' 35 - /** Content blob hash */ 36 - hash: string 35 + /** Content blob ref */ 36 + blob: BlobRef 37 37 } 38 38 39 39 const hashFile = 'file'
+37
src/lib/wisp-auth.ts
··· 1 + import { Did } from "@atproto/api"; 2 + import { NodeOAuthClient } from "@atproto/oauth-client-node"; 3 + import type { OAuthSession } from "@atproto/oauth-client-node"; 4 + import { Cookie } from "elysia"; 5 + 6 + 7 + export interface AuthenticatedContext { 8 + did: Did; 9 + session: OAuthSession; 10 + } 11 + 12 + export const authenticateRequest = async ( 13 + client: NodeOAuthClient, 14 + cookies: Record<string, Cookie<unknown>> 15 + ): Promise<AuthenticatedContext | null> => { 16 + try { 17 + const did = cookies.did?.value as Did; 18 + if (!did) return null; 19 + 20 + const session = await client.restore(did, "auto"); 21 + return session ? { did, session } : null; 22 + } catch (err) { 23 + console.error('Authentication error:', err); 24 + return null; 25 + } 26 + }; 27 + 28 + export const requireAuth = async ( 29 + client: NodeOAuthClient, 30 + cookies: Record<string, Cookie<unknown>> 31 + ): Promise<AuthenticatedContext> => { 32 + const auth = await authenticateRequest(client, cookies); 33 + if (!auth) { 34 + throw new Error('Authentication required'); 35 + } 36 + return auth; 37 + };
+203
src/lib/wisp-utils.ts
··· 1 + import type { BlobRef } from "@atproto/api"; 2 + import type { Record, Directory, File, Entry } from "../lexicon/types/place/wisp/fs"; 3 + 4 + export interface UploadedFile { 5 + name: string; 6 + content: Buffer; 7 + mimeType: string; 8 + size: number; 9 + } 10 + 11 + export interface FileUploadResult { 12 + hash: string; 13 + blobRef: BlobRef; 14 + } 15 + 16 + export interface ProcessedDirectory { 17 + directory: Directory; 18 + fileCount: number; 19 + } 20 + 21 + /** 22 + * Process uploaded files into a directory structure 23 + */ 24 + export function processUploadedFiles(files: UploadedFile[]): ProcessedDirectory { 25 + console.log(`๐Ÿ—๏ธ Processing ${files.length} uploaded files`); 26 + const entries: Entry[] = []; 27 + let fileCount = 0; 28 + 29 + // Group files by directory 30 + const directoryMap = new Map<string, UploadedFile[]>(); 31 + 32 + for (const file of files) { 33 + // Remove any base folder name from the path 34 + const normalizedPath = file.name.replace(/^[^\/]*\//, ''); 35 + const parts = normalizedPath.split('/'); 36 + 37 + console.log(`๐Ÿ“„ Processing file: ${file.name} -> normalized: ${normalizedPath}`); 38 + 39 + if (parts.length === 1) { 40 + // Root level file 41 + console.log(`๐Ÿ“ Root level file: ${parts[0]}`); 42 + entries.push({ 43 + name: parts[0], 44 + node: { 45 + $type: 'place.wisp.fs#file' as const, 46 + type: 'file' as const, 47 + blob: undefined as any // Will be filled after upload 48 + } 49 + }); 50 + fileCount++; 51 + } else { 52 + // File in subdirectory 53 + const dirPath = parts.slice(0, -1).join('/'); 54 + console.log(`๐Ÿ“‚ Subdirectory file: ${dirPath}/${parts[parts.length - 1]}`); 55 + if (!directoryMap.has(dirPath)) { 56 + directoryMap.set(dirPath, []); 57 + console.log(`โž• Created directory: ${dirPath}`); 58 + } 59 + directoryMap.get(dirPath)!.push({ 60 + ...file, 61 + name: normalizedPath 62 + }); 63 + } 64 + } 65 + 66 + // Process subdirectories 67 + console.log(`๐Ÿ“‚ Processing ${directoryMap.size} subdirectories`); 68 + for (const [dirPath, dirFiles] of directoryMap) { 69 + console.log(`๐Ÿ“ Processing directory: ${dirPath} with ${dirFiles.length} files`); 70 + const dirEntries: Entry[] = []; 71 + 72 + for (const file of dirFiles) { 73 + const fileName = file.name.split('/').pop()!; 74 + console.log(` ๐Ÿ“„ Adding file to directory: ${fileName}`); 75 + dirEntries.push({ 76 + name: fileName, 77 + node: { 78 + $type: 'place.wisp.fs#file' as const, 79 + type: 'file' as const, 80 + blob: undefined as any // Will be filled after upload 81 + } 82 + }); 83 + fileCount++; 84 + } 85 + 86 + // Build nested directory structure 87 + const pathParts = dirPath.split('/'); 88 + let currentEntries = entries; 89 + 90 + console.log(`๐Ÿ—๏ธ Building nested structure for path: ${pathParts.join('/')}`); 91 + 92 + for (let i = 0; i < pathParts.length; i++) { 93 + const part = pathParts[i]; 94 + const isLast = i === pathParts.length - 1; 95 + 96 + let existingEntry = currentEntries.find(e => e.name === part); 97 + 98 + if (!existingEntry) { 99 + const newDir = { 100 + $type: 'place.wisp.fs#directory' as const, 101 + type: 'directory' as const, 102 + entries: isLast ? dirEntries : [] 103 + }; 104 + 105 + existingEntry = { 106 + name: part, 107 + node: newDir 108 + }; 109 + currentEntries.push(existingEntry); 110 + console.log(` โž• Created directory entry: ${part}`); 111 + } else if ('entries' in existingEntry.node && isLast) { 112 + (existingEntry.node as any).entries.push(...dirEntries); 113 + console.log(` ๐Ÿ“ Added files to existing directory: ${part}`); 114 + } 115 + 116 + if (existingEntry && 'entries' in existingEntry.node) { 117 + currentEntries = (existingEntry.node as any).entries; 118 + } 119 + } 120 + } 121 + 122 + console.log(`โœ… Directory structure completed with ${fileCount} total files`); 123 + 124 + const result = { 125 + directory: { 126 + $type: 'place.wisp.fs#directory' as const, 127 + type: 'directory' as const, 128 + entries 129 + }, 130 + fileCount 131 + }; 132 + 133 + console.log('๐Ÿ“‹ Final directory structure:', JSON.stringify(result, null, 2)); 134 + return result; 135 + } 136 + 137 + /** 138 + * Create the manifest record for a site 139 + */ 140 + export function createManifest( 141 + siteName: string, 142 + root: Directory, 143 + fileCount: number 144 + ): Record { 145 + const manifest: Record = { 146 + $type: 'place.wisp.fs' as const, 147 + site: siteName, 148 + root, 149 + fileCount, 150 + createdAt: new Date().toISOString() 151 + }; 152 + 153 + console.log(`๐Ÿ“‹ Created manifest for site "${siteName}" with ${fileCount} files`); 154 + console.log('๐Ÿ“„ Manifest structure:', JSON.stringify(manifest, null, 2)); 155 + 156 + return manifest; 157 + } 158 + 159 + /** 160 + * Update file blobs in directory structure after upload 161 + */ 162 + export function updateFileBlobs( 163 + directory: Directory, 164 + uploadResults: FileUploadResult[], 165 + filePaths: string[] 166 + ): Directory { 167 + console.log(`๐Ÿ”„ Updating file blobs: ${uploadResults.length} results for ${filePaths.length} paths`); 168 + 169 + const updatedEntries = directory.entries.map(entry => { 170 + if ('type' in entry.node && entry.node.type === 'file') { 171 + const fileIndex = filePaths.findIndex(path => path.endsWith(entry.name)); 172 + if (fileIndex !== -1 && uploadResults[fileIndex]) { 173 + console.log(` ๐Ÿ”— Updating blob for file: ${entry.name} -> ${uploadResults[fileIndex].hash}`); 174 + return { 175 + ...entry, 176 + node: { 177 + $type: 'place.wisp.fs#file' as const, 178 + type: 'file' as const, 179 + blob: uploadResults[fileIndex].blobRef 180 + } 181 + }; 182 + } else { 183 + console.warn(` โš ๏ธ Could not find upload result for file: ${entry.name}`); 184 + } 185 + } else if ('type' in entry.node && entry.node.type === 'directory') { 186 + console.log(` ๐Ÿ“‚ Recursively updating directory: ${entry.name}`); 187 + return { 188 + ...entry, 189 + node: updateFileBlobs(entry.node as Directory, uploadResults, filePaths) 190 + }; 191 + } 192 + return entry; 193 + }) as Entry[]; 194 + 195 + const result = { 196 + $type: 'place.wisp.fs#directory' as const, 197 + type: 'directory' as const, 198 + entries: updatedEntries 199 + }; 200 + 201 + console.log('โœ… File blobs updated'); 202 + return result; 203 + }
+25
src/routes/auth.ts
··· 1 + import { Elysia } from 'elysia' 2 + import { NodeOAuthClient } from '@atproto/oauth-client-node' 3 + 4 + export const authRoutes = (client: NodeOAuthClient) => new Elysia() 5 + .post('/api/auth/signin', async (c) => { 6 + try { 7 + const { handle } = await c.request.json() 8 + const state = crypto.randomUUID() 9 + const url = await client.authorize(handle, { state }) 10 + return { url: url.toString() } 11 + } catch (err) { 12 + console.error('Signin error', err) 13 + return { error: 'Authentication failed' } 14 + } 15 + }) 16 + .get('/api/auth/callback', async (c) => { 17 + const params = new URLSearchParams(c.query) 18 + const { session } = await client.callback(params) 19 + if (!session) return { error: 'Authentication failed' } 20 + 21 + const cookieSession = c.cookie 22 + cookieSession.did.value = session.did 23 + 24 + return c.redirect('/editor') 25 + })
+230
src/routes/wisp.ts
··· 1 + import { Elysia } from 'elysia' 2 + import { requireAuth, type AuthenticatedContext } from '../lib/wisp-auth' 3 + import { NodeOAuthClient } from '@atproto/oauth-client-node' 4 + import { Agent } from '@atproto/api' 5 + import { 6 + type UploadedFile, 7 + type FileUploadResult, 8 + processUploadedFiles, 9 + createManifest, 10 + updateFileBlobs 11 + } from '../lib/wisp-utils' 12 + 13 + export const wispRoutes = (client: NodeOAuthClient) => 14 + new Elysia({ prefix: '/wisp' }) 15 + .derive(async ({ cookie }) => { 16 + const auth = await requireAuth(client, cookie) 17 + return { auth } 18 + }) 19 + .post( 20 + '/upload-files', 21 + async ({ body, auth }) => { 22 + const { siteName, files } = body as { 23 + siteName: string; 24 + files: File | File[] 25 + }; 26 + 27 + console.log('๐Ÿš€ Starting upload process', { siteName, fileCount: Array.isArray(files) ? files.length : 1 }); 28 + 29 + try { 30 + if (!files || (Array.isArray(files) ? files.length === 0 : !files)) { 31 + console.error('โŒ No files provided'); 32 + throw new Error('No files provided') 33 + } 34 + 35 + if (!siteName) { 36 + console.error('โŒ Site name is required'); 37 + throw new Error('Site name is required') 38 + } 39 + 40 + console.log('โœ… Initial validation passed'); 41 + 42 + // Create agent with OAuth session 43 + console.log('๐Ÿ” Creating agent with OAuth session'); 44 + const agent = new Agent((url, init) => auth.session.fetchHandler(url, init)) 45 + console.log('โœ… Agent created successfully'); 46 + 47 + // Convert File objects to UploadedFile format 48 + // Elysia gives us File objects directly, handle both single file and array 49 + const fileArray = Array.isArray(files) ? files : [files]; 50 + console.log(`๐Ÿ“ Processing ${fileArray.length} files`); 51 + const uploadedFiles: UploadedFile[] = []; 52 + 53 + // Define allowed file extensions for static site hosting 54 + const allowedExtensions = new Set([ 55 + // HTML 56 + '.html', '.htm', 57 + // CSS 58 + '.css', 59 + // JavaScript 60 + '.js', '.mjs', '.jsx', '.ts', '.tsx', 61 + // Images 62 + '.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp', '.ico', '.avif', 63 + // Fonts 64 + '.woff', '.woff2', '.ttf', '.otf', '.eot', 65 + // Documents 66 + '.pdf', '.txt', 67 + // JSON (for config files, but not .map files) 68 + '.json', 69 + // Audio/Video 70 + '.mp3', '.mp4', '.webm', '.ogg', '.wav', 71 + // Other web assets 72 + '.xml', '.rss', '.atom' 73 + ]); 74 + 75 + // Files to explicitly exclude 76 + const excludedFiles = new Set([ 77 + '.map', '.DS_Store', 'Thumbs.db' 78 + ]); 79 + 80 + for (let i = 0; i < fileArray.length; i++) { 81 + const file = fileArray[i]; 82 + const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase(); 83 + 84 + console.log(`๐Ÿ“„ Processing file ${i + 1}/${fileArray.length}: ${file.name} (${file.size} bytes, ${file.type})`); 85 + 86 + // Skip excluded files 87 + if (excludedFiles.has(fileExtension)) { 88 + console.log(`โญ๏ธ Skipping excluded file: ${file.name}`); 89 + continue; 90 + } 91 + 92 + // Skip files that aren't in allowed extensions 93 + if (!allowedExtensions.has(fileExtension)) { 94 + console.log(`โญ๏ธ Skipping non-web file: ${file.name} (${fileExtension})`); 95 + continue; 96 + } 97 + 98 + // Skip files that are too large (limit to 100MB per file) 99 + const maxSize = 100 * 1024 * 1024; // 100MB 100 + if (file.size > maxSize) { 101 + console.log(`โญ๏ธ Skipping large file: ${file.name} (${(file.size / 1024 / 1024).toFixed(2)}MB > 100MB limit)`); 102 + continue; 103 + } 104 + 105 + console.log(`โœ… Including file: ${file.name}`); 106 + const arrayBuffer = await file.arrayBuffer(); 107 + uploadedFiles.push({ 108 + name: file.name, 109 + content: Buffer.from(arrayBuffer), 110 + mimeType: file.type || 'application/octet-stream', 111 + size: file.size 112 + }); 113 + } 114 + 115 + // Check total size limit (300MB) 116 + const totalSize = uploadedFiles.reduce((sum, file) => sum + file.size, 0); 117 + const maxTotalSize = 300 * 1024 * 1024; // 300MB 118 + 119 + console.log(`๐Ÿ“Š Filtered to ${uploadedFiles.length} files from ${fileArray.length} total files`); 120 + console.log(`๐Ÿ“ฆ Total size: ${(totalSize / 1024 / 1024).toFixed(2)}MB (limit: 300MB)`); 121 + 122 + if (totalSize > maxTotalSize) { 123 + throw new Error(`Total upload size ${(totalSize / 1024 / 1024).toFixed(2)}MB exceeds 300MB limit`); 124 + } 125 + 126 + if (uploadedFiles.length === 0) { 127 + throw new Error('No valid web files found to upload. Allowed types: HTML, CSS, JS, images, fonts, PDFs, and other web assets.'); 128 + } 129 + 130 + console.log('โœ… File conversion completed'); 131 + 132 + // Process files into directory structure 133 + console.log('๐Ÿ—๏ธ Building directory structure'); 134 + const { directory, fileCount } = processUploadedFiles(uploadedFiles); 135 + console.log(`โœ… Directory structure created with ${fileCount} files`); 136 + 137 + // Upload files as blobs 138 + const uploadResults: FileUploadResult[] = []; 139 + const filePaths: string[] = []; 140 + 141 + console.log('โฌ†๏ธ Starting blob upload process'); 142 + for (let i = 0; i < uploadedFiles.length; i++) { 143 + const file = uploadedFiles[i]; 144 + console.log(`๐Ÿ“ค Uploading blob ${i + 1}/${uploadedFiles.length}: ${file.name}`); 145 + 146 + try { 147 + console.log(`๐Ÿ” Upload details:`, { 148 + fileName: file.name, 149 + fileSize: file.size, 150 + mimeType: file.mimeType, 151 + contentLength: file.content.length 152 + }); 153 + 154 + const uploadResult = await agent.com.atproto.repo.uploadBlob( 155 + file.content, 156 + { 157 + encoding: file.mimeType 158 + } 159 + ); 160 + 161 + console.log(`โœ… Upload successful for ${file.name}:`, { 162 + hash: uploadResult.data.blob.ref.toString(), 163 + mimeType: uploadResult.data.blob.mimeType, 164 + size: uploadResult.data.blob.size 165 + }); 166 + 167 + uploadResults.push({ 168 + hash: uploadResult.data.blob.ref.toString(), 169 + blobRef: uploadResult.data.blob 170 + }); 171 + 172 + filePaths.push(file.name); 173 + } catch (uploadError) { 174 + console.error(`โŒ Upload failed for file ${file.name}:`, uploadError); 175 + console.error('Upload error details:', { 176 + fileName: file.name, 177 + fileSize: file.size, 178 + mimeType: file.mimeType, 179 + error: uploadError 180 + }); 181 + throw uploadError; 182 + } 183 + } 184 + 185 + console.log('โœ… All blobs uploaded successfully'); 186 + 187 + // Update directory with file blobs 188 + console.log('๐Ÿ”„ Updating file blobs in directory structure'); 189 + const updatedDirectory = updateFileBlobs(directory, uploadResults, filePaths); 190 + console.log('โœ… File blobs updated'); 191 + 192 + // Create manifest 193 + console.log('๐Ÿ“‹ Creating manifest'); 194 + const manifest = createManifest(siteName, updatedDirectory, fileCount); 195 + console.log('โœ… Manifest created'); 196 + 197 + // Create the record 198 + console.log('๐Ÿ“ Creating record in repo'); 199 + const record = await agent.com.atproto.repo.createRecord({ 200 + repo: auth.did, 201 + collection: 'place.wisp.fs', 202 + record: manifest 203 + }); 204 + 205 + console.log('โœ… Record created successfully:', { 206 + uri: record.data.uri, 207 + cid: record.data.cid 208 + }); 209 + 210 + const result = { 211 + success: true, 212 + uri: record.data.uri, 213 + cid: record.data.cid, 214 + fileCount, 215 + siteName 216 + }; 217 + 218 + console.log('๐ŸŽ‰ Upload process completed successfully'); 219 + return result; 220 + } catch (error) { 221 + console.error('โŒ Upload error:', error); 222 + console.error('Error details:', { 223 + message: error instanceof Error ? error.message : 'Unknown error', 224 + stack: error instanceof Error ? error.stack : undefined, 225 + name: error instanceof Error ? error.name : undefined 226 + }); 227 + throw new Error(`Failed to upload files: ${error instanceof Error ? error.message : 'Unknown error'}`); 228 + } 229 + } 230 + )