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

sigh

Changed files
+242 -399
hosting-service
+9 -9
hosting-service/bun.lock
··· 7 7 "@atproto/api": "^0.17.4", 8 8 "@atproto/identity": "^0.4.9", 9 9 "@atproto/lexicon": "^0.5.1", 10 - "@atproto/sync": "^0.1.35", 10 + "@atproto/sync": "^0.1.36", 11 11 "@atproto/xrpc": "^0.7.5", 12 - "@elysiajs/node": "^1.4.1", 12 + "@elysiajs/node": "^1.4.2", 13 13 "@elysiajs/opentelemetry": "latest", 14 - "elysia": "latest", 14 + "elysia": "^1.4.15", 15 15 "mime-types": "^2.1.35", 16 16 "multiformats": "^13.4.1", 17 17 "postgres": "^3.4.5", ··· 38 38 39 39 "@atproto/repo": ["@atproto/repo@0.8.10", "", { "dependencies": { "@atproto/common": "^0.4.12", "@atproto/common-web": "^0.4.3", "@atproto/crypto": "^0.4.4", "@atproto/lexicon": "^0.5.1", "@ipld/dag-cbor": "^7.0.0", "multiformats": "^9.9.0", "uint8arrays": "3.0.0", "varint": "^6.0.0", "zod": "^3.23.8" } }, ""], 40 40 41 - "@atproto/sync": ["@atproto/sync@0.1.35", "", { "dependencies": { "@atproto/common": "^0.4.12", "@atproto/identity": "^0.4.9", "@atproto/lexicon": "^0.5.1", "@atproto/repo": "^0.8.10", "@atproto/syntax": "^0.4.1", "@atproto/xrpc-server": "^0.9.5", "multiformats": "^9.9.0", "p-queue": "^6.6.2", "ws": "^8.12.0" } }, ""], 41 + "@atproto/sync": ["@atproto/sync@0.1.36", "", { "dependencies": { "@atproto/common": "^0.4.12", "@atproto/identity": "^0.4.9", "@atproto/lexicon": "^0.5.1", "@atproto/repo": "^0.8.10", "@atproto/syntax": "^0.4.1", "@atproto/xrpc-server": "^0.9.5", "multiformats": "^9.9.0", "p-queue": "^6.6.2", "ws": "^8.12.0" } }, "sha512-HyF835Bmn8ps9BuXkmGjRrbgfv4K3fJdfEvXimEhTCntqIxQg0ttmOYDg/WBBmIRfkCB5ab+wS1PCGN8trr+FQ=="], 42 42 43 43 "@atproto/syntax": ["@atproto/syntax@0.4.1", "", {}, ""], 44 44 ··· 50 50 51 51 "@cbor-extract/cbor-extract-darwin-arm64": ["@cbor-extract/cbor-extract-darwin-arm64@2.2.0", "", { "os": "darwin", "cpu": "arm64" }, ""], 52 52 53 - "@elysiajs/node": ["@elysiajs/node@1.4.1", "", { "dependencies": { "crossws": "^0.4.1", "srvx": "^0.8.9" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-2wAALwHK3IYi1XJPnxfp1xJsvps5FqqcQqe+QXjYlGQvsmSG+vI5wNDIuvIlB+6p9NE/laLbqV0aFromf3X7yg=="], 53 + "@elysiajs/node": ["@elysiajs/node@1.4.2", "", { "dependencies": { "crossws": "^0.4.1", "srvx": "^0.9.4" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-zqeBAV4/faCcmIEjCp3g6jRwsbaWsd5HqmlEf3CirD9HkTWQNo4T+GN/qGZi7zgd84D3Kzxsny7ZTMXEfrDSXQ=="], 54 54 55 55 "@elysiajs/opentelemetry": ["@elysiajs/opentelemetry@1.4.6", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/instrumentation": "^0.200.0", "@opentelemetry/sdk-node": "^0.200.0" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-jR7t4M6ZvMnBqzzHsNTL6y3sNq9jbGi2vKxbkizi/OO5tlvlKl/rnBGyFjZUjQ1Hte7rCz+2kfmgOQMhkjk+Og=="], 56 56 ··· 272 272 273 273 "ee-first": ["ee-first@1.1.1", "", {}, ""], 274 274 275 - "elysia": ["elysia@1.4.13", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.2.2", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "exact-mirror": ">= 0.0.9", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-6QaWQEm7QN1UCo1TPpEjaRJPHUmnM7R29y6LY224frDGk5PrpAnWmdHkoZxkcv+JRWp1j2ROr2IHbxHbG/jRjw=="], 275 + "elysia": ["elysia@1.4.15", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.2.2", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-RaDqqZdLuC4UJetfVRQ4Z5aVpGgEtQ+pZnsbI4ZzEaf3l/MzuHcqSVoL/Fue3d6qE4RV9HMB2rAZaHyPIxkyzg=="], 276 276 277 277 "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], 278 278 ··· 468 468 469 469 "split2": ["split2@4.2.0", "", {}, ""], 470 470 471 - "srvx": ["srvx@0.8.16", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-hmcGW4CgroeSmzgF1Ihwgl+Ths0JqAJ7HwjP2X7e3JzY7u4IydLMcdnlqGQiQGUswz+PO9oh/KtCpOISIvs9QQ=="], 471 + "srvx": ["srvx@0.9.5", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-nQsA2c8q3XwbSn6kTxVQjz0zS096rV+Be2pzJwrYEAdtnYszLw4MTy8JWJjz1XEGBZwP0qW51SUIX3WdjdRemQ=="], 472 472 473 473 "statuses": ["statuses@2.0.1", "", {}, ""], 474 474 ··· 546 546 547 547 "uint8arrays/multiformats": ["multiformats@9.9.0", "", {}, ""], 548 548 549 - "@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 549 + "@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, ""], 550 550 551 - "require-in-the-middle/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 551 + "require-in-the-middle/debug/ms": ["ms@2.1.3", "", {}, ""], 552 552 } 553 553 }
+5 -4
hosting-service/package.json
··· 4 4 "type": "module", 5 5 "scripts": { 6 6 "dev": "tsx watch src/index.ts", 7 - "start": "node --loader tsx src/index.ts" 7 + "build": "tsc", 8 + "start": "tsx src/index.ts" 8 9 }, 9 10 "dependencies": { 10 11 "@atproto/api": "^0.17.4", 11 12 "@atproto/identity": "^0.4.9", 12 13 "@atproto/lexicon": "^0.5.1", 13 - "@atproto/sync": "^0.1.35", 14 + "@atproto/sync": "^0.1.36", 14 15 "@atproto/xrpc": "^0.7.5", 15 - "@elysiajs/opentelemetry": "latest", 16 - "elysia": "latest", 16 + "@hono/node-server": "^1.19.6", 17 + "hono": "^4.10.4", 17 18 "mime-types": "^2.1.35", 18 19 "multiformats": "^13.4.1", 19 20 "postgres": "^3.4.5"
+13 -9
hosting-service/src/index.ts
··· 1 1 import app from './server'; 2 + import { serve } from '@hono/node-server'; 2 3 import { FirehoseWorker } from './lib/firehose'; 3 4 import { logger } from './lib/observability'; 4 5 import { mkdirSync, existsSync } from 'fs'; ··· 20 21 firehose.start(); 21 22 22 23 // Add health check endpoint 23 - app.get('/health', () => { 24 + app.get('/health', (c) => { 24 25 const firehoseHealth = firehose.getHealth(); 25 - return { 26 + return c.json({ 26 27 status: 'ok', 27 28 firehose: firehoseHealth, 28 - }; 29 + }); 29 30 }); 30 31 31 - // Start HTTP server 32 - app.listen(PORT, () => { 33 - console.log(` 32 + // Start HTTP server with Node.js adapter 33 + const server = serve({ 34 + fetch: app.fetch, 35 + port: PORT, 36 + }); 37 + 38 + console.log(` 34 39 Wisp Hosting Service 35 40 36 41 Server: http://localhost:${PORT} ··· 38 43 Cache: ${CACHE_DIR} 39 44 Firehose: Connected to Firehose 40 45 `); 41 - }); 42 46 43 47 // Graceful shutdown 44 48 process.on('SIGINT', async () => { 45 49 console.log('\n🛑 Shutting down...'); 46 50 firehose.stop(); 47 - app.stop(); 51 + server.close(); 48 52 process.exit(0); 49 53 }); 50 54 51 55 process.on('SIGTERM', async () => { 52 56 console.log('\n🛑 Shutting down...'); 53 57 firehose.stop(); 54 - app.stop(); 58 + server.close(); 55 59 process.exit(0); 56 60 });
+1 -1
hosting-service/src/lexicon/types/place/wisp/fs.ts
··· 2 2 * GENERATED CODE - DO NOT MODIFY 3 3 */ 4 4 import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 - import { CID } from 'multiformats/cid' 5 + import { CID } from 'multiformats' 6 6 import { validate as _validate } from '../../../lexicons' 7 7 import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 8 8
+8 -86
hosting-service/src/lib/db.ts
··· 1 1 import postgres from 'postgres'; 2 + import { createHash } from 'crypto'; 2 3 3 4 const sql = postgres( 4 5 process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/wisp', ··· 21 22 verified: boolean; 22 23 } 23 24 24 - // In-memory cache with TTL 25 - interface CacheEntry<T> { 26 - data: T; 27 - expiry: number; 28 - } 29 25 30 - const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes 31 - 32 - class SimpleCache<T> { 33 - private cache = new Map<string, CacheEntry<T>>(); 34 - 35 - get(key: string): T | null { 36 - const entry = this.cache.get(key); 37 - if (!entry) return null; 38 - 39 - if (Date.now() > entry.expiry) { 40 - this.cache.delete(key); 41 - return null; 42 - } 43 - 44 - return entry.data; 45 - } 46 - 47 - set(key: string, data: T): void { 48 - this.cache.set(key, { 49 - data, 50 - expiry: Date.now() + CACHE_TTL_MS, 51 - }); 52 - } 53 - 54 - // Periodic cleanup to prevent memory leaks 55 - cleanup(): void { 56 - const now = Date.now(); 57 - for (const [key, entry] of this.cache.entries()) { 58 - if (now > entry.expiry) { 59 - this.cache.delete(key); 60 - } 61 - } 62 - } 63 - } 64 - 65 - // Create cache instances 66 - const wispDomainCache = new SimpleCache<DomainLookup | null>(); 67 - const customDomainCache = new SimpleCache<CustomDomainLookup | null>(); 68 - const customDomainHashCache = new SimpleCache<CustomDomainLookup | null>(); 69 - 70 - // Run cleanup every 5 minutes 71 - setInterval(() => { 72 - wispDomainCache.cleanup(); 73 - customDomainCache.cleanup(); 74 - customDomainHashCache.cleanup(); 75 - }, 5 * 60 * 1000); 76 26 77 27 export async function getWispDomain(domain: string): Promise<DomainLookup | null> { 78 28 const key = domain.toLowerCase(); 79 29 80 - // Check cache first 81 - const cached = wispDomainCache.get(key); 82 - if (cached !== null) { 83 - return cached; 84 - } 85 - 86 30 // Query database 87 31 const result = await sql<DomainLookup[]>` 88 32 SELECT did, rkey FROM domains WHERE domain = ${key} LIMIT 1 89 33 `; 90 34 const data = result[0] || null; 91 35 92 - // Store in cache 93 - wispDomainCache.set(key, data); 94 - 95 36 return data; 96 37 } 97 38 98 39 export async function getCustomDomain(domain: string): Promise<CustomDomainLookup | null> { 99 40 const key = domain.toLowerCase(); 100 41 101 - // Check cache first 102 - const cached = customDomainCache.get(key); 103 - if (cached !== null) { 104 - return cached; 105 - } 106 - 107 42 // Query database 108 43 const result = await sql<CustomDomainLookup[]>` 109 44 SELECT id, domain, did, rkey, verified FROM custom_domains ··· 111 46 `; 112 47 const data = result[0] || null; 113 48 114 - // Store in cache 115 - customDomainCache.set(key, data); 116 - 117 49 return data; 118 50 } 119 51 120 52 export async function getCustomDomainByHash(hash: string): Promise<CustomDomainLookup | null> { 121 - // Check cache first 122 - const cached = customDomainHashCache.get(hash); 123 - if (cached !== null) { 124 - return cached; 125 - } 126 - 127 53 // Query database 128 54 const result = await sql<CustomDomainLookup[]>` 129 55 SELECT id, domain, did, rkey, verified FROM custom_domains 130 56 WHERE id = ${hash} AND verified = true LIMIT 1 131 57 `; 132 58 const data = result[0] || null; 133 - 134 - // Store in cache 135 - customDomainHashCache.set(hash, data); 136 59 137 60 return data; 138 61 } ··· 163 86 * PostgreSQL advisory locks use bigint (64-bit signed integer) 164 87 */ 165 88 function stringToLockId(key: string): bigint { 166 - let hash = 0n; 167 - for (let i = 0; i < key.length; i++) { 168 - const char = BigInt(key.charCodeAt(i)); 169 - hash = ((hash << 5n) - hash + char) & 0x7FFFFFFFFFFFFFFFn; // Keep within signed int64 range 170 - } 171 - return hash; 89 + const hash = createHash('sha256').update(key).digest('hex'); 90 + // Take first 16 hex characters (64 bits) and convert to bigint 91 + const hashNum = BigInt('0x' + hash.substring(0, 16)); 92 + // Keep within signed int64 range 93 + return hashNum & 0x7FFFFFFFFFFFFFFFn; 172 94 } 173 95 174 96 /** ··· 180 102 const lockId = stringToLockId(key); 181 103 182 104 try { 183 - const result = await sql`SELECT pg_try_advisory_lock(${lockId}) as acquired`; 105 + const result = await sql`SELECT pg_try_advisory_lock(${Number(lockId)}) as acquired`; 184 106 return result[0]?.acquired === true; 185 107 } catch (err) { 186 108 console.error('Failed to acquire lock', { key, error: err }); ··· 195 117 const lockId = stringToLockId(key); 196 118 197 119 try { 198 - await sql`SELECT pg_advisory_unlock(${lockId})`; 120 + await sql`SELECT pg_advisory_unlock(${Number(lockId)})`; 199 121 } catch (err) { 200 122 console.error('Failed to release lock', { key, error: err }); 201 123 }
+2 -2
hosting-service/src/lib/firehose.ts
··· 49 49 idResolver: this.idResolver, 50 50 service: 'wss://bsky.network', 51 51 filterCollections: ['place.wisp.fs'], 52 - handleEvent: async (evt) => { 52 + handleEvent: async (evt: any) => { 53 53 this.lastEventTime = Date.now(); 54 54 55 55 // Watch for write events ··· 96 96 } 97 97 } 98 98 }, 99 - onError: (err) => { 99 + onError: (err: any) => { 100 100 this.log('Firehose error', { 101 101 error: err instanceof Error ? err.message : String(err), 102 102 stack: err instanceof Error ? err.stack : undefined,
-107
hosting-service/src/lib/html-rewriter.test.ts
··· 1 - /** 2 - * Simple tests for HTML path rewriter 3 - * Run with: bun test 4 - */ 5 - 6 - import { test, expect } from 'bun:test'; 7 - import { rewriteHtmlPaths, isHtmlContent } from './html-rewriter'; 8 - 9 - test('rewriteHtmlPaths - rewrites absolute paths in src attributes', () => { 10 - const html = '<img src="/logo.png">'; 11 - const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/'); 12 - expect(result).toBe('<img src="/did:plc:123/mysite/logo.png">'); 13 - }); 14 - 15 - test('rewriteHtmlPaths - rewrites absolute paths in href attributes', () => { 16 - const html = '<link rel="stylesheet" href="/style.css">'; 17 - const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/'); 18 - expect(result).toBe('<link rel="stylesheet" href="/did:plc:123/mysite/style.css">'); 19 - }); 20 - 21 - test('rewriteHtmlPaths - preserves external URLs', () => { 22 - const html = '<img src="https://example.com/logo.png">'; 23 - const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/'); 24 - expect(result).toBe('<img src="https://example.com/logo.png">'); 25 - }); 26 - 27 - test('rewriteHtmlPaths - preserves protocol-relative URLs', () => { 28 - const html = '<script src="//cdn.example.com/script.js"></script>'; 29 - const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/'); 30 - expect(result).toBe('<script src="//cdn.example.com/script.js"></script>'); 31 - }); 32 - 33 - test('rewriteHtmlPaths - preserves data URIs', () => { 34 - const html = '<img src="data:image/png;base64,abc123">'; 35 - const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/'); 36 - expect(result).toBe('<img src="data:image/png;base64,abc123">'); 37 - }); 38 - 39 - test('rewriteHtmlPaths - preserves anchors', () => { 40 - const html = '<a href="/#section">Jump</a>'; 41 - const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/'); 42 - expect(result).toBe('<a href="/#section">Jump</a>'); 43 - }); 44 - 45 - test('rewriteHtmlPaths - preserves relative paths', () => { 46 - const html = '<img src="./logo.png">'; 47 - const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/'); 48 - expect(result).toBe('<img src="./logo.png">'); 49 - }); 50 - 51 - test('rewriteHtmlPaths - handles single quotes', () => { 52 - const html = "<img src='/logo.png'>"; 53 - const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/'); 54 - expect(result).toBe("<img src='/did:plc:123/mysite/logo.png'>"); 55 - }); 56 - 57 - test('rewriteHtmlPaths - handles srcset', () => { 58 - const html = '<img srcset="/logo.png 1x, /logo@2x.png 2x">'; 59 - const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/'); 60 - expect(result).toBe('<img srcset="/did:plc:123/mysite/logo.png 1x, /did:plc:123/mysite/logo@2x.png 2x">'); 61 - }); 62 - 63 - test('rewriteHtmlPaths - handles form actions', () => { 64 - const html = '<form action="/submit"></form>'; 65 - const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/'); 66 - expect(result).toBe('<form action="/did:plc:123/mysite/submit"></form>'); 67 - }); 68 - 69 - test('rewriteHtmlPaths - handles complex HTML', () => { 70 - const html = ` 71 - <!DOCTYPE html> 72 - <html> 73 - <head> 74 - <link rel="stylesheet" href="/style.css"> 75 - <script src="/app.js"></script> 76 - </head> 77 - <body> 78 - <img src="/images/logo.png" srcset="/images/logo.png 1x, /images/logo@2x.png 2x"> 79 - <a href="/about">About</a> 80 - <a href="https://example.com">External</a> 81 - <a href="#section">Anchor</a> 82 - </body> 83 - </html> 84 - `.trim(); 85 - 86 - const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/'); 87 - 88 - expect(result).toContain('href="/did:plc:123/mysite/style.css"'); 89 - expect(result).toContain('src="/did:plc:123/mysite/app.js"'); 90 - expect(result).toContain('src="/did:plc:123/mysite/images/logo.png"'); 91 - expect(result).toContain('href="/did:plc:123/mysite/about"'); 92 - expect(result).toContain('href="https://example.com"'); // External preserved 93 - expect(result).toContain('href="#section"'); // Anchor preserved 94 - }); 95 - 96 - test('isHtmlContent - detects HTML by extension', () => { 97 - expect(isHtmlContent('index.html')).toBe(true); 98 - expect(isHtmlContent('page.htm')).toBe(true); 99 - expect(isHtmlContent('style.css')).toBe(false); 100 - expect(isHtmlContent('script.js')).toBe(false); 101 - }); 102 - 103 - test('isHtmlContent - detects HTML by content type', () => { 104 - expect(isHtmlContent('index', 'text/html')).toBe(true); 105 - expect(isHtmlContent('index', 'text/html; charset=utf-8')).toBe(true); 106 - expect(isHtmlContent('index', 'application/json')).toBe(false); 107 - });
+36 -38
hosting-service/src/lib/observability.ts
··· 1 1 // DIY Observability for Hosting Service 2 - import type { Context } from 'elysia' 2 + import type { Context } from 'hono' 3 3 4 4 // Types 5 5 export interface LogEntry { ··· 175 175 // Rotate if needed 176 176 if (errors.size > MAX_ERRORS) { 177 177 const oldest = Array.from(errors.keys())[0] 178 - errors.delete(oldest) 178 + if (oldest !== undefined) { 179 + errors.delete(oldest) 180 + } 179 181 } 180 182 } 181 183 }, ··· 262 264 return { 263 265 totalRequests: filtered.length, 264 266 avgDuration: Math.round(totalDuration / filtered.length), 265 - p50Duration: Math.round(p50), 266 - p95Duration: Math.round(p95), 267 - p99Duration: Math.round(p99), 267 + p50Duration: Math.round(p50 ?? 0), 268 + p95Duration: Math.round(p95 ?? 0), 269 + p99Duration: Math.round(p99 ?? 0), 268 270 errorRate: (errors / filtered.length) * 100, 269 271 requestsPerMinute: Math.round(filtered.length / timeWindowMinutes) 270 272 } ··· 275 277 } 276 278 } 277 279 278 - // Elysia middleware for request timing 280 + // Hono middleware for request timing 279 281 export function observabilityMiddleware(service: string) { 280 - return { 281 - beforeHandle: ({ request }: any) => { 282 - (request as any).__startTime = Date.now() 283 - }, 284 - afterHandle: ({ request, set }: any) => { 285 - const duration = Date.now() - ((request as any).__startTime || Date.now()) 286 - const url = new URL(request.url) 282 + return async (c: Context, next: () => Promise<void>) => { 283 + const startTime = Date.now() 284 + 285 + await next() 286 + 287 + const duration = Date.now() - startTime 288 + const { pathname } = new URL(c.req.url) 287 289 288 - metricsCollector.recordRequest( 289 - url.pathname, 290 - request.method, 291 - set.status || 200, 292 - duration, 293 - service 294 - ) 295 - }, 296 - onError: ({ request, error, set }: any) => { 297 - const duration = Date.now() - ((request as any).__startTime || Date.now()) 298 - const url = new URL(request.url) 290 + metricsCollector.recordRequest( 291 + pathname, 292 + c.req.method, 293 + c.res.status, 294 + duration, 295 + service 296 + ) 297 + } 298 + } 299 299 300 - metricsCollector.recordRequest( 301 - url.pathname, 302 - request.method, 303 - set.status || 500, 304 - duration, 305 - service 306 - ) 300 + // Hono error handler 301 + export function observabilityErrorHandler(service: string) { 302 + return (err: Error, c: Context) => { 303 + const { pathname } = new URL(c.req.url) 304 + 305 + logCollector.error( 306 + `Request failed: ${c.req.method} ${pathname}`, 307 + service, 308 + err, 309 + { statusCode: c.res.status || 500 } 310 + ) 307 311 308 - logCollector.error( 309 - `Request failed: ${request.method} ${url.pathname}`, 310 - service, 311 - error, 312 - { statusCode: set.status || 500 } 313 - ) 314 - } 312 + return c.text('Internal Server Error', 500) 315 313 } 316 314 } 317 315
+1 -1
hosting-service/src/lib/utils.ts
··· 3 3 import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs'; 4 4 import { writeFile, readFile, rename } from 'fs/promises'; 5 5 import { safeFetchJson, safeFetchBlob } from './safe-fetch'; 6 - import { CID } from 'multiformats/cid'; 6 + import { CID } from 'multiformats'; 7 7 8 8 const CACHE_DIR = './cache/sites'; 9 9 const CACHE_TTL = 14 * 24 * 60 * 60 * 1000; // 14 days cache TTL
+139 -142
hosting-service/src/server.ts
··· 1 - import { Elysia } from 'elysia'; 2 - import { node } from '@elysiajs/node' 3 - import { opentelemetry } from '@elysiajs/opentelemetry'; 1 + import { Hono } from 'hono'; 4 2 import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db'; 5 3 import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath } from './lib/utils'; 6 4 import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter'; 7 5 import { existsSync, readFileSync } from 'fs'; 8 6 import { lookup } from 'mime-types'; 9 - import { logger, observabilityMiddleware, logCollector, errorTracker, metricsCollector } from './lib/observability'; 7 + import { logger, observabilityMiddleware, observabilityErrorHandler, logCollector, errorTracker, metricsCollector } from './lib/observability'; 10 8 11 9 const BASE_HOST = process.env.BASE_HOST || 'wisp.place'; 12 10 ··· 221 219 } 222 220 } 223 221 224 - const app = new Elysia({ adapter: node() }) 225 - .use(opentelemetry()) 226 - .onBeforeHandle(observabilityMiddleware('hosting-service').beforeHandle) 227 - .onAfterHandle(observabilityMiddleware('hosting-service').afterHandle) 228 - .onError(observabilityMiddleware('hosting-service').onError) 229 - .get('/*', async ({ request, set }) => { 230 - const url = new URL(request.url); 231 - const hostname = request.headers.get('host') || ''; 232 - const rawPath = url.pathname.replace(/^\//, ''); 233 - const path = sanitizePath(rawPath); 222 + const app = new Hono(); 234 223 235 - // Check if this is sites.wisp.place subdomain 236 - if (hostname === `sites.${BASE_HOST}` || hostname === `sites.${BASE_HOST}:${process.env.PORT || 3000}`) { 237 - // Sanitize the path FIRST to prevent path traversal 238 - const sanitizedFullPath = sanitizePath(rawPath); 224 + // Add observability middleware 225 + app.use('*', observabilityMiddleware('hosting-service')); 239 226 240 - // Extract identifier and site from sanitized path: did:plc:123abc/sitename/file.html 241 - const pathParts = sanitizedFullPath.split('/'); 242 - if (pathParts.length < 2) { 243 - set.status = 400; 244 - return 'Invalid path format. Expected: /identifier/sitename/path'; 245 - } 227 + // Error handler 228 + app.onError(observabilityErrorHandler('hosting-service')); 246 229 247 - const identifier = pathParts[0]; 248 - const site = pathParts[1]; 249 - const filePath = pathParts.slice(2).join('/'); 230 + // Main site serving route 231 + app.get('/*', async (c) => { 232 + const url = new URL(c.req.url); 233 + const hostname = c.req.header('host') || ''; 234 + const rawPath = url.pathname.replace(/^\//, ''); 235 + const path = sanitizePath(rawPath); 250 236 251 - // Additional validation: identifier must be a valid DID or handle format 252 - if (!identifier || identifier.length < 3 || identifier.includes('..') || identifier.includes('\0')) { 253 - set.status = 400; 254 - return 'Invalid identifier'; 255 - } 237 + // Check if this is sites.wisp.place subdomain 238 + if (hostname === `sites.${BASE_HOST}` || hostname === `sites.${BASE_HOST}:${process.env.PORT || 3000}`) { 239 + // Sanitize the path FIRST to prevent path traversal 240 + const sanitizedFullPath = sanitizePath(rawPath); 256 241 257 - // Validate site name (rkey) 258 - if (!isValidRkey(site)) { 259 - set.status = 400; 260 - return 'Invalid site name'; 261 - } 262 - 263 - // Resolve identifier to DID 264 - const did = await resolveDid(identifier); 265 - if (!did) { 266 - set.status = 400; 267 - return 'Invalid identifier'; 268 - } 242 + // Extract identifier and site from sanitized path: did:plc:123abc/sitename/file.html 243 + const pathParts = sanitizedFullPath.split('/'); 244 + if (pathParts.length < 2) { 245 + return c.text('Invalid path format. Expected: /identifier/sitename/path', 400); 246 + } 269 247 270 - // Ensure site is cached 271 - const cached = await ensureSiteCached(did, site); 272 - if (!cached) { 273 - set.status = 404; 274 - return 'Site not found'; 275 - } 248 + const identifier = pathParts[0]; 249 + const site = pathParts[1]; 250 + const filePath = pathParts.slice(2).join('/'); 276 251 277 - // Serve with HTML path rewriting to handle absolute paths 278 - const basePath = `/${identifier}/${site}/`; 279 - return serveFromCacheWithRewrite(did, site, filePath, basePath); 252 + // Additional validation: identifier must be a valid DID or handle format 253 + if (!identifier || identifier.length < 3 || identifier.includes('..') || identifier.includes('\0')) { 254 + return c.text('Invalid identifier', 400); 280 255 } 281 256 282 - // Check if this is a DNS hash subdomain 283 - const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/); 284 - if (dnsMatch) { 285 - const hash = dnsMatch[1]; 286 - const baseDomain = dnsMatch[2]; 257 + // Validate site parameter exists 258 + if (!site) { 259 + return c.text('Site name required', 400); 260 + } 287 261 288 - if (baseDomain !== BASE_HOST) { 289 - set.status = 400; 290 - return 'Invalid base domain'; 291 - } 262 + // Validate site name (rkey) 263 + if (!isValidRkey(site)) { 264 + return c.text('Invalid site name', 400); 265 + } 292 266 293 - const customDomain = await getCustomDomainByHash(hash); 294 - if (!customDomain) { 295 - set.status = 404; 296 - return 'Custom domain not found or not verified'; 297 - } 267 + // Resolve identifier to DID 268 + const did = await resolveDid(identifier); 269 + if (!did) { 270 + return c.text('Invalid identifier', 400); 271 + } 298 272 299 - if (!customDomain.rkey) { 300 - set.status = 404; 301 - return 'Domain not mapped to a site'; 302 - } 273 + // Ensure site is cached 274 + const cached = await ensureSiteCached(did, site); 275 + if (!cached) { 276 + return c.text('Site not found', 404); 277 + } 303 278 304 - const rkey = customDomain.rkey; 305 - if (!isValidRkey(rkey)) { 306 - set.status = 500; 307 - return 'Invalid site configuration'; 308 - } 279 + // Serve with HTML path rewriting to handle absolute paths 280 + const basePath = `/${identifier}/${site}/`; 281 + return serveFromCacheWithRewrite(did, site, filePath, basePath); 282 + } 309 283 310 - const cached = await ensureSiteCached(customDomain.did, rkey); 311 - if (!cached) { 312 - set.status = 404; 313 - return 'Site not found'; 314 - } 284 + // Check if this is a DNS hash subdomain 285 + const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/); 286 + if (dnsMatch) { 287 + const hash = dnsMatch[1]; 288 + const baseDomain = dnsMatch[2]; 315 289 316 - return serveFromCache(customDomain.did, rkey, path); 290 + if (!hash) { 291 + return c.text('Invalid DNS hash', 400); 317 292 } 318 293 319 - // Route 2: Registered subdomains - /*.wisp.place/* 320 - if (hostname.endsWith(`.${BASE_HOST}`)) { 321 - const subdomain = hostname.replace(`.${BASE_HOST}`, ''); 322 - 323 - const domainInfo = await getWispDomain(hostname); 324 - if (!domainInfo) { 325 - set.status = 404; 326 - return 'Subdomain not registered'; 327 - } 328 - 329 - if (!domainInfo.rkey) { 330 - set.status = 404; 331 - return 'Domain not mapped to a site'; 332 - } 333 - 334 - const rkey = domainInfo.rkey; 335 - if (!isValidRkey(rkey)) { 336 - set.status = 500; 337 - return 'Invalid site configuration'; 338 - } 339 - 340 - const cached = await ensureSiteCached(domainInfo.did, rkey); 341 - if (!cached) { 342 - set.status = 404; 343 - return 'Site not found'; 344 - } 345 - 346 - return serveFromCache(domainInfo.did, rkey, path); 294 + if (baseDomain !== BASE_HOST) { 295 + return c.text('Invalid base domain', 400); 347 296 } 348 297 349 - // Route 1: Custom domains - /* 350 - const customDomain = await getCustomDomain(hostname); 298 + const customDomain = await getCustomDomainByHash(hash); 351 299 if (!customDomain) { 352 - set.status = 404; 353 - return 'Custom domain not found or not verified'; 300 + return c.text('Custom domain not found or not verified', 404); 354 301 } 355 302 356 303 if (!customDomain.rkey) { 357 - set.status = 404; 358 - return 'Domain not mapped to a site'; 304 + return c.text('Domain not mapped to a site', 404); 359 305 } 360 306 361 307 const rkey = customDomain.rkey; 362 308 if (!isValidRkey(rkey)) { 363 - set.status = 500; 364 - return 'Invalid site configuration'; 309 + return c.text('Invalid site configuration', 500); 365 310 } 366 311 367 312 const cached = await ensureSiteCached(customDomain.did, rkey); 368 313 if (!cached) { 369 - set.status = 404; 370 - return 'Site not found'; 314 + return c.text('Site not found', 404); 371 315 } 372 316 373 317 return serveFromCache(customDomain.did, rkey, path); 374 - }) 375 - // Internal observability endpoints (for admin panel) 376 - .get('/__internal__/observability/logs', ({ query }) => { 377 - const filter: any = {}; 378 - if (query.level) filter.level = query.level; 379 - if (query.service) filter.service = query.service; 380 - if (query.search) filter.search = query.search; 381 - if (query.eventType) filter.eventType = query.eventType; 382 - if (query.limit) filter.limit = parseInt(query.limit as string); 383 - return { logs: logCollector.getLogs(filter) }; 384 - }) 385 - .get('/__internal__/observability/errors', ({ query }) => { 386 - const filter: any = {}; 387 - if (query.service) filter.service = query.service; 388 - if (query.limit) filter.limit = parseInt(query.limit as string); 389 - return { errors: errorTracker.getErrors(filter) }; 390 - }) 391 - .get('/__internal__/observability/metrics', ({ query }) => { 392 - const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000; 393 - const stats = metricsCollector.getStats('hosting-service', timeWindow); 394 - return { stats, timeWindow }; 395 - }); 318 + } 319 + 320 + // Route 2: Registered subdomains - /*.wisp.place/* 321 + if (hostname.endsWith(`.${BASE_HOST}`)) { 322 + const domainInfo = await getWispDomain(hostname); 323 + if (!domainInfo) { 324 + return c.text('Subdomain not registered', 404); 325 + } 326 + 327 + if (!domainInfo.rkey) { 328 + return c.text('Domain not mapped to a site', 404); 329 + } 330 + 331 + const rkey = domainInfo.rkey; 332 + if (!isValidRkey(rkey)) { 333 + return c.text('Invalid site configuration', 500); 334 + } 335 + 336 + const cached = await ensureSiteCached(domainInfo.did, rkey); 337 + if (!cached) { 338 + return c.text('Site not found', 404); 339 + } 340 + 341 + return serveFromCache(domainInfo.did, rkey, path); 342 + } 343 + 344 + // Route 1: Custom domains - /* 345 + const customDomain = await getCustomDomain(hostname); 346 + if (!customDomain) { 347 + return c.text('Custom domain not found or not verified', 404); 348 + } 349 + 350 + if (!customDomain.rkey) { 351 + return c.text('Domain not mapped to a site', 404); 352 + } 353 + 354 + const rkey = customDomain.rkey; 355 + if (!isValidRkey(rkey)) { 356 + return c.text('Invalid site configuration', 500); 357 + } 358 + 359 + const cached = await ensureSiteCached(customDomain.did, rkey); 360 + if (!cached) { 361 + return c.text('Site not found', 404); 362 + } 363 + 364 + return serveFromCache(customDomain.did, rkey, path); 365 + }); 366 + 367 + // Internal observability endpoints (for admin panel) 368 + app.get('/__internal__/observability/logs', (c) => { 369 + const query = c.req.query(); 370 + const filter: any = {}; 371 + if (query.level) filter.level = query.level; 372 + if (query.service) filter.service = query.service; 373 + if (query.search) filter.search = query.search; 374 + if (query.eventType) filter.eventType = query.eventType; 375 + if (query.limit) filter.limit = parseInt(query.limit as string); 376 + return c.json({ logs: logCollector.getLogs(filter) }); 377 + }); 378 + 379 + app.get('/__internal__/observability/errors', (c) => { 380 + const query = c.req.query(); 381 + const filter: any = {}; 382 + if (query.service) filter.service = query.service; 383 + if (query.limit) filter.limit = parseInt(query.limit as string); 384 + return c.json({ errors: errorTracker.getErrors(filter) }); 385 + }); 386 + 387 + app.get('/__internal__/observability/metrics', (c) => { 388 + const query = c.req.query(); 389 + const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000; 390 + const stats = metricsCollector.getStats('hosting-service', timeWindow); 391 + return c.json({ stats, timeWindow }); 392 + }); 396 393 397 394 export default app;
+28
hosting-service/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + /* Base Options */ 4 + "esModuleInterop": true, 5 + "skipLibCheck": true, 6 + "target": "es2022", 7 + "allowJs": true, 8 + "resolveJsonModule": true, 9 + "moduleDetection": "force", 10 + "isolatedModules": true, 11 + "verbatimModuleSyntax": true, 12 + 13 + /* Strictness */ 14 + "strict": true, 15 + "noUncheckedIndexedAccess": true, 16 + "noImplicitOverride": true, 17 + "forceConsistentCasingInFileNames": true, 18 + 19 + /* Transpiling with TypeScript */ 20 + "module": "ESNext", 21 + "moduleResolution": "bundler", 22 + "outDir": "dist", 23 + "sourceMap": true, 24 + 25 + /* Code doesn't run in DOM */ 26 + "lib": ["es2022"] 27 + } 28 + }