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

Configure Feed

Select the types of activity you want to include in your feed.

node compat

+212 -184
-106
cli/CLAUDE.md
··· 1 - 2 - Default to using Bun instead of Node.js. 3 - 4 - - Use `bun <file>` instead of `node <file>` or `ts-node <file>` 5 - - Use `bun test` instead of `jest` or `vitest` 6 - - Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild` 7 - - Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` 8 - - Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>` 9 - - Use `bunx <package> <command>` instead of `npx <package> <command>` 10 - - Bun automatically loads .env, so don't use dotenv. 11 - 12 - ## APIs 13 - 14 - - `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`. 15 - - `bun:sqlite` for SQLite. Don't use `better-sqlite3`. 16 - - `Bun.redis` for Redis. Don't use `ioredis`. 17 - - `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`. 18 - - `WebSocket` is built-in. Don't use `ws`. 19 - - Prefer `Bun.file` over `node:fs`'s readFile/writeFile 20 - - Bun.$`ls` instead of execa. 21 - 22 - ## Testing 23 - 24 - Use `bun test` to run tests. 25 - 26 - ```ts#index.test.ts 27 - import { test, expect } from "bun:test"; 28 - 29 - test("hello world", () => { 30 - expect(1).toBe(1); 31 - }); 32 - ``` 33 - 34 - ## Frontend 35 - 36 - Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind. 37 - 38 - Server: 39 - 40 - ```ts#index.ts 41 - import index from "./index.html" 42 - 43 - Bun.serve({ 44 - routes: { 45 - "/": index, 46 - "/api/users/:id": { 47 - GET: (req) => { 48 - return new Response(JSON.stringify({ id: req.params.id })); 49 - }, 50 - }, 51 - }, 52 - // optional websocket support 53 - websocket: { 54 - open: (ws) => { 55 - ws.send("Hello, world!"); 56 - }, 57 - message: (ws, message) => { 58 - ws.send(message); 59 - }, 60 - close: (ws) => { 61 - // handle close 62 - } 63 - }, 64 - development: { 65 - hmr: true, 66 - console: true, 67 - } 68 - }) 69 - ``` 70 - 71 - HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle. 72 - 73 - ```html#index.html 74 - <html> 75 - <body> 76 - <h1>Hello, world!</h1> 77 - <script type="module" src="./frontend.tsx"></script> 78 - </body> 79 - </html> 80 - ``` 81 - 82 - With the following `frontend.tsx`: 83 - 84 - ```tsx#frontend.tsx 85 - import React from "react"; 86 - import { createRoot } from "react-dom/client"; 87 - 88 - // import .css files directly and it works 89 - import './index.css'; 90 - 91 - const root = createRoot(document.body); 92 - 93 - export default function Frontend() { 94 - return <h1>Hello, world!</h1>; 95 - } 96 - 97 - root.render(<Frontend />); 98 - ``` 99 - 100 - Then, run index.ts 101 - 102 - ```sh 103 - bun --hot ./index.ts 104 - ``` 105 - 106 - For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
+56 -5
cli/commands/deploy.ts
··· 9 9 estimateDirectorySize, 10 10 findLargeDirectories, 11 11 replaceDirectoryWithSubfs, 12 + splitDirectoryIntoChunks, 12 13 countFilesInDirectory, 13 14 type UploadedFile, 14 15 type FileUploadResult ··· 281 282 const subfsRkeys: string[] = []; 282 283 let currentDir = directory; 283 284 let iteration = 0; 285 + let chunkCounter = 0; 284 286 285 287 while ( 286 288 (estimateDirectorySize(currentDir) > MAX_MANIFEST_SIZE || ··· 297 299 if (largeDirs.length === 0) break; 298 300 299 301 const largest = largeDirs[0]!; 300 - const subfsRkey = `${siteRkey}-subfs-${iteration}`; 301 - 302 302 spinner.text = `Creating subfs ${iteration} for ${largest.path} (${formatBytes(largest.size)})`; 303 303 304 - // Create subfs record for this directory 305 - const subfsUri = await createSubfsRecord(agent, did, largest.directory, subfsRkey); 306 - subfsRkeys.push(subfsRkey); 304 + let subfsUri: string; 305 + 306 + // Check if directory is too large for a single subfs record 307 + if (largest.size > MAX_SUBFS_SIZE) { 308 + // Split into chunks 309 + console.log(pc.dim(`\n → Directory too large (${formatBytes(largest.size)}), splitting into chunks...`)); 310 + const chunks = splitDirectoryIntoChunks(largest.directory, MAX_SUBFS_SIZE); 311 + console.log(pc.dim(` → Created ${chunks.length} chunks`)); 312 + 313 + // Upload each chunk as a subfs record 314 + const chunkUris: string[] = []; 315 + for (let i = 0; i < chunks.length; i++) { 316 + const chunk = chunks[i]!; 317 + const chunkRkey = `${siteRkey}-chunk-${chunkCounter++}`; 318 + const chunkSize = estimateDirectorySize(chunk); 319 + const chunkFileCount = countFilesInDirectory(chunk); 320 + 321 + console.log(pc.dim(` → Uploading chunk ${i + 1}/${chunks.length} (${chunkFileCount} files, ${formatBytes(chunkSize)})...`)); 322 + 323 + const chunkUri = await createSubfsRecord(agent, did, chunk, chunkRkey); 324 + chunkUris.push(chunkUri); 325 + subfsRkeys.push(chunkRkey); 326 + } 327 + 328 + // Create parent subfs that references all chunks with flat: true 329 + console.log(pc.dim(` → Creating parent subfs with ${chunkUris.length} chunk references...`)); 330 + 331 + const parentEntries = chunkUris.map((uri, i) => ({ 332 + name: `chunk${i}`, 333 + node: { 334 + $type: 'place.wisp.fs#subfs' as const, 335 + type: 'subfs' as const, 336 + subject: uri, 337 + flat: true // Merge chunk contents into parent 338 + } 339 + })); 340 + 341 + const parentDirectory: Directory = { 342 + $type: 'place.wisp.fs#directory' as const, 343 + type: 'directory' as const, 344 + entries: parentEntries 345 + }; 346 + 347 + const parentRkey = `${siteRkey}-subfs-${iteration}`; 348 + subfsUri = await createSubfsRecord(agent, did, parentDirectory, parentRkey); 349 + subfsRkeys.push(parentRkey); 350 + 351 + console.log(pc.green(` ✓ Created parent subfs with ${chunks.length} chunks`)); 352 + } else { 353 + // Directory fits in a single subfs record 354 + const subfsRkey = `${siteRkey}-subfs-${iteration}`; 355 + subfsUri = await createSubfsRecord(agent, did, largest.directory, subfsRkey); 356 + subfsRkeys.push(subfsRkey); 357 + } 307 358 308 359 // Replace directory with subfs reference 309 360 currentDir = replaceDirectoryWithSubfs(currentDir, largest.path, subfsUri);
+3 -3
cli/commands/pull.ts
··· 3 3 import type { Record as SubfsRecord } from '@wisp/lexicons/types/place/wisp/subfs'; 4 4 import { extractBlobCid } from '@wisp/atproto-utils'; 5 5 import { sanitizePath } from '@wisp/fs-utils'; 6 - import { existsSync, mkdirSync, writeFileSync, rmSync, renameSync } from 'fs'; 6 + import { existsSync, mkdirSync, writeFileSync, rmSync, renameSync, readFileSync } from 'fs'; 7 7 import { dirname, join } from 'path'; 8 8 import { gunzipSync } from 'zlib'; 9 9 import { createSpinner, formatBytes, pc } from '../lib/progress.ts'; ··· 364 364 365 365 if (existsSync(srcPath)) { 366 366 mkdirSync(dirname(destPath), { recursive: true }); 367 - const content = Bun.file(srcPath).arrayBuffer(); 368 - writeFileSync(destPath, Buffer.from(await content)); 367 + const content = readFileSync(srcPath); 368 + writeFileSync(destPath, content); 369 369 } 370 370 } 371 371 }
+80 -38
cli/commands/serve.ts
··· 1 1 import { AtpAgent } from '@atproto/api'; 2 2 import { IdResolver } from '@atproto/identity'; 3 - import { BunFirehose } from '../lib/firehose'; 3 + import { Firehose } from '@atproto/sync'; 4 + import { Hono } from 'hono'; 5 + import { serve as honoNodeServe } from '@hono/node-server'; 4 6 import type { Record as SettingsRecord } from '@wisp/lexicons/types/place/wisp/settings'; 5 7 import { existsSync, readFileSync, statSync, readdirSync } from 'fs'; 6 8 import { join, extname } from 'path'; ··· 8 10 import { pull } from './pull.ts'; 9 11 import { createSpinner, pc } from '../lib/progress.ts'; 10 12 import { parseRedirectsFile, matchRedirectRule, parseQueryString, type RedirectRule } from '../lib/redirects.ts'; 13 + import { isBun } from '../lib/runtime.ts'; 14 + import { BunFirehose } from '../lib/firehose.ts'; 11 15 12 16 export interface ServeOptions { 13 17 site: string; ··· 308 312 redirectRules 309 313 }; 310 314 311 - // 5. Start HTTP server 312 - const server = Bun.serve({ 313 - port, 314 - fetch(req) { 315 - return handleRequest(req, state); 316 - } 315 + // 5. Start HTTP server with Hono (works on both Bun and Node) 316 + const app = new Hono(); 317 + 318 + app.all('*', (c) => { 319 + const req = c.req.raw; 320 + return handleRequest(req, state); 317 321 }); 318 322 323 + let serverHandle: { close: () => void }; 324 + 325 + if (isBun) { 326 + // @ts-ignore - Bun global 327 + const bunServer = Bun.serve({ 328 + port, 329 + fetch: app.fetch, 330 + }); 331 + serverHandle = { close: () => bunServer.stop() }; 332 + } else { 333 + const nodeServer = honoNodeServe({ 334 + fetch: app.fetch, 335 + port, 336 + }); 337 + serverHandle = { close: () => nodeServer.close() }; 338 + } 339 + 319 340 console.log(pc.green(`\n✓ Server running at http://localhost:${port}\n`)); 320 341 console.log(pc.dim('Watching for updates via firehose...\n')); 321 342 322 - // 6. Connect to firehose for live updates 343 + // 6. Connect to firehose for live updates (runtime-aware) 323 344 const idResolver = new IdResolver(); 324 - const firehose = new BunFirehose({ 325 - idResolver, 326 - service: pdsEndpoint.replace('https://', 'wss://').replace('http://', 'ws://'), 327 - filterCollections: ['place.wisp.fs', 'place.wisp.settings'], 328 - handleEvent: async (evt) => { 329 - // Only handle commit events for this DID 330 - if (evt.event !== 'create' && evt.event !== 'update' && evt.event !== 'delete') return; 331 - if (evt.did !== did) return; 332 - if (evt.rkey !== site) return; 333 345 334 - if (evt.collection === 'place.wisp.fs') { 335 - console.log(pc.yellow('\nSite updated, re-pulling...\n')); 336 - await pull(identifier, { site, path: outputPath }); 346 + const firehoseHandleEvent = async (evt: any) => { 347 + // Only handle commit events for this DID 348 + if (evt.event !== 'create' && evt.event !== 'update' && evt.event !== 'delete') return; 349 + if (evt.did !== did) return; 350 + if (evt.rkey !== site) return; 337 351 338 - // Reload redirects 339 - state.redirectRules = loadRedirectRules(outputPath); 340 - console.log(pc.green('✓ Site reloaded\n')); 341 - } else if (evt.collection === 'place.wisp.settings') { 342 - console.log(pc.yellow('\nSettings updated...\n')); 343 - state.settings = await fetchSettings(pdsEndpoint, did, site); 344 - console.log(pc.green('✓ Settings reloaded\n')); 345 - } 346 - }, 347 - onError: (err: Error) => { 348 - console.error(pc.red('Firehose error:'), err.message); 349 - if (err.cause) { 350 - console.error(pc.red(' Cause:'), err.cause); 351 - } 352 + if (evt.collection === 'place.wisp.fs') { 353 + console.log(pc.yellow('\nSite updated, re-pulling...\n')); 354 + await pull(identifier, { site, path: outputPath }); 355 + 356 + // Reload redirects 357 + state.redirectRules = loadRedirectRules(outputPath); 358 + console.log(pc.green('✓ Site reloaded\n')); 359 + } else if (evt.collection === 'place.wisp.settings') { 360 + console.log(pc.yellow('\nSettings updated...\n')); 361 + state.settings = await fetchSettings(pdsEndpoint, did, site); 362 + console.log(pc.green('✓ Settings reloaded\n')); 352 363 } 353 - }); 364 + }; 354 365 355 - firehose.start(); 366 + const firehoseOnError = (err: Error) => { 367 + console.error(pc.red('Firehose error:'), err.message); 368 + if (err.cause) { 369 + console.error(pc.red(' Cause:'), err.cause); 370 + } 371 + }; 372 + 373 + let firehoseHandle: { destroy: () => void }; 374 + 375 + if (isBun) { 376 + // Use BunFirehose for Bun (native WebSocket) 377 + const bunFirehose = new BunFirehose({ 378 + idResolver, 379 + service: pdsEndpoint.replace('https://', 'wss://').replace('http://', 'ws://'), 380 + filterCollections: ['place.wisp.fs', 'place.wisp.settings'], 381 + handleEvent: firehoseHandleEvent, 382 + onError: firehoseOnError, 383 + }); 384 + bunFirehose.start(); 385 + firehoseHandle = { destroy: () => bunFirehose.destroy() }; 386 + } else { 387 + // Use @atproto/sync Firehose for Node.js (uses ws library) 388 + const nodeFirehose = new Firehose({ 389 + idResolver, 390 + service: pdsEndpoint.replace('https://', 'wss://').replace('http://', 'ws://'), 391 + filterCollections: ['place.wisp.fs', 'place.wisp.settings'], 392 + handleEvent: firehoseHandleEvent, 393 + onError: firehoseOnError, 394 + }); 395 + nodeFirehose.start(); 396 + firehoseHandle = { destroy: () => nodeFirehose.destroy() }; 397 + } 356 398 357 399 // Handle shutdown 358 400 process.on('SIGINT', () => { 359 401 console.log(pc.dim('\nShutting down...')); 360 - firehose.destroy(); 361 - server.stop(); 402 + firehoseHandle.destroy(); 403 + serverHandle.close(); 362 404 process.exit(0); 363 405 }); 364 406
+43 -27
cli/lib/auth.ts
··· 1 1 import { NodeOAuthClient, type NodeSavedSession, type NodeSavedState, type NodeSavedStateStore, type NodeSavedSessionStore } from "@atproto/oauth-client-node"; 2 2 import { Agent, CredentialSession } from "@atproto/api"; 3 + import { Hono } from "hono"; 4 + import { serve as honoNodeServe } from "@hono/node-server"; 3 5 import open from "open"; 4 6 import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "fs"; 5 7 import { dirname, join } from "path"; 6 8 import { homedir } from "os"; 9 + import { isBun } from "./runtime"; 7 10 8 11 // OAuth scope for CLI 9 12 const OAUTH_SCOPE = 'atproto repo:place.wisp.fs repo:place.wisp.subfs repo:place.wisp.settings blob:*/*'; ··· 148 151 149 152 // Create loopback server to receive callback 150 153 const callbackPromise = new Promise<{ params: URLSearchParams }>((resolve, reject) => { 151 - const server = Bun.serve({ 152 - port: LOOPBACK_PORT, 153 - hostname: LOOPBACK_HOST, 154 - fetch(req) { 155 - const url = new URL(req.url); 154 + const app = new Hono(); 155 + let serverHandle: { close: () => void } | null = null; 156 156 157 - if (url.pathname === '/oauth/callback') { 158 - const params = new URLSearchParams(url.search); 157 + const successHtml = ` 158 + <html> 159 + <head><title>Wisp CLI - Authentication Successful</title></head> 160 + <body style="font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0;"> 161 + <div style="text-align: center;"> 162 + <h1>Authentication Successful</h1> 163 + <p>You can close this window and return to the CLI.</p> 164 + </div> 165 + </body> 166 + </html> 167 + `; 159 168 160 - // Close server after receiving callback 161 - setTimeout(() => server.stop(), 100); 169 + app.get('/oauth/callback', (c) => { 170 + const params = new URLSearchParams(c.req.url.split('?')[1] || ''); 162 171 163 - resolve({ params }); 172 + // Close server after receiving callback 173 + setTimeout(() => serverHandle?.close(), 100); 164 174 165 - return new Response(` 166 - <html> 167 - <head><title>Wisp CLI - Authentication Successful</title></head> 168 - <body style="font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0;"> 169 - <div style="text-align: center;"> 170 - <h1>Authentication Successful</h1> 171 - <p>You can close this window and return to the CLI.</p> 172 - </div> 173 - </body> 174 - </html> 175 - `, { 176 - headers: { 'Content-Type': 'text/html' } 177 - }); 178 - } 175 + resolve({ params }); 179 176 180 - return new Response('Not found', { status: 404 }); 181 - }, 177 + return c.html(successHtml); 182 178 }); 183 179 180 + app.all('*', (c) => c.text('Not found', 404)); 181 + 182 + // Start server based on runtime 183 + if (isBun) { 184 + // @ts-ignore - Bun global 185 + const bunServer = Bun.serve({ 186 + port: LOOPBACK_PORT, 187 + hostname: LOOPBACK_HOST, 188 + fetch: app.fetch, 189 + }); 190 + serverHandle = { close: () => bunServer.stop() }; 191 + } else { 192 + const nodeServer = honoNodeServe({ 193 + fetch: app.fetch, 194 + port: LOOPBACK_PORT, 195 + hostname: LOOPBACK_HOST, 196 + }); 197 + serverHandle = { close: () => nodeServer.close() }; 198 + } 199 + 184 200 // Timeout after 5 minutes 185 201 setTimeout(() => { 186 - server.stop(); 202 + serverHandle?.close(); 187 203 reject(new Error('OAuth callback timeout')); 188 204 }, 5 * 60 * 1000); 189 205 });
+2 -2
cli/lib/firehose.ts
··· 4 4 */ 5 5 6 6 import { IdResolver } from '@atproto/identity'; 7 - import { cborToLexRecord, readCar, verifyProofs, parseDataKey, formatDataKey } from '@atproto/repo'; 7 + import { cborToLexRecord, readCar, verifyProofs, parseDataKey, formatDataKey, BlockMap } from '@atproto/repo'; 8 8 import { CID } from 'multiformats/cid'; 9 9 import { AtUri } from '@atproto/syntax'; 10 10 import { BunSubscription } from './subscription'; ··· 14 14 seq: number; 15 15 time: string; 16 16 commit: CID; 17 - blocks: Map<string, Uint8Array>; 17 + blocks: BlockMap; 18 18 rev: string; 19 19 uri: AtUri; 20 20 did: string;
+17
cli/lib/runtime.ts
··· 1 + /** 2 + * Runtime detection utilities for cross-platform compatibility 3 + */ 4 + 5 + declare const Bun: unknown; 6 + 7 + export const isBun = typeof Bun !== 'undefined'; 8 + export const isNode = typeof process !== 'undefined' && !isBun; 9 + 10 + /** 11 + * Get the current runtime name for logging 12 + */ 13 + export function getRuntimeName(): string { 14 + if (isBun) return 'Bun'; 15 + if (isNode) return 'Node.js'; 16 + return 'Unknown'; 17 + }
+1 -1
cli/lib/subscription.ts
··· 23 23 } 24 24 25 25 function decodeFrame(bytes: Uint8Array): { header: FrameHeader; body: unknown } { 26 - const decoded = decodeAll(bytes); 26 + const decoded = [...decodeAll(bytes)]; 27 27 if (decoded.length < 2) { 28 28 throw new Error('Invalid frame: missing header or body'); 29 29 }
+10 -2
cli/package.json
··· 3 3 "version": "1.0.0", 4 4 "description": "CLI for wisp.place - deploy static sites to the AT Protocol", 5 5 "type": "module", 6 + "main": "./dist/index.js", 6 7 "bin": { 7 - "wisp-cli": "./index.ts" 8 + "wisp-cli": "./dist/index.js" 8 9 }, 10 + "files": [ 11 + "dist" 12 + ], 9 13 "scripts": { 10 14 "dev": "bun run index.ts", 15 + "build": "bun build ./index.ts --outdir ./dist --target node --sourcemap=linked --minify", 11 16 "typecheck": "tsc --noEmit" 12 17 }, 13 18 "dependencies": { ··· 19 24 "@atproto/repo": "^0.8.12", 20 25 "@atproto/sync": "^0.1.39", 21 26 "@atproto/syntax": "^0.4.3", 27 + "@hono/node-server": "^1.13.8", 28 + "hono": "^4.7.4", 22 29 "multiformats": "^13.4.2", 23 30 "@clack/prompts": "^0.10.0", 24 31 "@wisp/atproto-utils": "workspace:*", ··· 33 40 }, 34 41 "devDependencies": { 35 42 "@types/bun": "latest", 36 - "@types/mime-types": "^3.0.1" 43 + "@types/mime-types": "^3.0.1", 44 + "@types/node": "^22.0.0" 37 45 }, 38 46 "peerDependencies": { 39 47 "typescript": "^5"