+9
-9
hosting-service/bun.lock
+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
+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
+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
+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
+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
+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
-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
+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
+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
+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
+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
+
}