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
···
*
* Routes:
* GET /wisp/sites - List all sites for authenticated user
-
* GET /wisp/fs/:site - Get site record (metadata/manifest)
-
* GET /wisp/fs/:site/file/* - Get individual file content by path
* POST /wisp/upload-files - Upload and deploy files as a site
*/
+2 -2
lexicons/fs.json
···
},
"file": {
"type": "object",
-
"required": ["type", "hash"],
+
"required": ["type", "blob"],
"properties": {
"type": { "type": "string", "const": "file" },
-
"hash": { "type": "string", "description": "Content blob hash" }
+
"blob": { "type": "blob", "accept": ["*/*"], "maxSize": 1000000, "description": "Content blob ref" }
}
},
"directory": {
+123
public/editor/editor.tsx
···
+
import { useState, useRef } from 'react'
+
import { createRoot } from 'react-dom/client'
+
+
import Layout from '@public/layouts'
+
+
function Editor() {
+
const [uploading, setUploading] = useState(false)
+
const [result, setResult] = useState<any>(null)
+
const [error, setError] = useState<string | null>(null)
+
const folderInputRef = useRef<HTMLInputElement>(null)
+
const siteNameRef = useRef<HTMLInputElement>(null)
+
+
const handleFileUpload = async (e: React.FormEvent) => {
+
e.preventDefault()
+
setError(null)
+
setResult(null)
+
+
const files = folderInputRef.current?.files
+
const siteName = siteNameRef.current?.value
+
+
if (!files || files.length === 0) {
+
setError('Please select a folder to upload')
+
return
+
}
+
+
if (!siteName) {
+
setError('Please enter a site name')
+
return
+
}
+
+
setUploading(true)
+
+
try {
+
const formData = new FormData()
+
formData.append('siteName', siteName)
+
+
for (let i = 0; i < files.length; i++) {
+
formData.append('files', files[i])
+
}
+
+
const response = await fetch('/wisp/upload-files', {
+
method: 'POST',
+
body: formData
+
})
+
+
if (!response.ok) {
+
throw new Error(`Upload failed: ${response.statusText}`)
+
}
+
+
const data = await response.json()
+
setResult(data)
+
} catch (err) {
+
setError(err instanceof Error ? err.message : 'Upload failed')
+
} finally {
+
setUploading(false)
+
}
+
}
+
+
return (
+
<div className="w-full max-w-2xl mx-auto p-6">
+
<h1 className="text-3xl font-bold mb-6 text-center">Upload Folder</h1>
+
+
<form onSubmit={handleFileUpload} className="space-y-4">
+
<div>
+
<label htmlFor="siteName" className="block text-sm font-medium mb-2">
+
Site Name
+
</label>
+
<input
+
ref={siteNameRef}
+
type="text"
+
id="siteName"
+
placeholder="Enter site name"
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+
/>
+
</div>
+
+
<div>
+
<label htmlFor="folder" className="block text-sm font-medium mb-2">
+
Select Folder
+
</label>
+
<input
+
ref={folderInputRef}
+
type="file"
+
id="folder"
+
{...({ webkitdirectory: '', directory: '' } as any)}
+
multiple
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+
/>
+
</div>
+
+
<button
+
type="submit"
+
disabled={uploading}
+
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"
+
>
+
{uploading ? 'Uploading...' : 'Upload Folder'}
+
</button>
+
</form>
+
+
{error && (
+
<div className="mt-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded-md">
+
{error}
+
</div>
+
)}
+
+
{result && (
+
<div className="mt-4 p-3 bg-green-100 border border-green-400 text-green-700 rounded-md">
+
<h3 className="font-semibold mb-2">Upload Successful!</h3>
+
<p>Files uploaded: {result.fileCount}</p>
+
<p>Site name: {result.siteName}</p>
+
<p>URI: {result.uri}</p>
+
</div>
+
)}
+
</div>
+
)
+
}
+
+
const root = createRoot(document.getElementById('elysia')!)
+
root.render(
+
<Layout className="gap-6">
+
<Editor />
+
</Layout>
+
)
+12
public/editor/index.html
···
+
<!doctype html>
+
<html lang="en">
+
<head>
+
<meta charset="UTF-8" />
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+
<title>Elysia Static</title>
+
</head>
+
<body>
+
<div id="elysia"></div>
+
<script type="module" src="./editor.tsx"></script>
+
</body>
+
</html>
+4 -21
src/index.ts
···
getOAuthClient,
getCurrentKeys
} from './lib/oauth-client'
+
import { authRoutes } from './routes/auth'
+
import { wispRoutes } from './routes/wisp'
const config: Config = {
domain: (Bun.env.DOMAIN ?? `https://${BASE_HOST}`) as `https://${string}`,
···
prefix: '/'
})
)
-
.post('/api/auth/signin', async (c) => {
-
try {
-
const { handle } = await c.request.json()
-
const state = crypto.randomUUID()
-
const url = await client.authorize(handle, { state })
-
return { url: url.toString() }
-
} catch (err) {
-
console.error('Signin error', err)
-
return { error: 'Authentication failed' }
-
}
-
})
-
.get('/api/auth/callback', async (c) => {
-
const params = new URLSearchParams(c.query)
-
const { session } = await client.callback(params)
-
if (!session) return { error: 'Authentication failed' }
-
-
const cookieSession = c.cookie
-
cookieSession.did.value = session.did
-
-
return c.redirect('/')
-
})
+
.use(authRoutes(client))
+
.use(wispRoutes(client))
.get('/client-metadata.json', (c) => {
return createClientMetadata(config)
})
+6 -4
src/lexicon/lexicons.ts
···
},
file: {
type: 'object',
-
required: ['type', 'hash'],
+
required: ['type', 'blob'],
properties: {
type: {
type: 'string',
const: 'file',
},
-
hash: {
-
type: 'string',
-
description: 'Content blob hash',
+
blob: {
+
type: 'blob',
+
accept: ['*/*'],
+
maxSize: 1000000,
+
description: 'Content blob ref',
},
},
},
+2 -2
src/lexicon/types/place/wisp/fs.ts
···
export interface File {
$type?: 'place.wisp.fs#file'
type: 'file'
-
/** Content blob hash */
-
hash: string
+
/** Content blob ref */
+
blob: BlobRef
}
const hashFile = 'file'
+37
src/lib/wisp-auth.ts
···
+
import { Did } from "@atproto/api";
+
import { NodeOAuthClient } from "@atproto/oauth-client-node";
+
import type { OAuthSession } from "@atproto/oauth-client-node";
+
import { Cookie } from "elysia";
+
+
+
export interface AuthenticatedContext {
+
did: Did;
+
session: OAuthSession;
+
}
+
+
export const authenticateRequest = async (
+
client: NodeOAuthClient,
+
cookies: Record<string, Cookie<unknown>>
+
): Promise<AuthenticatedContext | null> => {
+
try {
+
const did = cookies.did?.value as Did;
+
if (!did) return null;
+
+
const session = await client.restore(did, "auto");
+
return session ? { did, session } : null;
+
} catch (err) {
+
console.error('Authentication error:', err);
+
return null;
+
}
+
};
+
+
export const requireAuth = async (
+
client: NodeOAuthClient,
+
cookies: Record<string, Cookie<unknown>>
+
): Promise<AuthenticatedContext> => {
+
const auth = await authenticateRequest(client, cookies);
+
if (!auth) {
+
throw new Error('Authentication required');
+
}
+
return auth;
+
};
+203
src/lib/wisp-utils.ts
···
+
import type { BlobRef } from "@atproto/api";
+
import type { Record, Directory, File, Entry } from "../lexicon/types/place/wisp/fs";
+
+
export interface UploadedFile {
+
name: string;
+
content: Buffer;
+
mimeType: string;
+
size: number;
+
}
+
+
export interface FileUploadResult {
+
hash: string;
+
blobRef: BlobRef;
+
}
+
+
export interface ProcessedDirectory {
+
directory: Directory;
+
fileCount: number;
+
}
+
+
/**
+
* Process uploaded files into a directory structure
+
*/
+
export function processUploadedFiles(files: UploadedFile[]): ProcessedDirectory {
+
console.log(`๐Ÿ—๏ธ Processing ${files.length} uploaded files`);
+
const entries: Entry[] = [];
+
let fileCount = 0;
+
+
// Group files by directory
+
const directoryMap = new Map<string, UploadedFile[]>();
+
+
for (const file of files) {
+
// Remove any base folder name from the path
+
const normalizedPath = file.name.replace(/^[^\/]*\//, '');
+
const parts = normalizedPath.split('/');
+
+
console.log(`๐Ÿ“„ Processing file: ${file.name} -> normalized: ${normalizedPath}`);
+
+
if (parts.length === 1) {
+
// Root level file
+
console.log(`๐Ÿ“ Root level file: ${parts[0]}`);
+
entries.push({
+
name: parts[0],
+
node: {
+
$type: 'place.wisp.fs#file' as const,
+
type: 'file' as const,
+
blob: undefined as any // Will be filled after upload
+
}
+
});
+
fileCount++;
+
} else {
+
// File in subdirectory
+
const dirPath = parts.slice(0, -1).join('/');
+
console.log(`๐Ÿ“‚ Subdirectory file: ${dirPath}/${parts[parts.length - 1]}`);
+
if (!directoryMap.has(dirPath)) {
+
directoryMap.set(dirPath, []);
+
console.log(`โž• Created directory: ${dirPath}`);
+
}
+
directoryMap.get(dirPath)!.push({
+
...file,
+
name: normalizedPath
+
});
+
}
+
}
+
+
// Process subdirectories
+
console.log(`๐Ÿ“‚ Processing ${directoryMap.size} subdirectories`);
+
for (const [dirPath, dirFiles] of directoryMap) {
+
console.log(`๐Ÿ“ Processing directory: ${dirPath} with ${dirFiles.length} files`);
+
const dirEntries: Entry[] = [];
+
+
for (const file of dirFiles) {
+
const fileName = file.name.split('/').pop()!;
+
console.log(` ๐Ÿ“„ Adding file to directory: ${fileName}`);
+
dirEntries.push({
+
name: fileName,
+
node: {
+
$type: 'place.wisp.fs#file' as const,
+
type: 'file' as const,
+
blob: undefined as any // Will be filled after upload
+
}
+
});
+
fileCount++;
+
}
+
+
// Build nested directory structure
+
const pathParts = dirPath.split('/');
+
let currentEntries = entries;
+
+
console.log(`๐Ÿ—๏ธ Building nested structure for path: ${pathParts.join('/')}`);
+
+
for (let i = 0; i < pathParts.length; i++) {
+
const part = pathParts[i];
+
const isLast = i === pathParts.length - 1;
+
+
let existingEntry = currentEntries.find(e => e.name === part);
+
+
if (!existingEntry) {
+
const newDir = {
+
$type: 'place.wisp.fs#directory' as const,
+
type: 'directory' as const,
+
entries: isLast ? dirEntries : []
+
};
+
+
existingEntry = {
+
name: part,
+
node: newDir
+
};
+
currentEntries.push(existingEntry);
+
console.log(` โž• Created directory entry: ${part}`);
+
} else if ('entries' in existingEntry.node && isLast) {
+
(existingEntry.node as any).entries.push(...dirEntries);
+
console.log(` ๐Ÿ“ Added files to existing directory: ${part}`);
+
}
+
+
if (existingEntry && 'entries' in existingEntry.node) {
+
currentEntries = (existingEntry.node as any).entries;
+
}
+
}
+
}
+
+
console.log(`โœ… Directory structure completed with ${fileCount} total files`);
+
+
const result = {
+
directory: {
+
$type: 'place.wisp.fs#directory' as const,
+
type: 'directory' as const,
+
entries
+
},
+
fileCount
+
};
+
+
console.log('๐Ÿ“‹ Final directory structure:', JSON.stringify(result, null, 2));
+
return result;
+
}
+
+
/**
+
* Create the manifest record for a site
+
*/
+
export function createManifest(
+
siteName: string,
+
root: Directory,
+
fileCount: number
+
): Record {
+
const manifest: Record = {
+
$type: 'place.wisp.fs' as const,
+
site: siteName,
+
root,
+
fileCount,
+
createdAt: new Date().toISOString()
+
};
+
+
console.log(`๐Ÿ“‹ Created manifest for site "${siteName}" with ${fileCount} files`);
+
console.log('๐Ÿ“„ Manifest structure:', JSON.stringify(manifest, null, 2));
+
+
return manifest;
+
}
+
+
/**
+
* Update file blobs in directory structure after upload
+
*/
+
export function updateFileBlobs(
+
directory: Directory,
+
uploadResults: FileUploadResult[],
+
filePaths: string[]
+
): Directory {
+
console.log(`๐Ÿ”„ Updating file blobs: ${uploadResults.length} results for ${filePaths.length} paths`);
+
+
const updatedEntries = directory.entries.map(entry => {
+
if ('type' in entry.node && entry.node.type === 'file') {
+
const fileIndex = filePaths.findIndex(path => path.endsWith(entry.name));
+
if (fileIndex !== -1 && uploadResults[fileIndex]) {
+
console.log(` ๐Ÿ”— Updating blob for file: ${entry.name} -> ${uploadResults[fileIndex].hash}`);
+
return {
+
...entry,
+
node: {
+
$type: 'place.wisp.fs#file' as const,
+
type: 'file' as const,
+
blob: uploadResults[fileIndex].blobRef
+
}
+
};
+
} else {
+
console.warn(` โš ๏ธ Could not find upload result for file: ${entry.name}`);
+
}
+
} else if ('type' in entry.node && entry.node.type === 'directory') {
+
console.log(` ๐Ÿ“‚ Recursively updating directory: ${entry.name}`);
+
return {
+
...entry,
+
node: updateFileBlobs(entry.node as Directory, uploadResults, filePaths)
+
};
+
}
+
return entry;
+
}) as Entry[];
+
+
const result = {
+
$type: 'place.wisp.fs#directory' as const,
+
type: 'directory' as const,
+
entries: updatedEntries
+
};
+
+
console.log('โœ… File blobs updated');
+
return result;
+
}
+25
src/routes/auth.ts
···
+
import { Elysia } from 'elysia'
+
import { NodeOAuthClient } from '@atproto/oauth-client-node'
+
+
export const authRoutes = (client: NodeOAuthClient) => new Elysia()
+
.post('/api/auth/signin', async (c) => {
+
try {
+
const { handle } = await c.request.json()
+
const state = crypto.randomUUID()
+
const url = await client.authorize(handle, { state })
+
return { url: url.toString() }
+
} catch (err) {
+
console.error('Signin error', err)
+
return { error: 'Authentication failed' }
+
}
+
})
+
.get('/api/auth/callback', async (c) => {
+
const params = new URLSearchParams(c.query)
+
const { session } = await client.callback(params)
+
if (!session) return { error: 'Authentication failed' }
+
+
const cookieSession = c.cookie
+
cookieSession.did.value = session.did
+
+
return c.redirect('/editor')
+
})
+230
src/routes/wisp.ts
···
+
import { Elysia } from 'elysia'
+
import { requireAuth, type AuthenticatedContext } from '../lib/wisp-auth'
+
import { NodeOAuthClient } from '@atproto/oauth-client-node'
+
import { Agent } from '@atproto/api'
+
import {
+
type UploadedFile,
+
type FileUploadResult,
+
processUploadedFiles,
+
createManifest,
+
updateFileBlobs
+
} from '../lib/wisp-utils'
+
+
export const wispRoutes = (client: NodeOAuthClient) =>
+
new Elysia({ prefix: '/wisp' })
+
.derive(async ({ cookie }) => {
+
const auth = await requireAuth(client, cookie)
+
return { auth }
+
})
+
.post(
+
'/upload-files',
+
async ({ body, auth }) => {
+
const { siteName, files } = body as {
+
siteName: string;
+
files: File | File[]
+
};
+
+
console.log('๐Ÿš€ Starting upload process', { siteName, fileCount: Array.isArray(files) ? files.length : 1 });
+
+
try {
+
if (!files || (Array.isArray(files) ? files.length === 0 : !files)) {
+
console.error('โŒ No files provided');
+
throw new Error('No files provided')
+
}
+
+
if (!siteName) {
+
console.error('โŒ Site name is required');
+
throw new Error('Site name is required')
+
}
+
+
console.log('โœ… Initial validation passed');
+
+
// Create agent with OAuth session
+
console.log('๐Ÿ” Creating agent with OAuth session');
+
const agent = new Agent((url, init) => auth.session.fetchHandler(url, init))
+
console.log('โœ… Agent created successfully');
+
+
// Convert File objects to UploadedFile format
+
// Elysia gives us File objects directly, handle both single file and array
+
const fileArray = Array.isArray(files) ? files : [files];
+
console.log(`๐Ÿ“ Processing ${fileArray.length} files`);
+
const uploadedFiles: UploadedFile[] = [];
+
+
// Define allowed file extensions for static site hosting
+
const allowedExtensions = new Set([
+
// HTML
+
'.html', '.htm',
+
// CSS
+
'.css',
+
// JavaScript
+
'.js', '.mjs', '.jsx', '.ts', '.tsx',
+
// Images
+
'.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp', '.ico', '.avif',
+
// Fonts
+
'.woff', '.woff2', '.ttf', '.otf', '.eot',
+
// Documents
+
'.pdf', '.txt',
+
// JSON (for config files, but not .map files)
+
'.json',
+
// Audio/Video
+
'.mp3', '.mp4', '.webm', '.ogg', '.wav',
+
// Other web assets
+
'.xml', '.rss', '.atom'
+
]);
+
+
// Files to explicitly exclude
+
const excludedFiles = new Set([
+
'.map', '.DS_Store', 'Thumbs.db'
+
]);
+
+
for (let i = 0; i < fileArray.length; i++) {
+
const file = fileArray[i];
+
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
+
+
console.log(`๐Ÿ“„ Processing file ${i + 1}/${fileArray.length}: ${file.name} (${file.size} bytes, ${file.type})`);
+
+
// Skip excluded files
+
if (excludedFiles.has(fileExtension)) {
+
console.log(`โญ๏ธ Skipping excluded file: ${file.name}`);
+
continue;
+
}
+
+
// Skip files that aren't in allowed extensions
+
if (!allowedExtensions.has(fileExtension)) {
+
console.log(`โญ๏ธ Skipping non-web file: ${file.name} (${fileExtension})`);
+
continue;
+
}
+
+
// Skip files that are too large (limit to 100MB per file)
+
const maxSize = 100 * 1024 * 1024; // 100MB
+
if (file.size > maxSize) {
+
console.log(`โญ๏ธ Skipping large file: ${file.name} (${(file.size / 1024 / 1024).toFixed(2)}MB > 100MB limit)`);
+
continue;
+
}
+
+
console.log(`โœ… Including file: ${file.name}`);
+
const arrayBuffer = await file.arrayBuffer();
+
uploadedFiles.push({
+
name: file.name,
+
content: Buffer.from(arrayBuffer),
+
mimeType: file.type || 'application/octet-stream',
+
size: file.size
+
});
+
}
+
+
// Check total size limit (300MB)
+
const totalSize = uploadedFiles.reduce((sum, file) => sum + file.size, 0);
+
const maxTotalSize = 300 * 1024 * 1024; // 300MB
+
+
console.log(`๐Ÿ“Š Filtered to ${uploadedFiles.length} files from ${fileArray.length} total files`);
+
console.log(`๐Ÿ“ฆ Total size: ${(totalSize / 1024 / 1024).toFixed(2)}MB (limit: 300MB)`);
+
+
if (totalSize > maxTotalSize) {
+
throw new Error(`Total upload size ${(totalSize / 1024 / 1024).toFixed(2)}MB exceeds 300MB limit`);
+
}
+
+
if (uploadedFiles.length === 0) {
+
throw new Error('No valid web files found to upload. Allowed types: HTML, CSS, JS, images, fonts, PDFs, and other web assets.');
+
}
+
+
console.log('โœ… File conversion completed');
+
+
// Process files into directory structure
+
console.log('๐Ÿ—๏ธ Building directory structure');
+
const { directory, fileCount } = processUploadedFiles(uploadedFiles);
+
console.log(`โœ… Directory structure created with ${fileCount} files`);
+
+
// Upload files as blobs
+
const uploadResults: FileUploadResult[] = [];
+
const filePaths: string[] = [];
+
+
console.log('โฌ†๏ธ Starting blob upload process');
+
for (let i = 0; i < uploadedFiles.length; i++) {
+
const file = uploadedFiles[i];
+
console.log(`๐Ÿ“ค Uploading blob ${i + 1}/${uploadedFiles.length}: ${file.name}`);
+
+
try {
+
console.log(`๐Ÿ” Upload details:`, {
+
fileName: file.name,
+
fileSize: file.size,
+
mimeType: file.mimeType,
+
contentLength: file.content.length
+
});
+
+
const uploadResult = await agent.com.atproto.repo.uploadBlob(
+
file.content,
+
{
+
encoding: file.mimeType
+
}
+
);
+
+
console.log(`โœ… Upload successful for ${file.name}:`, {
+
hash: uploadResult.data.blob.ref.toString(),
+
mimeType: uploadResult.data.blob.mimeType,
+
size: uploadResult.data.blob.size
+
});
+
+
uploadResults.push({
+
hash: uploadResult.data.blob.ref.toString(),
+
blobRef: uploadResult.data.blob
+
});
+
+
filePaths.push(file.name);
+
} catch (uploadError) {
+
console.error(`โŒ Upload failed for file ${file.name}:`, uploadError);
+
console.error('Upload error details:', {
+
fileName: file.name,
+
fileSize: file.size,
+
mimeType: file.mimeType,
+
error: uploadError
+
});
+
throw uploadError;
+
}
+
}
+
+
console.log('โœ… All blobs uploaded successfully');
+
+
// Update directory with file blobs
+
console.log('๐Ÿ”„ Updating file blobs in directory structure');
+
const updatedDirectory = updateFileBlobs(directory, uploadResults, filePaths);
+
console.log('โœ… File blobs updated');
+
+
// Create manifest
+
console.log('๐Ÿ“‹ Creating manifest');
+
const manifest = createManifest(siteName, updatedDirectory, fileCount);
+
console.log('โœ… Manifest created');
+
+
// Create the record
+
console.log('๐Ÿ“ Creating record in repo');
+
const record = await agent.com.atproto.repo.createRecord({
+
repo: auth.did,
+
collection: 'place.wisp.fs',
+
record: manifest
+
});
+
+
console.log('โœ… Record created successfully:', {
+
uri: record.data.uri,
+
cid: record.data.cid
+
});
+
+
const result = {
+
success: true,
+
uri: record.data.uri,
+
cid: record.data.cid,
+
fileCount,
+
siteName
+
};
+
+
console.log('๐ŸŽ‰ Upload process completed successfully');
+
return result;
+
} catch (error) {
+
console.error('โŒ Upload error:', error);
+
console.error('Error details:', {
+
message: error instanceof Error ? error.message : 'Unknown error',
+
stack: error instanceof Error ? error.stack : undefined,
+
name: error instanceof Error ? error.name : undefined
+
});
+
throw new Error(`Failed to upload files: ${error instanceof Error ? error.message : 'Unknown error'}`);
+
}
+
}
+
)