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

hosting service now is using node.js for runtime, better lexicon validation across main app and microservice

Changed files
+756 -253
hosting-service
src
lexicons
types
place
wisp
lib
routes
-60
hosting-service/bun.lock
··· 1 - { 2 - "lockfileVersion": 1, 3 - "workspaces": { 4 - "": { 5 - "name": "wisp-hosting-service", 6 - "dependencies": { 7 - "@atproto/api": "^0.13.20", 8 - "@atproto/xrpc": "^0.6.4", 9 - "hono": "^4.6.14", 10 - "postgres": "^3.4.5", 11 - }, 12 - "devDependencies": { 13 - "@types/bun": "latest", 14 - }, 15 - }, 16 - }, 17 - "packages": { 18 - "@atproto/api": ["@atproto/api@0.13.35", "", { "dependencies": { "@atproto/common-web": "^0.4.0", "@atproto/lexicon": "^0.4.6", "@atproto/syntax": "^0.3.2", "@atproto/xrpc": "^0.6.8", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-vsEfBj0C333TLjDppvTdTE0IdKlXuljKSveAeI4PPx/l6eUKNnDTsYxvILtXUVzwUlTDmSRqy5O4Ryh78n1b7g=="], 19 - 20 - "@atproto/common-web": ["@atproto/common-web@0.4.3", "", { "dependencies": { "graphemer": "^1.4.0", "multiformats": "^9.9.0", "uint8arrays": "3.0.0", "zod": "^3.23.8" } }, "sha512-nRDINmSe4VycJzPo6fP/hEltBcULFxt9Kw7fQk6405FyAWZiTluYHlXOnU7GkQfeUK44OENG1qFTBcmCJ7e8pg=="], 21 - 22 - "@atproto/lexicon": ["@atproto/lexicon@0.4.14", "", { "dependencies": { "@atproto/common-web": "^0.4.2", "@atproto/syntax": "^0.4.0", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-jiKpmH1QER3Gvc7JVY5brwrfo+etFoe57tKPQX/SmPwjvUsFnJAow5xLIryuBaJgFAhnTZViXKs41t//pahGHQ=="], 23 - 24 - "@atproto/syntax": ["@atproto/syntax@0.3.4", "", {}, "sha512-8CNmi5DipOLaVeSMPggMe7FCksVag0aO6XZy9WflbduTKM4dFZVCs4686UeMLfGRXX+X966XgwECHoLYrovMMg=="], 25 - 26 - "@atproto/xrpc": ["@atproto/xrpc@0.6.12", "", { "dependencies": { "@atproto/lexicon": "^0.4.10", "zod": "^3.23.8" } }, "sha512-Ut3iISNLujlmY9Gu8sNU+SPDJDvqlVzWddU8qUr0Yae5oD4SguaUFjjhireMGhQ3M5E0KljQgDbTmnBo1kIZ3w=="], 27 - 28 - "@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="], 29 - 30 - "@types/node": ["@types/node@24.9.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg=="], 31 - 32 - "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], 33 - 34 - "await-lock": ["await-lock@2.2.2", "", {}, "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw=="], 35 - 36 - "bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="], 37 - 38 - "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], 39 - 40 - "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], 41 - 42 - "hono": ["hono@4.10.2", "", {}, "sha512-p6fyzl+mQo6uhESLxbF5WlBOAJMDh36PljwlKtP5V1v09NxlqGru3ShK+4wKhSuhuYf8qxMmrivHOa/M7q0sMg=="], 43 - 44 - "iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="], 45 - 46 - "multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 47 - 48 - "postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="], 49 - 50 - "tlds": ["tlds@1.261.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA=="], 51 - 52 - "uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA=="], 53 - 54 - "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 55 - 56 - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 57 - 58 - "@atproto/lexicon/@atproto/syntax": ["@atproto/syntax@0.4.1", "", {}, "sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw=="], 59 - } 60 - }
+13 -5
hosting-service/package.json
··· 3 3 "version": "1.0.0", 4 4 "type": "module", 5 5 "scripts": { 6 - "dev": "bun --watch src/index.ts", 7 - "start": "bun src/index.ts" 6 + "dev": "tsx watch src/index.ts", 7 + "start": "node --loader tsx src/index.ts" 8 8 }, 9 9 "dependencies": { 10 + "@atproto/api": "^0.17.4", 11 + "@atproto/identity": "^0.4.9", 12 + "@atproto/lexicon": "^0.5.1", 13 + "@atproto/sync": "^0.1.35", 14 + "@atproto/xrpc": "^0.7.5", 15 + "@hono/node-server": "^1.13.7", 10 16 "hono": "^4.6.14", 11 - "@atproto/api": "^0.13.20", 12 - "@atproto/xrpc": "^0.6.4", 17 + "mime-types": "^2.1.35", 18 + "multiformats": "^13.4.1", 13 19 "postgres": "^3.4.5" 14 20 }, 15 21 "devDependencies": { 16 - "@types/bun": "latest" 22 + "@types/mime-types": "^2.1.4", 23 + "@types/node": "^22.10.5", 24 + "tsx": "^4.19.2" 17 25 } 18 26 }
+9 -9
hosting-service/src/index.ts
··· 1 - import { serve } from 'bun'; 2 - import app from './server'; 3 - import { FirehoseWorker } from './lib/firehose'; 1 + import { serve } from '@hono/node-server'; 2 + import app from './server.js'; 3 + import { FirehoseWorker } from './lib/firehose.js'; 4 4 import { mkdirSync, existsSync } from 'fs'; 5 5 6 - const PORT = process.env.PORT || 3001; 6 + const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3001; 7 7 const CACHE_DIR = './cache/sites'; 8 8 9 9 // Ensure cache directory exists ··· 40 40 Server: http://localhost:${PORT} 41 41 Health: http://localhost:${PORT}/health 42 42 Cache: ${CACHE_DIR} 43 - Firehose: Connected to Jetstream 43 + Firehose: Connected to Firehose 44 44 `); 45 45 46 46 // Graceful shutdown 47 - process.on('SIGINT', () => { 47 + process.on('SIGINT', async () => { 48 48 console.log('\n🛑 Shutting down...'); 49 49 firehose.stop(); 50 - server.stop(); 50 + server.close(); 51 51 process.exit(0); 52 52 }); 53 53 54 - process.on('SIGTERM', () => { 54 + process.on('SIGTERM', async () => { 55 55 console.log('\n🛑 Shutting down...'); 56 56 firehose.stop(); 57 - server.stop(); 57 + server.close(); 58 58 process.exit(0); 59 59 });
+127
hosting-service/src/lexicon/lexicons.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { 5 + type LexiconDoc, 6 + Lexicons, 7 + ValidationError, 8 + type ValidationResult, 9 + } from '@atproto/lexicon' 10 + import { type $Typed, is$typed, maybe$typed } from './util.js' 11 + 12 + export const schemaDict = { 13 + PlaceWispFs: { 14 + lexicon: 1, 15 + id: 'place.wisp.fs', 16 + defs: { 17 + main: { 18 + type: 'record', 19 + description: 'Virtual filesystem manifest for a Wisp site', 20 + record: { 21 + type: 'object', 22 + required: ['site', 'root', 'createdAt'], 23 + properties: { 24 + site: { 25 + type: 'string', 26 + }, 27 + root: { 28 + type: 'ref', 29 + ref: 'lex:place.wisp.fs#directory', 30 + }, 31 + fileCount: { 32 + type: 'integer', 33 + minimum: 0, 34 + maximum: 1000, 35 + }, 36 + createdAt: { 37 + type: 'string', 38 + format: 'datetime', 39 + }, 40 + }, 41 + }, 42 + }, 43 + file: { 44 + type: 'object', 45 + required: ['type', 'blob'], 46 + properties: { 47 + type: { 48 + type: 'string', 49 + const: 'file', 50 + }, 51 + blob: { 52 + type: 'blob', 53 + accept: ['*/*'], 54 + maxSize: 1000000, 55 + description: 'Content blob ref', 56 + }, 57 + }, 58 + }, 59 + directory: { 60 + type: 'object', 61 + required: ['type', 'entries'], 62 + properties: { 63 + type: { 64 + type: 'string', 65 + const: 'directory', 66 + }, 67 + entries: { 68 + type: 'array', 69 + maxLength: 500, 70 + items: { 71 + type: 'ref', 72 + ref: 'lex:place.wisp.fs#entry', 73 + }, 74 + }, 75 + }, 76 + }, 77 + entry: { 78 + type: 'object', 79 + required: ['name', 'node'], 80 + properties: { 81 + name: { 82 + type: 'string', 83 + maxLength: 255, 84 + }, 85 + node: { 86 + type: 'union', 87 + refs: ['lex:place.wisp.fs#file', 'lex:place.wisp.fs#directory'], 88 + }, 89 + }, 90 + }, 91 + }, 92 + }, 93 + } as const satisfies Record<string, LexiconDoc> 94 + export const schemas = Object.values(schemaDict) satisfies LexiconDoc[] 95 + export const lexicons: Lexicons = new Lexicons(schemas) 96 + 97 + export function validate<T extends { $type: string }>( 98 + v: unknown, 99 + id: string, 100 + hash: string, 101 + requiredType: true, 102 + ): ValidationResult<T> 103 + export function validate<T extends { $type?: string }>( 104 + v: unknown, 105 + id: string, 106 + hash: string, 107 + requiredType?: false, 108 + ): ValidationResult<T> 109 + export function validate( 110 + v: unknown, 111 + id: string, 112 + hash: string, 113 + requiredType?: boolean, 114 + ): ValidationResult { 115 + return (requiredType ? is$typed : maybe$typed)(v, id, hash) 116 + ? lexicons.validate(`${id}#${hash}`, v) 117 + : { 118 + success: false, 119 + error: new ValidationError( 120 + `Must be an object with "${hash === 'main' ? id : `${id}#${hash}`}" $type property`, 121 + ), 122 + } 123 + } 124 + 125 + export const ids = { 126 + PlaceWispFs: 'place.wisp.fs', 127 + } as const
+79
hosting-service/src/lexicon/types/place/wisp/fs.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../lexicons' 7 + import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 8 + 9 + const is$typed = _is$typed, 10 + validate = _validate 11 + const id = 'place.wisp.fs' 12 + 13 + export interface Record { 14 + $type: 'place.wisp.fs' 15 + site: string 16 + root: Directory 17 + fileCount?: number 18 + createdAt: string 19 + [k: string]: unknown 20 + } 21 + 22 + const hashRecord = 'main' 23 + 24 + export function isRecord<V>(v: V) { 25 + return is$typed(v, id, hashRecord) 26 + } 27 + 28 + export function validateRecord<V>(v: V) { 29 + return validate<Record & V>(v, id, hashRecord, true) 30 + } 31 + 32 + export interface File { 33 + $type?: 'place.wisp.fs#file' 34 + type: 'file' 35 + /** Content blob ref */ 36 + blob: BlobRef 37 + } 38 + 39 + const hashFile = 'file' 40 + 41 + export function isFile<V>(v: V) { 42 + return is$typed(v, id, hashFile) 43 + } 44 + 45 + export function validateFile<V>(v: V) { 46 + return validate<File & V>(v, id, hashFile) 47 + } 48 + 49 + export interface Directory { 50 + $type?: 'place.wisp.fs#directory' 51 + type: 'directory' 52 + entries: Entry[] 53 + } 54 + 55 + const hashDirectory = 'directory' 56 + 57 + export function isDirectory<V>(v: V) { 58 + return is$typed(v, id, hashDirectory) 59 + } 60 + 61 + export function validateDirectory<V>(v: V) { 62 + return validate<Directory & V>(v, id, hashDirectory) 63 + } 64 + 65 + export interface Entry { 66 + $type?: 'place.wisp.fs#entry' 67 + name: string 68 + node: $Typed<File> | $Typed<Directory> | { $type: string } 69 + } 70 + 71 + const hashEntry = 'entry' 72 + 73 + export function isEntry<V>(v: V) { 74 + return is$typed(v, id, hashEntry) 75 + } 76 + 77 + export function validateEntry<V>(v: V) { 78 + return validate<Entry & V>(v, id, hashEntry) 79 + }
+82
hosting-service/src/lexicon/util.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + 5 + import { type ValidationResult } from '@atproto/lexicon' 6 + 7 + export type OmitKey<T, K extends keyof T> = { 8 + [K2 in keyof T as K2 extends K ? never : K2]: T[K2] 9 + } 10 + 11 + export type $Typed<V, T extends string = string> = V & { $type: T } 12 + export type Un$Typed<V extends { $type?: string }> = OmitKey<V, '$type'> 13 + 14 + export type $Type<Id extends string, Hash extends string> = Hash extends 'main' 15 + ? Id 16 + : `${Id}#${Hash}` 17 + 18 + function isObject<V>(v: V): v is V & object { 19 + return v != null && typeof v === 'object' 20 + } 21 + 22 + function is$type<Id extends string, Hash extends string>( 23 + $type: unknown, 24 + id: Id, 25 + hash: Hash, 26 + ): $type is $Type<Id, Hash> { 27 + return hash === 'main' 28 + ? $type === id 29 + : // $type === `${id}#${hash}` 30 + typeof $type === 'string' && 31 + $type.length === id.length + 1 + hash.length && 32 + $type.charCodeAt(id.length) === 35 /* '#' */ && 33 + $type.startsWith(id) && 34 + $type.endsWith(hash) 35 + } 36 + 37 + export type $TypedObject< 38 + V, 39 + Id extends string, 40 + Hash extends string, 41 + > = V extends { 42 + $type: $Type<Id, Hash> 43 + } 44 + ? V 45 + : V extends { $type?: string } 46 + ? V extends { $type?: infer T extends $Type<Id, Hash> } 47 + ? V & { $type: T } 48 + : never 49 + : V & { $type: $Type<Id, Hash> } 50 + 51 + export function is$typed<V, Id extends string, Hash extends string>( 52 + v: V, 53 + id: Id, 54 + hash: Hash, 55 + ): v is $TypedObject<V, Id, Hash> { 56 + return isObject(v) && '$type' in v && is$type(v.$type, id, hash) 57 + } 58 + 59 + export function maybe$typed<V, Id extends string, Hash extends string>( 60 + v: V, 61 + id: Id, 62 + hash: Hash, 63 + ): v is V & object & { $type?: $Type<Id, Hash> } { 64 + return ( 65 + isObject(v) && 66 + ('$type' in v ? v.$type === undefined || is$type(v.$type, id, hash) : true) 67 + ) 68 + } 69 + 70 + export type Validator<R = unknown> = (v: unknown) => ValidationResult<R> 71 + export type ValidatorParam<V extends Validator> = 72 + V extends Validator<infer R> ? R : never 73 + 74 + /** 75 + * Utility function that allows to convert a "validate*" utility function into a 76 + * type predicate. 77 + */ 78 + export function asPredicate<V extends Validator>(validate: V) { 79 + return function <T>(v: T): v is T & ValidatorParam<V> { 80 + return validate(v).success 81 + } 82 + }
+70 -163
hosting-service/src/lib/firehose.ts
··· 1 1 import { existsSync, rmSync } from 'fs'; 2 - import type { WispFsRecord } from './types'; 3 2 import { getPdsForDid, downloadAndCacheSite, extractBlobCid, fetchSiteRecord } from './utils'; 4 3 import { upsertSite } from './db'; 5 4 import { safeFetch } from './safe-fetch'; 5 + import { isRecord, validateRecord } from '../lexicon/types/place/wisp/fs'; 6 + import { Firehose } from '@atproto/sync'; 7 + import { IdResolver } from '@atproto/identity'; 6 8 7 9 const CACHE_DIR = './cache/sites'; 8 - const JETSTREAM_URL = 'wss://jetstream2.us-west.bsky.network/subscribe'; 9 - const RECONNECT_DELAY = 5000; // 5 seconds 10 - const MAX_RECONNECT_DELAY = 60000; // 1 minute 11 - 12 - interface JetstreamCommitEvent { 13 - did: string; 14 - time_us: number; 15 - type: 'com' | 'identity' | 'account'; 16 - kind: 'commit'; 17 - commit: { 18 - rev: string; 19 - operation: 'create' | 'update' | 'delete'; 20 - collection: string; 21 - rkey: string; 22 - record?: any; 23 - cid?: string; 24 - }; 25 - } 26 - 27 - interface JetstreamIdentityEvent { 28 - did: string; 29 - time_us: number; 30 - type: 'identity'; 31 - kind: 'update'; 32 - identity: { 33 - did: string; 34 - handle: string; 35 - seq: number; 36 - time: string; 37 - }; 38 - } 39 - 40 - interface JetstreamAccountEvent { 41 - did: string; 42 - time_us: number; 43 - type: 'account'; 44 - kind: 'update' | 'delete'; 45 - account: { 46 - active: boolean; 47 - did: string; 48 - seq: number; 49 - time: string; 50 - }; 51 - } 52 - 53 - type JetstreamEvent = 54 - | JetstreamCommitEvent 55 - | JetstreamIdentityEvent 56 - | JetstreamAccountEvent; 57 10 58 11 export class FirehoseWorker { 59 - private ws: WebSocket | null = null; 60 - private reconnectAttempts = 0; 61 - private reconnectTimeout: Timer | null = null; 12 + private firehose: Firehose | null = null; 13 + private idResolver: IdResolver; 62 14 private isShuttingDown = false; 63 15 private lastEventTime = Date.now(); 64 16 65 17 constructor( 66 18 private logger?: (msg: string, data?: Record<string, unknown>) => void, 67 - ) {} 19 + ) { 20 + this.idResolver = new IdResolver(); 21 + } 68 22 69 23 private log(msg: string, data?: Record<string, unknown>) { 70 24 const log = this.logger || console.log; ··· 80 34 this.log('Stopping firehose worker'); 81 35 this.isShuttingDown = true; 82 36 83 - if (this.reconnectTimeout) { 84 - clearTimeout(this.reconnectTimeout); 85 - this.reconnectTimeout = null; 86 - } 87 - 88 - if (this.ws) { 89 - this.ws.close(); 90 - this.ws = null; 37 + if (this.firehose) { 38 + this.firehose.destroy(); 39 + this.firehose = null; 91 40 } 92 41 } 93 42 94 43 private connect() { 95 44 if (this.isShuttingDown) return; 96 45 97 - const url = new URL(JETSTREAM_URL); 98 - url.searchParams.set('wantedCollections', 'place.wisp.fs'); 46 + this.log('Connecting to AT Protocol firehose'); 99 47 100 - this.log('Connecting to Jetstream', { url: url.toString() }); 48 + this.firehose = new Firehose({ 49 + idResolver: this.idResolver, 50 + service: 'wss://bsky.network', 51 + filterCollections: ['place.wisp.fs'], 52 + handleEvent: async (evt) => { 53 + this.lastEventTime = Date.now(); 101 54 102 - try { 103 - this.ws = new WebSocket(url.toString()); 55 + // Watch for write events 56 + if (evt.event === 'create' || evt.event === 'update') { 57 + const record = evt.record; 104 58 105 - this.ws.onopen = () => { 106 - this.log('Connected to Jetstream'); 107 - this.reconnectAttempts = 0; 108 - this.lastEventTime = Date.now(); 109 - }; 59 + // If the write is a valid place.wisp.fs record 60 + if ( 61 + evt.collection === 'place.wisp.fs' && 62 + isRecord(record) && 63 + validateRecord(record).success 64 + ) { 65 + this.log('Received place.wisp.fs event', { 66 + did: evt.did, 67 + event: evt.event, 68 + rkey: evt.rkey, 69 + }); 110 70 111 - this.ws.onmessage = async (event) => { 112 - this.lastEventTime = Date.now(); 113 - 114 - try { 115 - const data = JSON.parse(event.data as string) as JetstreamEvent; 116 - await this.handleEvent(data); 117 - } catch (err) { 118 - this.log('Error processing event', { 119 - error: err instanceof Error ? err.message : String(err), 71 + try { 72 + await this.handleCreateOrUpdate(evt.did, evt.rkey, record, evt.cid?.toString()); 73 + } catch (err) { 74 + this.log('Error handling event', { 75 + did: evt.did, 76 + event: evt.event, 77 + rkey: evt.rkey, 78 + error: err instanceof Error ? err.message : String(err), 79 + }); 80 + } 81 + } 82 + } else if (evt.event === 'delete' && evt.collection === 'place.wisp.fs') { 83 + this.log('Received delete event', { 84 + did: evt.did, 85 + rkey: evt.rkey, 120 86 }); 121 - } 122 - }; 123 87 124 - this.ws.onerror = (error) => { 125 - this.log('WebSocket error', { error: String(error) }); 126 - }; 127 - 128 - this.ws.onclose = () => { 129 - this.log('WebSocket closed'); 130 - this.ws = null; 131 - 132 - if (!this.isShuttingDown) { 133 - this.scheduleReconnect(); 88 + try { 89 + await this.handleDelete(evt.did, evt.rkey); 90 + } catch (err) { 91 + this.log('Error handling delete', { 92 + did: evt.did, 93 + rkey: evt.rkey, 94 + error: err instanceof Error ? err.message : String(err), 95 + }); 96 + } 134 97 } 135 - }; 136 - } catch (err) { 137 - this.log('Failed to create WebSocket', { 138 - error: err instanceof Error ? err.message : String(err), 139 - }); 140 - this.scheduleReconnect(); 141 - } 142 - } 143 - 144 - private scheduleReconnect() { 145 - if (this.isShuttingDown) return; 146 - 147 - this.reconnectAttempts++; 148 - const delay = Math.min( 149 - RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts - 1), 150 - MAX_RECONNECT_DELAY, 151 - ); 152 - 153 - this.log(`Scheduling reconnect attempt ${this.reconnectAttempts}`, { 154 - delay: `${delay}ms`, 155 - }); 156 - 157 - this.reconnectTimeout = setTimeout(() => { 158 - this.connect(); 159 - }, delay); 160 - } 161 - 162 - private async handleEvent(event: JetstreamEvent) { 163 - if (event.kind !== 'commit') return; 164 - 165 - const commitEvent = event as JetstreamCommitEvent; 166 - const { commit, did } = commitEvent; 167 - 168 - if (commit.collection !== 'place.wisp.fs') return; 169 - 170 - this.log('Received place.wisp.fs event', { 171 - did, 172 - operation: commit.operation, 173 - rkey: commit.rkey, 98 + }, 99 + onError: (err) => { 100 + this.log('Firehose error', { 101 + error: err instanceof Error ? err.message : String(err), 102 + stack: err instanceof Error ? err.stack : undefined, 103 + fullError: err, 104 + }); 105 + console.error('Full firehose error:', err); 106 + }, 174 107 }); 175 108 176 - try { 177 - if (commit.operation === 'create' || commit.operation === 'update') { 178 - // Pass the CID from the event for verification 179 - await this.handleCreateOrUpdate(did, commit.rkey, commit.record, commit.cid); 180 - } else if (commit.operation === 'delete') { 181 - await this.handleDelete(did, commit.rkey); 182 - } 183 - } catch (err) { 184 - this.log('Error handling event', { 185 - did, 186 - operation: commit.operation, 187 - rkey: commit.rkey, 188 - error: err instanceof Error ? err.message : String(err), 189 - }); 190 - } 109 + this.firehose.start(); 110 + this.log('Firehose started'); 191 111 } 192 112 193 113 private async handleCreateOrUpdate(did: string, site: string, record: any, eventCid?: string) { 194 114 this.log('Processing create/update', { did, site }); 195 115 196 - if (!this.validateRecord(record)) { 197 - this.log('Invalid record structure, skipping', { did, site }); 198 - return; 199 - } 200 - 201 - const fsRecord = record as WispFsRecord; 116 + // Record is already validated in handleEvent 117 + const fsRecord = record; 202 118 203 119 const pdsEndpoint = await getPdsForDid(did); 204 120 if (!pdsEndpoint) { ··· 291 207 this.log('Successfully processed delete', { did, site }); 292 208 } 293 209 294 - private validateRecord(record: any): boolean { 295 - if (!record || typeof record !== 'object') return false; 296 - if (record.$type !== 'place.wisp.fs') return false; 297 - if (!record.root || typeof record.root !== 'object') return false; 298 - if (!record.site || typeof record.site !== 'string') return false; 299 - return true; 300 - } 301 - 302 210 private deleteCache(did: string, site: string) { 303 211 const cacheDir = `${CACHE_DIR}/${did}/${site}`; 304 212 ··· 324 232 } 325 233 326 234 getHealth() { 327 - const isConnected = this.ws !== null && this.ws.readyState === WebSocket.OPEN; 235 + const isConnected = this.firehose !== null; 328 236 const timeSinceLastEvent = Date.now() - this.lastEventTime; 329 237 330 238 return { 331 239 connected: isConnected, 332 - reconnectAttempts: this.reconnectAttempts, 333 240 lastEventTime: this.lastEventTime, 334 241 timeSinceLastEvent, 335 242 healthy: isConnected && timeSinceLastEvent < 300000, // 5 minutes
+15 -14
hosting-service/src/server.ts
··· 1 1 import { Hono } from 'hono'; 2 - import { serveStatic } from 'hono/bun'; 3 2 import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db'; 4 3 import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath } from './lib/utils'; 5 4 import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter'; 6 - import { existsSync } from 'fs'; 5 + import { existsSync, readFileSync } from 'fs'; 6 + import { lookup } from 'mime-types'; 7 7 8 8 const app = new Hono(); 9 9 ··· 33 33 const cachedFile = getCachedFilePath(did, rkey, requestPath); 34 34 35 35 if (existsSync(cachedFile)) { 36 - const file = Bun.file(cachedFile); 37 - return new Response(file, { 36 + const content = readFileSync(cachedFile); 37 + const mimeType = lookup(cachedFile) || 'application/octet-stream'; 38 + return new Response(content, { 38 39 headers: { 39 - 'Content-Type': file.type || 'application/octet-stream', 40 + 'Content-Type': mimeType, 40 41 }, 41 42 }); 42 43 } ··· 45 46 if (!requestPath.includes('.')) { 46 47 const indexFile = getCachedFilePath(did, rkey, `${requestPath}/index.html`); 47 48 if (existsSync(indexFile)) { 48 - const file = Bun.file(indexFile); 49 - return new Response(file, { 49 + const content = readFileSync(indexFile); 50 + return new Response(content, { 50 51 headers: { 51 52 'Content-Type': 'text/html; charset=utf-8', 52 53 }, ··· 73 74 const cachedFile = getCachedFilePath(did, rkey, requestPath); 74 75 75 76 if (existsSync(cachedFile)) { 76 - const file = Bun.file(cachedFile); 77 + const mimeType = lookup(cachedFile) || 'application/octet-stream'; 77 78 78 79 // Check if this is HTML content that needs rewriting 79 - if (isHtmlContent(requestPath, file.type)) { 80 - const content = await file.text(); 80 + if (isHtmlContent(requestPath, mimeType)) { 81 + const content = readFileSync(cachedFile, 'utf-8'); 81 82 const rewritten = rewriteHtmlPaths(content, basePath); 82 83 return new Response(rewritten, { 83 84 headers: { ··· 87 88 } 88 89 89 90 // Non-HTML files served with proper MIME type 90 - return new Response(file, { 91 + const content = readFileSync(cachedFile); 92 + return new Response(content, { 91 93 headers: { 92 - 'Content-Type': file.type || 'application/octet-stream', 94 + 'Content-Type': mimeType, 93 95 }, 94 96 }); 95 97 } ··· 98 100 if (!requestPath.includes('.')) { 99 101 const indexFile = getCachedFilePath(did, rkey, `${requestPath}/index.html`); 100 102 if (existsSync(indexFile)) { 101 - const file = Bun.file(indexFile); 102 - const content = await file.text(); 103 + const content = readFileSync(indexFile, 'utf-8'); 103 104 const rewritten = rewriteHtmlPaths(content, basePath); 104 105 return new Response(rewritten, { 105 106 headers: {
-1
src/index.ts
··· 1 1 import { Elysia } from 'elysia' 2 2 import { cors } from '@elysiajs/cors' 3 3 import { staticPlugin } from '@elysiajs/static' 4 - import { openapi, fromTypes } from '@elysiajs/openapi' 5 4 6 5 import type { Config } from './lib/types' 7 6 import { BASE_HOST } from './lib/constants'
+44
src/lexicons/index.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { 5 + type Auth, 6 + type Options as XrpcOptions, 7 + Server as XrpcServer, 8 + type StreamConfigOrHandler, 9 + type MethodConfigOrHandler, 10 + createServer as createXrpcServer, 11 + } from '@atproto/xrpc-server' 12 + import { schemas } from './lexicons.js' 13 + 14 + export function createServer(options?: XrpcOptions): Server { 15 + return new Server(options) 16 + } 17 + 18 + export class Server { 19 + xrpc: XrpcServer 20 + place: PlaceNS 21 + 22 + constructor(options?: XrpcOptions) { 23 + this.xrpc = createXrpcServer(schemas, options) 24 + this.place = new PlaceNS(this) 25 + } 26 + } 27 + 28 + export class PlaceNS { 29 + _server: Server 30 + wisp: PlaceWispNS 31 + 32 + constructor(server: Server) { 33 + this._server = server 34 + this.wisp = new PlaceWispNS(server) 35 + } 36 + } 37 + 38 + export class PlaceWispNS { 39 + _server: Server 40 + 41 + constructor(server: Server) { 42 + this._server = server 43 + } 44 + }
+127
src/lexicons/lexicons.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { 5 + type LexiconDoc, 6 + Lexicons, 7 + ValidationError, 8 + type ValidationResult, 9 + } from '@atproto/lexicon' 10 + import { type $Typed, is$typed, maybe$typed } from './util.js' 11 + 12 + export const schemaDict = { 13 + PlaceWispFs: { 14 + lexicon: 1, 15 + id: 'place.wisp.fs', 16 + defs: { 17 + main: { 18 + type: 'record', 19 + description: 'Virtual filesystem manifest for a Wisp site', 20 + record: { 21 + type: 'object', 22 + required: ['site', 'root', 'createdAt'], 23 + properties: { 24 + site: { 25 + type: 'string', 26 + }, 27 + root: { 28 + type: 'ref', 29 + ref: 'lex:place.wisp.fs#directory', 30 + }, 31 + fileCount: { 32 + type: 'integer', 33 + minimum: 0, 34 + maximum: 1000, 35 + }, 36 + createdAt: { 37 + type: 'string', 38 + format: 'datetime', 39 + }, 40 + }, 41 + }, 42 + }, 43 + file: { 44 + type: 'object', 45 + required: ['type', 'blob'], 46 + properties: { 47 + type: { 48 + type: 'string', 49 + const: 'file', 50 + }, 51 + blob: { 52 + type: 'blob', 53 + accept: ['*/*'], 54 + maxSize: 1000000, 55 + description: 'Content blob ref', 56 + }, 57 + }, 58 + }, 59 + directory: { 60 + type: 'object', 61 + required: ['type', 'entries'], 62 + properties: { 63 + type: { 64 + type: 'string', 65 + const: 'directory', 66 + }, 67 + entries: { 68 + type: 'array', 69 + maxLength: 500, 70 + items: { 71 + type: 'ref', 72 + ref: 'lex:place.wisp.fs#entry', 73 + }, 74 + }, 75 + }, 76 + }, 77 + entry: { 78 + type: 'object', 79 + required: ['name', 'node'], 80 + properties: { 81 + name: { 82 + type: 'string', 83 + maxLength: 255, 84 + }, 85 + node: { 86 + type: 'union', 87 + refs: ['lex:place.wisp.fs#file', 'lex:place.wisp.fs#directory'], 88 + }, 89 + }, 90 + }, 91 + }, 92 + }, 93 + } as const satisfies Record<string, LexiconDoc> 94 + export const schemas = Object.values(schemaDict) satisfies LexiconDoc[] 95 + export const lexicons: Lexicons = new Lexicons(schemas) 96 + 97 + export function validate<T extends { $type: string }>( 98 + v: unknown, 99 + id: string, 100 + hash: string, 101 + requiredType: true, 102 + ): ValidationResult<T> 103 + export function validate<T extends { $type?: string }>( 104 + v: unknown, 105 + id: string, 106 + hash: string, 107 + requiredType?: false, 108 + ): ValidationResult<T> 109 + export function validate( 110 + v: unknown, 111 + id: string, 112 + hash: string, 113 + requiredType?: boolean, 114 + ): ValidationResult { 115 + return (requiredType ? is$typed : maybe$typed)(v, id, hash) 116 + ? lexicons.validate(`${id}#${hash}`, v) 117 + : { 118 + success: false, 119 + error: new ValidationError( 120 + `Must be an object with "${hash === 'main' ? id : `${id}#${hash}`}" $type property`, 121 + ), 122 + } 123 + } 124 + 125 + export const ids = { 126 + PlaceWispFs: 'place.wisp.fs', 127 + } as const
+85
src/lexicons/types/place/wisp/fs.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../lexicons' 7 + import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 8 + 9 + const is$typed = _is$typed, 10 + validate = _validate 11 + const id = 'place.wisp.fs' 12 + 13 + export interface Main { 14 + $type: 'place.wisp.fs' 15 + site: string 16 + root: Directory 17 + fileCount?: number 18 + createdAt: string 19 + [k: string]: unknown 20 + } 21 + 22 + const hashMain = 'main' 23 + 24 + export function isMain<V>(v: V) { 25 + return is$typed(v, id, hashMain) 26 + } 27 + 28 + export function validateMain<V>(v: V) { 29 + return validate<Main & V>(v, id, hashMain, true) 30 + } 31 + 32 + export { 33 + type Main as Record, 34 + isMain as isRecord, 35 + validateMain as validateRecord, 36 + } 37 + 38 + export interface File { 39 + $type?: 'place.wisp.fs#file' 40 + type: 'file' 41 + /** Content blob ref */ 42 + blob: BlobRef 43 + } 44 + 45 + const hashFile = 'file' 46 + 47 + export function isFile<V>(v: V) { 48 + return is$typed(v, id, hashFile) 49 + } 50 + 51 + export function validateFile<V>(v: V) { 52 + return validate<File & V>(v, id, hashFile) 53 + } 54 + 55 + export interface Directory { 56 + $type?: 'place.wisp.fs#directory' 57 + type: 'directory' 58 + entries: Entry[] 59 + } 60 + 61 + const hashDirectory = 'directory' 62 + 63 + export function isDirectory<V>(v: V) { 64 + return is$typed(v, id, hashDirectory) 65 + } 66 + 67 + export function validateDirectory<V>(v: V) { 68 + return validate<Directory & V>(v, id, hashDirectory) 69 + } 70 + 71 + export interface Entry { 72 + $type?: 'place.wisp.fs#entry' 73 + name: string 74 + node: $Typed<File> | $Typed<Directory> | { $type: string } 75 + } 76 + 77 + const hashEntry = 'entry' 78 + 79 + export function isEntry<V>(v: V) { 80 + return is$typed(v, id, hashEntry) 81 + } 82 + 83 + export function validateEntry<V>(v: V) { 84 + return validate<Entry & V>(v, id, hashEntry) 85 + }
+82
src/lexicons/util.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + 5 + import { type ValidationResult } from '@atproto/lexicon' 6 + 7 + export type OmitKey<T, K extends keyof T> = { 8 + [K2 in keyof T as K2 extends K ? never : K2]: T[K2] 9 + } 10 + 11 + export type $Typed<V, T extends string = string> = V & { $type: T } 12 + export type Un$Typed<V extends { $type?: string }> = OmitKey<V, '$type'> 13 + 14 + export type $Type<Id extends string, Hash extends string> = Hash extends 'main' 15 + ? Id 16 + : `${Id}#${Hash}` 17 + 18 + function isObject<V>(v: V): v is V & object { 19 + return v != null && typeof v === 'object' 20 + } 21 + 22 + function is$type<Id extends string, Hash extends string>( 23 + $type: unknown, 24 + id: Id, 25 + hash: Hash, 26 + ): $type is $Type<Id, Hash> { 27 + return hash === 'main' 28 + ? $type === id 29 + : // $type === `${id}#${hash}` 30 + typeof $type === 'string' && 31 + $type.length === id.length + 1 + hash.length && 32 + $type.charCodeAt(id.length) === 35 /* '#' */ && 33 + $type.startsWith(id) && 34 + $type.endsWith(hash) 35 + } 36 + 37 + export type $TypedObject< 38 + V, 39 + Id extends string, 40 + Hash extends string, 41 + > = V extends { 42 + $type: $Type<Id, Hash> 43 + } 44 + ? V 45 + : V extends { $type?: string } 46 + ? V extends { $type?: infer T extends $Type<Id, Hash> } 47 + ? V & { $type: T } 48 + : never 49 + : V & { $type: $Type<Id, Hash> } 50 + 51 + export function is$typed<V, Id extends string, Hash extends string>( 52 + v: V, 53 + id: Id, 54 + hash: Hash, 55 + ): v is $TypedObject<V, Id, Hash> { 56 + return isObject(v) && '$type' in v && is$type(v.$type, id, hash) 57 + } 58 + 59 + export function maybe$typed<V, Id extends string, Hash extends string>( 60 + v: V, 61 + id: Id, 62 + hash: Hash, 63 + ): v is V & object & { $type?: $Type<Id, Hash> } { 64 + return ( 65 + isObject(v) && 66 + ('$type' in v ? v.$type === undefined || is$type(v.$type, id, hash) : true) 67 + ) 68 + } 69 + 70 + export type Validator<R = unknown> = (v: unknown) => ValidationResult<R> 71 + export type ValidatorParam<V extends Validator> = 72 + V extends Validator<infer R> ? R : never 73 + 74 + /** 75 + * Utility function that allows to convert a "validate*" utility function into a 76 + * type predicate. 77 + */ 78 + export function asPredicate<V extends Validator>(validate: V) { 79 + return function <T>(v: T): v is T & ValidatorParam<V> { 80 + return validate(v).success 81 + } 82 + }
+10 -1
src/lib/wisp-utils.ts
··· 1 1 import type { BlobRef } from "@atproto/api"; 2 2 import type { Record, Directory, File, Entry } from "../lexicon/types/place/wisp/fs"; 3 + import { validateRecord } from "../lexicon/types/place/wisp/fs"; 3 4 4 5 export interface UploadedFile { 5 6 name: string; ··· 126 127 root: Directory, 127 128 fileCount: number 128 129 ): Record { 129 - return { 130 + const manifest = { 130 131 $type: 'place.wisp.fs' as const, 131 132 site: siteName, 132 133 root, 133 134 fileCount, 134 135 createdAt: new Date().toISOString() 135 136 }; 137 + 138 + // Validate the manifest before returning 139 + const validationResult = validateRecord(manifest); 140 + if (!validationResult.success) { 141 + throw new Error(`Invalid manifest: ${validationResult.error?.message || 'Validation failed'}`); 142 + } 143 + 144 + return manifest; 136 145 } 137 146 138 147 /**
+13
src/routes/wisp.ts
··· 11 11 } from '../lib/wisp-utils' 12 12 import { upsertSite } from '../lib/db' 13 13 import { logger } from '../lib/logger' 14 + import { validateRecord } from '../lexicon/types/place/wisp/fs' 14 15 15 16 function isValidSiteName(siteName: string): boolean { 16 17 if (!siteName || typeof siteName !== 'string') return false; ··· 72 73 fileCount: 0, 73 74 createdAt: new Date().toISOString() 74 75 }; 76 + 77 + // Validate the manifest 78 + const validationResult = validateRecord(emptyManifest); 79 + if (!validationResult.success) { 80 + throw new Error(`Invalid manifest: ${validationResult.error?.message || 'Validation failed'}`); 81 + } 75 82 76 83 // Use site name as rkey 77 84 const rkey = siteName; ··· 186 193 fileCount: 0, 187 194 createdAt: new Date().toISOString() 188 195 }; 196 + 197 + // Validate the manifest 198 + const validationResult = validateRecord(emptyManifest); 199 + if (!validationResult.success) { 200 + throw new Error(`Invalid manifest: ${validationResult.error?.message || 'Validation failed'}`); 201 + } 189 202 190 203 // Use site name as rkey 191 204 const rkey = siteName;