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

sigh

Changed files
+242 -399
hosting-service
+9 -9
hosting-service/bun.lock
···
"@atproto/api": "^0.17.4",
"@atproto/identity": "^0.4.9",
"@atproto/lexicon": "^0.5.1",
-
"@atproto/sync": "^0.1.35",
+
"@atproto/sync": "^0.1.36",
"@atproto/xrpc": "^0.7.5",
-
"@elysiajs/node": "^1.4.1",
+
"@elysiajs/node": "^1.4.2",
"@elysiajs/opentelemetry": "latest",
-
"elysia": "latest",
+
"elysia": "^1.4.15",
"mime-types": "^2.1.35",
"multiformats": "^13.4.1",
"postgres": "^3.4.5",
···
"@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" } }, ""],
-
"@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" } }, ""],
+
"@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=="],
"@atproto/syntax": ["@atproto/syntax@0.4.1", "", {}, ""],
···
"@cbor-extract/cbor-extract-darwin-arm64": ["@cbor-extract/cbor-extract-darwin-arm64@2.2.0", "", { "os": "darwin", "cpu": "arm64" }, ""],
-
"@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=="],
+
"@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=="],
"@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=="],
···
"ee-first": ["ee-first@1.1.1", "", {}, ""],
-
"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=="],
+
"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=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
···
"split2": ["split2@4.2.0", "", {}, ""],
-
"srvx": ["srvx@0.8.16", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-hmcGW4CgroeSmzgF1Ihwgl+Ths0JqAJ7HwjP2X7e3JzY7u4IydLMcdnlqGQiQGUswz+PO9oh/KtCpOISIvs9QQ=="],
+
"srvx": ["srvx@0.9.5", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-nQsA2c8q3XwbSn6kTxVQjz0zS096rV+Be2pzJwrYEAdtnYszLw4MTy8JWJjz1XEGBZwP0qW51SUIX3WdjdRemQ=="],
"statuses": ["statuses@2.0.1", "", {}, ""],
···
"uint8arrays/multiformats": ["multiformats@9.9.0", "", {}, ""],
-
"@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
"@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, ""],
-
"require-in-the-middle/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
"require-in-the-middle/debug/ms": ["ms@2.1.3", "", {}, ""],
}
}
+5 -4
hosting-service/package.json
···
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
-
"start": "node --loader tsx src/index.ts"
+
"build": "tsc",
+
"start": "tsx src/index.ts"
},
"dependencies": {
"@atproto/api": "^0.17.4",
"@atproto/identity": "^0.4.9",
"@atproto/lexicon": "^0.5.1",
-
"@atproto/sync": "^0.1.35",
+
"@atproto/sync": "^0.1.36",
"@atproto/xrpc": "^0.7.5",
-
"@elysiajs/opentelemetry": "latest",
-
"elysia": "latest",
+
"@hono/node-server": "^1.19.6",
+
"hono": "^4.10.4",
"mime-types": "^2.1.35",
"multiformats": "^13.4.1",
"postgres": "^3.4.5"
+13 -9
hosting-service/src/index.ts
···
import app from './server';
+
import { serve } from '@hono/node-server';
import { FirehoseWorker } from './lib/firehose';
import { logger } from './lib/observability';
import { mkdirSync, existsSync } from 'fs';
···
firehose.start();
// Add health check endpoint
-
app.get('/health', () => {
+
app.get('/health', (c) => {
const firehoseHealth = firehose.getHealth();
-
return {
+
return c.json({
status: 'ok',
firehose: firehoseHealth,
-
};
+
});
});
-
// Start HTTP server
-
app.listen(PORT, () => {
-
console.log(`
+
// Start HTTP server with Node.js adapter
+
const server = serve({
+
fetch: app.fetch,
+
port: PORT,
+
});
+
+
console.log(`
Wisp Hosting Service
Server: http://localhost:${PORT}
···
Cache: ${CACHE_DIR}
Firehose: Connected to Firehose
`);
-
});
// Graceful shutdown
process.on('SIGINT', async () => {
console.log('\n🛑 Shutting down...');
firehose.stop();
-
app.stop();
+
server.close();
process.exit(0);
});
process.on('SIGTERM', async () => {
console.log('\n🛑 Shutting down...');
firehose.stop();
-
app.stop();
+
server.close();
process.exit(0);
});
+1 -1
hosting-service/src/lexicon/types/place/wisp/fs.ts
···
* GENERATED CODE - DO NOT MODIFY
*/
import { type ValidationResult, BlobRef } from '@atproto/lexicon'
-
import { CID } from 'multiformats/cid'
+
import { CID } from 'multiformats'
import { validate as _validate } from '../../../lexicons'
import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util'
+8 -86
hosting-service/src/lib/db.ts
···
import postgres from 'postgres';
+
import { createHash } from 'crypto';
const sql = postgres(
process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/wisp',
···
verified: boolean;
}
-
// In-memory cache with TTL
-
interface CacheEntry<T> {
-
data: T;
-
expiry: number;
-
}
-
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
-
-
class SimpleCache<T> {
-
private cache = new Map<string, CacheEntry<T>>();
-
-
get(key: string): T | null {
-
const entry = this.cache.get(key);
-
if (!entry) return null;
-
-
if (Date.now() > entry.expiry) {
-
this.cache.delete(key);
-
return null;
-
}
-
-
return entry.data;
-
}
-
-
set(key: string, data: T): void {
-
this.cache.set(key, {
-
data,
-
expiry: Date.now() + CACHE_TTL_MS,
-
});
-
}
-
-
// Periodic cleanup to prevent memory leaks
-
cleanup(): void {
-
const now = Date.now();
-
for (const [key, entry] of this.cache.entries()) {
-
if (now > entry.expiry) {
-
this.cache.delete(key);
-
}
-
}
-
}
-
}
-
-
// Create cache instances
-
const wispDomainCache = new SimpleCache<DomainLookup | null>();
-
const customDomainCache = new SimpleCache<CustomDomainLookup | null>();
-
const customDomainHashCache = new SimpleCache<CustomDomainLookup | null>();
-
-
// Run cleanup every 5 minutes
-
setInterval(() => {
-
wispDomainCache.cleanup();
-
customDomainCache.cleanup();
-
customDomainHashCache.cleanup();
-
}, 5 * 60 * 1000);
export async function getWispDomain(domain: string): Promise<DomainLookup | null> {
const key = domain.toLowerCase();
-
// Check cache first
-
const cached = wispDomainCache.get(key);
-
if (cached !== null) {
-
return cached;
-
}
-
// Query database
const result = await sql<DomainLookup[]>`
SELECT did, rkey FROM domains WHERE domain = ${key} LIMIT 1
`;
const data = result[0] || null;
-
// Store in cache
-
wispDomainCache.set(key, data);
-
return data;
}
export async function getCustomDomain(domain: string): Promise<CustomDomainLookup | null> {
const key = domain.toLowerCase();
-
// Check cache first
-
const cached = customDomainCache.get(key);
-
if (cached !== null) {
-
return cached;
-
}
-
// Query database
const result = await sql<CustomDomainLookup[]>`
SELECT id, domain, did, rkey, verified FROM custom_domains
···
`;
const data = result[0] || null;
-
// Store in cache
-
customDomainCache.set(key, data);
-
return data;
}
export async function getCustomDomainByHash(hash: string): Promise<CustomDomainLookup | null> {
-
// Check cache first
-
const cached = customDomainHashCache.get(hash);
-
if (cached !== null) {
-
return cached;
-
}
-
// Query database
const result = await sql<CustomDomainLookup[]>`
SELECT id, domain, did, rkey, verified FROM custom_domains
WHERE id = ${hash} AND verified = true LIMIT 1
`;
const data = result[0] || null;
-
-
// Store in cache
-
customDomainHashCache.set(hash, data);
return data;
}
···
* PostgreSQL advisory locks use bigint (64-bit signed integer)
*/
function stringToLockId(key: string): bigint {
-
let hash = 0n;
-
for (let i = 0; i < key.length; i++) {
-
const char = BigInt(key.charCodeAt(i));
-
hash = ((hash << 5n) - hash + char) & 0x7FFFFFFFFFFFFFFFn; // Keep within signed int64 range
-
}
-
return hash;
+
const hash = createHash('sha256').update(key).digest('hex');
+
// Take first 16 hex characters (64 bits) and convert to bigint
+
const hashNum = BigInt('0x' + hash.substring(0, 16));
+
// Keep within signed int64 range
+
return hashNum & 0x7FFFFFFFFFFFFFFFn;
}
/**
···
const lockId = stringToLockId(key);
try {
-
const result = await sql`SELECT pg_try_advisory_lock(${lockId}) as acquired`;
+
const result = await sql`SELECT pg_try_advisory_lock(${Number(lockId)}) as acquired`;
return result[0]?.acquired === true;
} catch (err) {
console.error('Failed to acquire lock', { key, error: err });
···
const lockId = stringToLockId(key);
try {
-
await sql`SELECT pg_advisory_unlock(${lockId})`;
+
await sql`SELECT pg_advisory_unlock(${Number(lockId)})`;
} catch (err) {
console.error('Failed to release lock', { key, error: err });
}
+2 -2
hosting-service/src/lib/firehose.ts
···
idResolver: this.idResolver,
service: 'wss://bsky.network',
filterCollections: ['place.wisp.fs'],
-
handleEvent: async (evt) => {
+
handleEvent: async (evt: any) => {
this.lastEventTime = Date.now();
// Watch for write events
···
}
}
},
-
onError: (err) => {
+
onError: (err: any) => {
this.log('Firehose error', {
error: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined,
-107
hosting-service/src/lib/html-rewriter.test.ts
···
-
/**
-
* Simple tests for HTML path rewriter
-
* Run with: bun test
-
*/
-
-
import { test, expect } from 'bun:test';
-
import { rewriteHtmlPaths, isHtmlContent } from './html-rewriter';
-
-
test('rewriteHtmlPaths - rewrites absolute paths in src attributes', () => {
-
const html = '<img src="/logo.png">';
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
-
expect(result).toBe('<img src="/did:plc:123/mysite/logo.png">');
-
});
-
-
test('rewriteHtmlPaths - rewrites absolute paths in href attributes', () => {
-
const html = '<link rel="stylesheet" href="/style.css">';
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
-
expect(result).toBe('<link rel="stylesheet" href="/did:plc:123/mysite/style.css">');
-
});
-
-
test('rewriteHtmlPaths - preserves external URLs', () => {
-
const html = '<img src="https://example.com/logo.png">';
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
-
expect(result).toBe('<img src="https://example.com/logo.png">');
-
});
-
-
test('rewriteHtmlPaths - preserves protocol-relative URLs', () => {
-
const html = '<script src="//cdn.example.com/script.js"></script>';
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
-
expect(result).toBe('<script src="//cdn.example.com/script.js"></script>');
-
});
-
-
test('rewriteHtmlPaths - preserves data URIs', () => {
-
const html = '<img src="data:image/png;base64,abc123">';
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
-
expect(result).toBe('<img src="data:image/png;base64,abc123">');
-
});
-
-
test('rewriteHtmlPaths - preserves anchors', () => {
-
const html = '<a href="/#section">Jump</a>';
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
-
expect(result).toBe('<a href="/#section">Jump</a>');
-
});
-
-
test('rewriteHtmlPaths - preserves relative paths', () => {
-
const html = '<img src="./logo.png">';
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
-
expect(result).toBe('<img src="./logo.png">');
-
});
-
-
test('rewriteHtmlPaths - handles single quotes', () => {
-
const html = "<img src='/logo.png'>";
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
-
expect(result).toBe("<img src='/did:plc:123/mysite/logo.png'>");
-
});
-
-
test('rewriteHtmlPaths - handles srcset', () => {
-
const html = '<img srcset="/logo.png 1x, /logo@2x.png 2x">';
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
-
expect(result).toBe('<img srcset="/did:plc:123/mysite/logo.png 1x, /did:plc:123/mysite/logo@2x.png 2x">');
-
});
-
-
test('rewriteHtmlPaths - handles form actions', () => {
-
const html = '<form action="/submit"></form>';
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
-
expect(result).toBe('<form action="/did:plc:123/mysite/submit"></form>');
-
});
-
-
test('rewriteHtmlPaths - handles complex HTML', () => {
-
const html = `
-
<!DOCTYPE html>
-
<html>
-
<head>
-
<link rel="stylesheet" href="/style.css">
-
<script src="/app.js"></script>
-
</head>
-
<body>
-
<img src="/images/logo.png" srcset="/images/logo.png 1x, /images/logo@2x.png 2x">
-
<a href="/about">About</a>
-
<a href="https://example.com">External</a>
-
<a href="#section">Anchor</a>
-
</body>
-
</html>
-
`.trim();
-
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
-
-
expect(result).toContain('href="/did:plc:123/mysite/style.css"');
-
expect(result).toContain('src="/did:plc:123/mysite/app.js"');
-
expect(result).toContain('src="/did:plc:123/mysite/images/logo.png"');
-
expect(result).toContain('href="/did:plc:123/mysite/about"');
-
expect(result).toContain('href="https://example.com"'); // External preserved
-
expect(result).toContain('href="#section"'); // Anchor preserved
-
});
-
-
test('isHtmlContent - detects HTML by extension', () => {
-
expect(isHtmlContent('index.html')).toBe(true);
-
expect(isHtmlContent('page.htm')).toBe(true);
-
expect(isHtmlContent('style.css')).toBe(false);
-
expect(isHtmlContent('script.js')).toBe(false);
-
});
-
-
test('isHtmlContent - detects HTML by content type', () => {
-
expect(isHtmlContent('index', 'text/html')).toBe(true);
-
expect(isHtmlContent('index', 'text/html; charset=utf-8')).toBe(true);
-
expect(isHtmlContent('index', 'application/json')).toBe(false);
-
});
+36 -38
hosting-service/src/lib/observability.ts
···
// DIY Observability for Hosting Service
-
import type { Context } from 'elysia'
+
import type { Context } from 'hono'
// Types
export interface LogEntry {
···
// Rotate if needed
if (errors.size > MAX_ERRORS) {
const oldest = Array.from(errors.keys())[0]
-
errors.delete(oldest)
+
if (oldest !== undefined) {
+
errors.delete(oldest)
+
}
}
}
},
···
return {
totalRequests: filtered.length,
avgDuration: Math.round(totalDuration / filtered.length),
-
p50Duration: Math.round(p50),
-
p95Duration: Math.round(p95),
-
p99Duration: Math.round(p99),
+
p50Duration: Math.round(p50 ?? 0),
+
p95Duration: Math.round(p95 ?? 0),
+
p99Duration: Math.round(p99 ?? 0),
errorRate: (errors / filtered.length) * 100,
requestsPerMinute: Math.round(filtered.length / timeWindowMinutes)
}
···
}
}
-
// Elysia middleware for request timing
+
// Hono middleware for request timing
export function observabilityMiddleware(service: string) {
-
return {
-
beforeHandle: ({ request }: any) => {
-
(request as any).__startTime = Date.now()
-
},
-
afterHandle: ({ request, set }: any) => {
-
const duration = Date.now() - ((request as any).__startTime || Date.now())
-
const url = new URL(request.url)
+
return async (c: Context, next: () => Promise<void>) => {
+
const startTime = Date.now()
+
+
await next()
+
+
const duration = Date.now() - startTime
+
const { pathname } = new URL(c.req.url)
-
metricsCollector.recordRequest(
-
url.pathname,
-
request.method,
-
set.status || 200,
-
duration,
-
service
-
)
-
},
-
onError: ({ request, error, set }: any) => {
-
const duration = Date.now() - ((request as any).__startTime || Date.now())
-
const url = new URL(request.url)
+
metricsCollector.recordRequest(
+
pathname,
+
c.req.method,
+
c.res.status,
+
duration,
+
service
+
)
+
}
+
}
-
metricsCollector.recordRequest(
-
url.pathname,
-
request.method,
-
set.status || 500,
-
duration,
-
service
-
)
+
// Hono error handler
+
export function observabilityErrorHandler(service: string) {
+
return (err: Error, c: Context) => {
+
const { pathname } = new URL(c.req.url)
+
+
logCollector.error(
+
`Request failed: ${c.req.method} ${pathname}`,
+
service,
+
err,
+
{ statusCode: c.res.status || 500 }
+
)
-
logCollector.error(
-
`Request failed: ${request.method} ${url.pathname}`,
-
service,
-
error,
-
{ statusCode: set.status || 500 }
-
)
-
}
+
return c.text('Internal Server Error', 500)
}
}
+1 -1
hosting-service/src/lib/utils.ts
···
import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs';
import { writeFile, readFile, rename } from 'fs/promises';
import { safeFetchJson, safeFetchBlob } from './safe-fetch';
-
import { CID } from 'multiformats/cid';
+
import { CID } from 'multiformats';
const CACHE_DIR = './cache/sites';
const CACHE_TTL = 14 * 24 * 60 * 60 * 1000; // 14 days cache TTL
+139 -142
hosting-service/src/server.ts
···
-
import { Elysia } from 'elysia';
-
import { node } from '@elysiajs/node'
-
import { opentelemetry } from '@elysiajs/opentelemetry';
+
import { Hono } from 'hono';
import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db';
import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath } from './lib/utils';
import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter';
import { existsSync, readFileSync } from 'fs';
import { lookup } from 'mime-types';
-
import { logger, observabilityMiddleware, logCollector, errorTracker, metricsCollector } from './lib/observability';
+
import { logger, observabilityMiddleware, observabilityErrorHandler, logCollector, errorTracker, metricsCollector } from './lib/observability';
const BASE_HOST = process.env.BASE_HOST || 'wisp.place';
···
}
}
-
const app = new Elysia({ adapter: node() })
-
.use(opentelemetry())
-
.onBeforeHandle(observabilityMiddleware('hosting-service').beforeHandle)
-
.onAfterHandle(observabilityMiddleware('hosting-service').afterHandle)
-
.onError(observabilityMiddleware('hosting-service').onError)
-
.get('/*', async ({ request, set }) => {
-
const url = new URL(request.url);
-
const hostname = request.headers.get('host') || '';
-
const rawPath = url.pathname.replace(/^\//, '');
-
const path = sanitizePath(rawPath);
+
const app = new Hono();
-
// Check if this is sites.wisp.place subdomain
-
if (hostname === `sites.${BASE_HOST}` || hostname === `sites.${BASE_HOST}:${process.env.PORT || 3000}`) {
-
// Sanitize the path FIRST to prevent path traversal
-
const sanitizedFullPath = sanitizePath(rawPath);
+
// Add observability middleware
+
app.use('*', observabilityMiddleware('hosting-service'));
-
// Extract identifier and site from sanitized path: did:plc:123abc/sitename/file.html
-
const pathParts = sanitizedFullPath.split('/');
-
if (pathParts.length < 2) {
-
set.status = 400;
-
return 'Invalid path format. Expected: /identifier/sitename/path';
-
}
+
// Error handler
+
app.onError(observabilityErrorHandler('hosting-service'));
-
const identifier = pathParts[0];
-
const site = pathParts[1];
-
const filePath = pathParts.slice(2).join('/');
+
// Main site serving route
+
app.get('/*', async (c) => {
+
const url = new URL(c.req.url);
+
const hostname = c.req.header('host') || '';
+
const rawPath = url.pathname.replace(/^\//, '');
+
const path = sanitizePath(rawPath);
-
// Additional validation: identifier must be a valid DID or handle format
-
if (!identifier || identifier.length < 3 || identifier.includes('..') || identifier.includes('\0')) {
-
set.status = 400;
-
return 'Invalid identifier';
-
}
+
// Check if this is sites.wisp.place subdomain
+
if (hostname === `sites.${BASE_HOST}` || hostname === `sites.${BASE_HOST}:${process.env.PORT || 3000}`) {
+
// Sanitize the path FIRST to prevent path traversal
+
const sanitizedFullPath = sanitizePath(rawPath);
-
// Validate site name (rkey)
-
if (!isValidRkey(site)) {
-
set.status = 400;
-
return 'Invalid site name';
-
}
-
-
// Resolve identifier to DID
-
const did = await resolveDid(identifier);
-
if (!did) {
-
set.status = 400;
-
return 'Invalid identifier';
-
}
+
// Extract identifier and site from sanitized path: did:plc:123abc/sitename/file.html
+
const pathParts = sanitizedFullPath.split('/');
+
if (pathParts.length < 2) {
+
return c.text('Invalid path format. Expected: /identifier/sitename/path', 400);
+
}
-
// Ensure site is cached
-
const cached = await ensureSiteCached(did, site);
-
if (!cached) {
-
set.status = 404;
-
return 'Site not found';
-
}
+
const identifier = pathParts[0];
+
const site = pathParts[1];
+
const filePath = pathParts.slice(2).join('/');
-
// Serve with HTML path rewriting to handle absolute paths
-
const basePath = `/${identifier}/${site}/`;
-
return serveFromCacheWithRewrite(did, site, filePath, basePath);
+
// Additional validation: identifier must be a valid DID or handle format
+
if (!identifier || identifier.length < 3 || identifier.includes('..') || identifier.includes('\0')) {
+
return c.text('Invalid identifier', 400);
}
-
// Check if this is a DNS hash subdomain
-
const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/);
-
if (dnsMatch) {
-
const hash = dnsMatch[1];
-
const baseDomain = dnsMatch[2];
+
// Validate site parameter exists
+
if (!site) {
+
return c.text('Site name required', 400);
+
}
-
if (baseDomain !== BASE_HOST) {
-
set.status = 400;
-
return 'Invalid base domain';
-
}
+
// Validate site name (rkey)
+
if (!isValidRkey(site)) {
+
return c.text('Invalid site name', 400);
+
}
-
const customDomain = await getCustomDomainByHash(hash);
-
if (!customDomain) {
-
set.status = 404;
-
return 'Custom domain not found or not verified';
-
}
+
// Resolve identifier to DID
+
const did = await resolveDid(identifier);
+
if (!did) {
+
return c.text('Invalid identifier', 400);
+
}
-
if (!customDomain.rkey) {
-
set.status = 404;
-
return 'Domain not mapped to a site';
-
}
+
// Ensure site is cached
+
const cached = await ensureSiteCached(did, site);
+
if (!cached) {
+
return c.text('Site not found', 404);
+
}
-
const rkey = customDomain.rkey;
-
if (!isValidRkey(rkey)) {
-
set.status = 500;
-
return 'Invalid site configuration';
-
}
+
// Serve with HTML path rewriting to handle absolute paths
+
const basePath = `/${identifier}/${site}/`;
+
return serveFromCacheWithRewrite(did, site, filePath, basePath);
+
}
-
const cached = await ensureSiteCached(customDomain.did, rkey);
-
if (!cached) {
-
set.status = 404;
-
return 'Site not found';
-
}
+
// Check if this is a DNS hash subdomain
+
const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/);
+
if (dnsMatch) {
+
const hash = dnsMatch[1];
+
const baseDomain = dnsMatch[2];
-
return serveFromCache(customDomain.did, rkey, path);
+
if (!hash) {
+
return c.text('Invalid DNS hash', 400);
}
-
// Route 2: Registered subdomains - /*.wisp.place/*
-
if (hostname.endsWith(`.${BASE_HOST}`)) {
-
const subdomain = hostname.replace(`.${BASE_HOST}`, '');
-
-
const domainInfo = await getWispDomain(hostname);
-
if (!domainInfo) {
-
set.status = 404;
-
return 'Subdomain not registered';
-
}
-
-
if (!domainInfo.rkey) {
-
set.status = 404;
-
return 'Domain not mapped to a site';
-
}
-
-
const rkey = domainInfo.rkey;
-
if (!isValidRkey(rkey)) {
-
set.status = 500;
-
return 'Invalid site configuration';
-
}
-
-
const cached = await ensureSiteCached(domainInfo.did, rkey);
-
if (!cached) {
-
set.status = 404;
-
return 'Site not found';
-
}
-
-
return serveFromCache(domainInfo.did, rkey, path);
+
if (baseDomain !== BASE_HOST) {
+
return c.text('Invalid base domain', 400);
}
-
// Route 1: Custom domains - /*
-
const customDomain = await getCustomDomain(hostname);
+
const customDomain = await getCustomDomainByHash(hash);
if (!customDomain) {
-
set.status = 404;
-
return 'Custom domain not found or not verified';
+
return c.text('Custom domain not found or not verified', 404);
}
if (!customDomain.rkey) {
-
set.status = 404;
-
return 'Domain not mapped to a site';
+
return c.text('Domain not mapped to a site', 404);
}
const rkey = customDomain.rkey;
if (!isValidRkey(rkey)) {
-
set.status = 500;
-
return 'Invalid site configuration';
+
return c.text('Invalid site configuration', 500);
}
const cached = await ensureSiteCached(customDomain.did, rkey);
if (!cached) {
-
set.status = 404;
-
return 'Site not found';
+
return c.text('Site not found', 404);
}
return serveFromCache(customDomain.did, rkey, path);
-
})
-
// Internal observability endpoints (for admin panel)
-
.get('/__internal__/observability/logs', ({ query }) => {
-
const filter: any = {};
-
if (query.level) filter.level = query.level;
-
if (query.service) filter.service = query.service;
-
if (query.search) filter.search = query.search;
-
if (query.eventType) filter.eventType = query.eventType;
-
if (query.limit) filter.limit = parseInt(query.limit as string);
-
return { logs: logCollector.getLogs(filter) };
-
})
-
.get('/__internal__/observability/errors', ({ query }) => {
-
const filter: any = {};
-
if (query.service) filter.service = query.service;
-
if (query.limit) filter.limit = parseInt(query.limit as string);
-
return { errors: errorTracker.getErrors(filter) };
-
})
-
.get('/__internal__/observability/metrics', ({ query }) => {
-
const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000;
-
const stats = metricsCollector.getStats('hosting-service', timeWindow);
-
return { stats, timeWindow };
-
});
+
}
+
+
// Route 2: Registered subdomains - /*.wisp.place/*
+
if (hostname.endsWith(`.${BASE_HOST}`)) {
+
const domainInfo = await getWispDomain(hostname);
+
if (!domainInfo) {
+
return c.text('Subdomain not registered', 404);
+
}
+
+
if (!domainInfo.rkey) {
+
return c.text('Domain not mapped to a site', 404);
+
}
+
+
const rkey = domainInfo.rkey;
+
if (!isValidRkey(rkey)) {
+
return c.text('Invalid site configuration', 500);
+
}
+
+
const cached = await ensureSiteCached(domainInfo.did, rkey);
+
if (!cached) {
+
return c.text('Site not found', 404);
+
}
+
+
return serveFromCache(domainInfo.did, rkey, path);
+
}
+
+
// Route 1: Custom domains - /*
+
const customDomain = await getCustomDomain(hostname);
+
if (!customDomain) {
+
return c.text('Custom domain not found or not verified', 404);
+
}
+
+
if (!customDomain.rkey) {
+
return c.text('Domain not mapped to a site', 404);
+
}
+
+
const rkey = customDomain.rkey;
+
if (!isValidRkey(rkey)) {
+
return c.text('Invalid site configuration', 500);
+
}
+
+
const cached = await ensureSiteCached(customDomain.did, rkey);
+
if (!cached) {
+
return c.text('Site not found', 404);
+
}
+
+
return serveFromCache(customDomain.did, rkey, path);
+
});
+
+
// Internal observability endpoints (for admin panel)
+
app.get('/__internal__/observability/logs', (c) => {
+
const query = c.req.query();
+
const filter: any = {};
+
if (query.level) filter.level = query.level;
+
if (query.service) filter.service = query.service;
+
if (query.search) filter.search = query.search;
+
if (query.eventType) filter.eventType = query.eventType;
+
if (query.limit) filter.limit = parseInt(query.limit as string);
+
return c.json({ logs: logCollector.getLogs(filter) });
+
});
+
+
app.get('/__internal__/observability/errors', (c) => {
+
const query = c.req.query();
+
const filter: any = {};
+
if (query.service) filter.service = query.service;
+
if (query.limit) filter.limit = parseInt(query.limit as string);
+
return c.json({ errors: errorTracker.getErrors(filter) });
+
});
+
+
app.get('/__internal__/observability/metrics', (c) => {
+
const query = c.req.query();
+
const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000;
+
const stats = metricsCollector.getStats('hosting-service', timeWindow);
+
return c.json({ stats, timeWindow });
+
});
export default app;
+28
hosting-service/tsconfig.json
···
+
{
+
"compilerOptions": {
+
/* Base Options */
+
"esModuleInterop": true,
+
"skipLibCheck": true,
+
"target": "es2022",
+
"allowJs": true,
+
"resolveJsonModule": true,
+
"moduleDetection": "force",
+
"isolatedModules": true,
+
"verbatimModuleSyntax": true,
+
+
/* Strictness */
+
"strict": true,
+
"noUncheckedIndexedAccess": true,
+
"noImplicitOverride": true,
+
"forceConsistentCasingInFileNames": true,
+
+
/* Transpiling with TypeScript */
+
"module": "ESNext",
+
"moduleResolution": "bundler",
+
"outDir": "dist",
+
"sourceMap": true,
+
+
/* Code doesn't run in DOM */
+
"lib": ["es2022"]
+
}
+
}