@agentlip/hub 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,878 @@
1
+ // Bun hub daemon (HTTP + WS)
2
+ import { randomUUID } from "node:crypto";
3
+ import { join } from "node:path";
4
+ import type { Server } from "bun";
5
+ import type { HealthResponse } from "@agentlip/protocol";
6
+ import { PROTOCOL_VERSION } from "@agentlip/protocol";
7
+ import { openDb, runMigrations, MIGRATIONS_DIR as KERNEL_MIGRATIONS_DIR } from "@agentlip/kernel";
8
+ import { requireAuth, requireWsToken } from "./authMiddleware";
9
+ import { handleApiV1, type ApiV1Context } from "./apiV1";
10
+ import { createWsHub, createWsHandlers } from "./wsEndpoint";
11
+ import {
12
+ HubRateLimiter,
13
+ rateLimitedResponse,
14
+ addRateLimitHeaders,
15
+ type RateLimiterConfig,
16
+ } from "./rateLimiter";
17
+ import { readJsonBody, SIZE_LIMITS } from "./bodyParser";
18
+ import { acquireWriterLock, releaseWriterLock } from "./lock";
19
+ import {
20
+ writeServerJson,
21
+ readServerJson,
22
+ removeServerJson,
23
+ type ServerJsonData,
24
+ } from "./serverJson";
25
+ import { generateAuthToken } from "./authToken";
26
+ import { loadWorkspaceConfig, type WorkspaceConfig } from "./config";
27
+ import { runLinkifierPluginsForMessage } from "./linkifierDerived";
28
+ import { runExtractorPluginsForMessage } from "./extractorDerived";
29
+ import { handleUiRequest } from "./ui";
30
+
31
+ // ─────────────────────────────────────────────────────────────────────────────
32
+ // Structured JSON logger
33
+ // ─────────────────────────────────────────────────────────────────────────────
34
+
35
+ /** Cached test environment detection result */
36
+ let _isTestEnvCached: boolean | null = null;
37
+
38
+ /** Detect if running under test environment to suppress log noise */
39
+ function isTestEnvironment(): boolean {
40
+ if (_isTestEnvCached !== null) return _isTestEnvCached;
41
+
42
+ _isTestEnvCached =
43
+ // Explicit environment variables (conventional)
44
+ process.env.NODE_ENV === "test" ||
45
+ process.env.VITEST !== undefined ||
46
+ process.env.JEST_WORKER_ID !== undefined ||
47
+ // Bun test: entry point is a .test. or _test. file
48
+ (process.argv[1]?.match(/[._]test\.[jt]sx?$/) !== null) ||
49
+ // CI test runners often set this
50
+ process.env.CI === "true" && process.env.AGENTLIP_LOG_LEVEL === undefined;
51
+
52
+ return _isTestEnvCached;
53
+ }
54
+
55
+ /** Structured log entry for HTTP requests */
56
+ export interface HttpLogEntry {
57
+ ts: string;
58
+ level: "info" | "warn" | "error";
59
+ msg: string;
60
+ method: string;
61
+ path: string;
62
+ status: number;
63
+ duration_ms: number;
64
+ instance_id: string;
65
+ request_id: string;
66
+ event_ids?: number[];
67
+ content_length?: number;
68
+ }
69
+
70
+ /** Emit a structured JSON log line (no-op in test environment) */
71
+ function emitLog(entry: HttpLogEntry): void {
72
+ if (isTestEnvironment()) return;
73
+ // Write to stdout as single JSON line
74
+ console.log(JSON.stringify(entry));
75
+ }
76
+
77
+ /** Get or generate request ID from X-Request-ID header */
78
+ function getRequestId(req: Request): string {
79
+ return req.headers.get("X-Request-ID") ?? randomUUID();
80
+ }
81
+
82
+ /** Security headers applied to all responses */
83
+ const SECURITY_HEADERS = {
84
+ 'X-Frame-Options': 'DENY',
85
+ 'X-Content-Type-Options': 'nosniff',
86
+ 'X-XSS-Protection': '1; mode=block',
87
+ 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws://localhost:* ws://127.0.0.1:*; frame-ancestors 'none'",
88
+ 'Referrer-Policy': 'no-referrer',
89
+ } as const;
90
+
91
+ /** Add security headers to response */
92
+ function withSecurityHeaders(response: Response): Response {
93
+ const newHeaders = new Headers(response.headers);
94
+ for (const [key, value] of Object.entries(SECURITY_HEADERS)) {
95
+ newHeaders.set(key, value);
96
+ }
97
+ return new Response(response.body, {
98
+ status: response.status,
99
+ statusText: response.statusText,
100
+ headers: newHeaders,
101
+ });
102
+ }
103
+
104
+ /** Add X-Request-ID header to response */
105
+ function withRequestIdHeader(response: Response, requestId: string): Response {
106
+ // Clone response to add header (Response headers may be immutable)
107
+ const newHeaders = new Headers(response.headers);
108
+ newHeaders.set("X-Request-ID", requestId);
109
+ return new Response(response.body, {
110
+ status: response.status,
111
+ statusText: response.statusText,
112
+ headers: newHeaders,
113
+ });
114
+ }
115
+
116
+ // Re-export auth middleware utilities
117
+ export {
118
+ parseBearerToken,
119
+ requireAuth,
120
+ requireWsToken,
121
+ type AuthResult,
122
+ type AuthOk,
123
+ type AuthFailure,
124
+ type WsAuthResult,
125
+ type WsAuthOk,
126
+ type WsAuthFailure,
127
+ } from "./authMiddleware";
128
+
129
+ // Re-export rate limiter utilities
130
+ export {
131
+ RateLimiter,
132
+ HubRateLimiter,
133
+ rateLimitedResponse,
134
+ addRateLimitHeaders,
135
+ DEFAULT_RATE_LIMITS,
136
+ type RateLimiterConfig,
137
+ type RateLimitResult,
138
+ } from "./rateLimiter";
139
+
140
+ // Re-export body parser utilities
141
+ export {
142
+ readJsonBody,
143
+ parseWsMessage,
144
+ validateWsMessageSize,
145
+ validateJsonSize,
146
+ payloadTooLargeResponse,
147
+ invalidJsonResponse,
148
+ invalidContentTypeResponse,
149
+ validationErrorResponse,
150
+ SIZE_LIMITS,
151
+ type ReadJsonBodyOptions,
152
+ type JsonBodyResult,
153
+ } from "./bodyParser";
154
+
155
+ // Re-export HTTP API handler utilities
156
+ export { handleApiV1, type ApiV1Context, type UrlExtractionConfig } from "./apiV1";
157
+
158
+ // Re-export WS endpoint utilities
159
+ export {
160
+ createWsHub,
161
+ createWsHandlers,
162
+ type WsHub,
163
+ type WsHandlers,
164
+ } from "./wsEndpoint";
165
+
166
+ // Re-export config utilities
167
+ export {
168
+ loadWorkspaceConfig,
169
+ validateWorkspaceConfig,
170
+ validatePluginModulePath,
171
+ type WorkspaceConfig,
172
+ type PluginConfig,
173
+ type LoadConfigResult,
174
+ } from "./config";
175
+
176
+ export interface StartHubOptions {
177
+ host?: string;
178
+ port?: number;
179
+ instanceId?: string;
180
+ dbId?: string;
181
+ schemaVersion?: number;
182
+ /** SQLite db file path. Defaults to in-memory (":memory:") for tests. */
183
+ dbPath?: string;
184
+ /**
185
+ * Workspace root directory (enables daemon mode).
186
+ * When provided, hub will:
187
+ * - Acquire writer lock (.agentlip/locks/writer.lock)
188
+ * - Write server.json (.agentlip/server.json with mode 0600)
189
+ * - Clean up on shutdown (remove lock + server.json)
190
+ */
191
+ workspaceRoot?: string;
192
+ /** Directory containing SQL migrations (defaults to repo migrations/). */
193
+ migrationsDir?: string;
194
+ /** Enable optional FTS5 migration (opportunistic, non-fatal). If undefined, uses AGENTLIP_ENABLE_FTS env var (1=enabled, 0=disabled). Default: false. */
195
+ enableFts?: boolean;
196
+ allowUnsafeNetwork?: boolean;
197
+ /** Auth token for mutation endpoints + WS. If not provided, mutations are rejected. */
198
+ authToken?: string;
199
+ /** Rate limit config for per-client limiting */
200
+ rateLimitPerClient?: RateLimiterConfig;
201
+ /** Rate limit config for global limiting */
202
+ rateLimitGlobal?: RateLimiterConfig;
203
+ /** Disable rate limiting (for testing) */
204
+ disableRateLimiting?: boolean;
205
+ /** URL extraction configuration for auto-creating attachments from message content. */
206
+ urlExtraction?: {
207
+ allowlist?: RegExp[];
208
+ blocklist?: RegExp[];
209
+ };
210
+ }
211
+
212
+ export interface HubServer {
213
+ server: Server<unknown>;
214
+ instanceId: string;
215
+ port: number;
216
+ host: string;
217
+ stop(): Promise<void>;
218
+ }
219
+
220
+ /**
221
+ * Validates that the bind host is localhost-only (127.0.0.1 or ::1).
222
+ * Rejects 0.0.0.0 unless allowUnsafeNetwork flag is explicitly set.
223
+ *
224
+ * @throws Error if host is not localhost and allowUnsafeNetwork is false
225
+ */
226
+ export function assertLocalhostBind(
227
+ host: string,
228
+ options?: { allowUnsafeNetwork?: boolean }
229
+ ): void {
230
+ const normalized = host.trim().toLowerCase();
231
+
232
+ // Allow localhost variants
233
+ const localhostVariants = [
234
+ "127.0.0.1",
235
+ "localhost",
236
+ "::1",
237
+ "[::1]",
238
+ ];
239
+
240
+ if (localhostVariants.includes(normalized)) {
241
+ return;
242
+ }
243
+
244
+ // Reject 0.0.0.0 and :: unless explicitly allowed
245
+ const unsafeHosts = ["0.0.0.0", "::", "[::]"];
246
+ if (unsafeHosts.includes(normalized)) {
247
+ if (options?.allowUnsafeNetwork === true) {
248
+ return;
249
+ }
250
+ throw new Error(
251
+ `Refusing to bind to ${host}: network-exposed binding is unsafe. ` +
252
+ `Use 127.0.0.1 or ::1 for localhost, or pass allowUnsafeNetwork: true to override.`
253
+ );
254
+ }
255
+
256
+ // Reject any other non-localhost address
257
+ throw new Error(
258
+ `Invalid bind host: ${host}. Must be localhost (127.0.0.1 or ::1). ` +
259
+ `Use allowUnsafeNetwork: true to bind to network interfaces.`
260
+ );
261
+ }
262
+
263
+ /**
264
+ * Resolve FTS enablement from options or environment variable.
265
+ *
266
+ * Precedence:
267
+ * 1. Explicit enableFts parameter (if provided)
268
+ * 2. AGENTLIP_ENABLE_FTS env var (1=enabled, 0=disabled)
269
+ * 3. Default: false
270
+ */
271
+ function resolveFtsEnabled(enableFts?: boolean): boolean {
272
+ if (enableFts !== undefined) {
273
+ return enableFts;
274
+ }
275
+
276
+ const envValue = process.env.AGENTLIP_ENABLE_FTS;
277
+ if (envValue === "1") return true;
278
+ if (envValue === "0") return false;
279
+
280
+ return false;
281
+ }
282
+
283
+ /**
284
+ * Start the Agentlip hub HTTP server.
285
+ *
286
+ * Implements:
287
+ * - GET /health endpoint (unauthenticated, always returns 200 when responsive)
288
+ * - Localhost-only bind validation by default
289
+ * - FTS configuration via options or AGENTLIP_ENABLE_FTS env var
290
+ * - Workspace-aware daemon mode (when workspaceRoot provided):
291
+ * - Acquires writer lock (.agentlip/locks/writer.lock)
292
+ * - Writes server.json (.agentlip/server.json with mode 0600)
293
+ * - Graceful shutdown removes lock + server.json
294
+ *
295
+ * @param options Configuration options
296
+ * @returns HubServer instance with stop() method
297
+ */
298
+ export async function startHub(options: StartHubOptions = {}): Promise<HubServer> {
299
+ const {
300
+ host = "127.0.0.1",
301
+ port = 0, // 0 = random available port
302
+ instanceId = randomUUID(),
303
+ dbId,
304
+ schemaVersion,
305
+ dbPath = ":memory:",
306
+ workspaceRoot,
307
+ migrationsDir,
308
+ enableFts,
309
+ allowUnsafeNetwork = false,
310
+ authToken: providedAuthToken,
311
+ rateLimitPerClient,
312
+ rateLimitGlobal,
313
+ disableRateLimiting = false,
314
+ } = options;
315
+
316
+ const effectiveEnableFts = resolveFtsEnabled(enableFts);
317
+
318
+ // Validate localhost-only bind
319
+ assertLocalhostBind(host, { allowUnsafeNetwork });
320
+
321
+ // Daemon mode: acquire writer lock before starting server
322
+ const daemonMode = !!workspaceRoot;
323
+ if (daemonMode && workspaceRoot) {
324
+ // Health check function for lock acquisition
325
+ const healthCheck = async (serverJson: ServerJsonData): Promise<boolean> => {
326
+ try {
327
+ const healthUrl = `http://${serverJson.host}:${serverJson.port}/health`;
328
+ const res = await fetch(healthUrl, {
329
+ signal: AbortSignal.timeout(2000), // 2s timeout
330
+ });
331
+ if (!res.ok) return false;
332
+
333
+ const health = await res.json();
334
+ // Verify instance_id matches (same hub instance)
335
+ return health.instance_id === serverJson.instance_id;
336
+ } catch {
337
+ return false; // Any error = stale
338
+ }
339
+ };
340
+
341
+ // Acquire writer lock (throws if live hub exists)
342
+ await acquireWriterLock({ workspaceRoot, healthCheck });
343
+ }
344
+
345
+ // Determine auth token (daemon mode: load from server.json or generate new)
346
+ let authToken = providedAuthToken;
347
+ if (daemonMode && workspaceRoot && !authToken) {
348
+ // Try to load existing token from server.json (if present and valid)
349
+ const existingServerJson = await readServerJson({ workspaceRoot });
350
+ if (existingServerJson?.auth_token) {
351
+ authToken = existingServerJson.auth_token;
352
+ } else {
353
+ // Generate new token for this instance
354
+ authToken = generateAuthToken();
355
+ }
356
+ }
357
+
358
+ // Load workspace config (agentlip.config.ts) in daemon mode (optional file)
359
+ let workspaceConfig: WorkspaceConfig | null = null;
360
+ if (daemonMode && workspaceRoot) {
361
+ try {
362
+ const loaded = await loadWorkspaceConfig(workspaceRoot);
363
+ workspaceConfig = loaded?.config ?? null;
364
+ } catch (err) {
365
+ // Config load failed - release lock and abort startup
366
+ await releaseWriterLock({ workspaceRoot });
367
+ throw err;
368
+ }
369
+ }
370
+
371
+ // Open database (default: in-memory for tests) and apply migrations
372
+ const effectiveMigrationsDir =
373
+ migrationsDir ?? KERNEL_MIGRATIONS_DIR;
374
+ const db = openDb({ dbPath });
375
+ try {
376
+ runMigrations({ db, migrationsDir: effectiveMigrationsDir, enableFts: effectiveEnableFts });
377
+ } catch (err) {
378
+ db.close();
379
+ throw err;
380
+ }
381
+
382
+ // Read meta values (used as defaults for /health if not explicitly provided)
383
+ const readMetaValue = (key: string): string | null => {
384
+ try {
385
+ const row = db
386
+ .query<{ value: string }, [string]>(
387
+ "SELECT value FROM meta WHERE key = ?"
388
+ )
389
+ .get(key);
390
+ return row?.value ?? null;
391
+ } catch {
392
+ return null;
393
+ }
394
+ };
395
+
396
+ const metaDbId = readMetaValue("db_id");
397
+ const metaSchemaVersion = (() => {
398
+ const raw = readMetaValue("schema_version");
399
+ if (!raw) return null;
400
+ const n = parseInt(raw, 10);
401
+ return Number.isFinite(n) ? n : null;
402
+ })();
403
+
404
+ const effectiveDbId = dbId ?? metaDbId ?? "unknown";
405
+ const effectiveSchemaVersion = schemaVersion ?? metaSchemaVersion ?? 0;
406
+
407
+ // Initialize WS hub (used when /ws is upgraded)
408
+ const wsHub = createWsHub({ db, instanceId }) as any;
409
+ const wsHandlers = createWsHandlers({
410
+ db,
411
+ authToken: authToken ?? "",
412
+ hub: wsHub,
413
+ });
414
+
415
+ // ─────────────────────────────────────────────────────────────────────────────
416
+ // Plugin derived pipelines (linkifiers/extractors)
417
+ // ─────────────────────────────────────────────────────────────────────────────
418
+
419
+ const hasEnabledLinkifiers =
420
+ workspaceRoot != null &&
421
+ workspaceConfig?.plugins?.some(
422
+ (p) => p.enabled && p.type === "linkifier" && typeof p.module === "string"
423
+ ) === true;
424
+
425
+ const hasEnabledExtractors =
426
+ workspaceRoot != null &&
427
+ workspaceConfig?.plugins?.some(
428
+ (p) => p.enabled && p.type === "extractor" && typeof p.module === "string"
429
+ ) === true;
430
+
431
+ const getEventInfoStmt = db.query<
432
+ { name: string; entity_id: string },
433
+ [number]
434
+ >(
435
+ "SELECT name, entity_id FROM events WHERE event_id = ?"
436
+ );
437
+
438
+ function scheduleDerivedPluginsForMessage(messageId: string): void {
439
+ if (!workspaceRoot || !workspaceConfig) return;
440
+ if (!hasEnabledLinkifiers && !hasEnabledExtractors) return;
441
+
442
+ // Defer to avoid blocking the request that triggered the mutation.
443
+ setTimeout(() => {
444
+ if (hasEnabledLinkifiers) {
445
+ void runLinkifierPluginsForMessage({
446
+ db,
447
+ workspaceRoot,
448
+ workspaceConfig,
449
+ messageId,
450
+ onEventIds: (ids) => wsHub.publishEventIds(ids),
451
+ }).catch((err) => {
452
+ if (!isTestEnvironment()) {
453
+ console.warn(
454
+ `[plugins] linkifier pipeline failed for message ${messageId}: ${err?.message ?? String(err)}`
455
+ );
456
+ }
457
+ });
458
+ }
459
+
460
+ if (hasEnabledExtractors) {
461
+ void runExtractorPluginsForMessage({
462
+ db,
463
+ workspaceRoot,
464
+ workspaceConfig,
465
+ messageId,
466
+ onEventIds: (ids) => wsHub.publishEventIds(ids),
467
+ }).catch((err) => {
468
+ if (!isTestEnvironment()) {
469
+ console.warn(
470
+ `[plugins] extractor pipeline failed for message ${messageId}: ${err?.message ?? String(err)}`
471
+ );
472
+ }
473
+ });
474
+ }
475
+ }, 0);
476
+ }
477
+
478
+ function maybeSchedulePluginsForEventIds(eventIds: number[]): void {
479
+ if (!workspaceRoot || !workspaceConfig) return;
480
+ if (!hasEnabledLinkifiers && !hasEnabledExtractors) return;
481
+
482
+ for (const eventId of eventIds) {
483
+ const row = getEventInfoStmt.get(eventId);
484
+ if (!row) continue;
485
+
486
+ if (row.name === "message.created" || row.name === "message.edited") {
487
+ scheduleDerivedPluginsForMessage(row.entity_id);
488
+ }
489
+ }
490
+ }
491
+
492
+ // Track process start time for uptime calculation
493
+ const startTimeMs = Date.now();
494
+
495
+ // Initialize rate limiter (unless disabled)
496
+ const rateLimiter = disableRateLimiting
497
+ ? null
498
+ : new HubRateLimiter(rateLimitGlobal, rateLimitPerClient);
499
+ rateLimiter?.startCleanup();
500
+
501
+ // Graceful shutdown flag (when set, reject new non-health requests)
502
+ let shuttingDown = false;
503
+
504
+ // Track in-flight requests for graceful drain
505
+ let inflightCount = 0;
506
+ const inflightPromises = new Set<Promise<void>>();
507
+
508
+ const server = Bun.serve({
509
+ hostname: host,
510
+ port,
511
+
512
+ fetch(req: Request, bunServer: any) {
513
+ const url = new URL(req.url);
514
+ const requestId = getRequestId(req);
515
+ const startMs = Date.now();
516
+
517
+ // GET /health - unauthenticated health check (no rate limiting, no logging)
518
+ // Always respond to health checks, even during shutdown
519
+ if (url.pathname === "/health" && req.method === "GET") {
520
+ const uptimeSeconds = Math.floor((Date.now() - startTimeMs) / 1000);
521
+
522
+ const healthResponse: HealthResponse = {
523
+ status: "ok",
524
+ instance_id: instanceId,
525
+ db_id: effectiveDbId,
526
+ schema_version: effectiveSchemaVersion,
527
+ protocol_version: PROTOCOL_VERSION,
528
+ pid: process.pid,
529
+ uptime_seconds: uptimeSeconds,
530
+ };
531
+
532
+ // Health endpoint: no logging to avoid noise
533
+ return withSecurityHeaders(withRequestIdHeader(Response.json(healthResponse), requestId));
534
+ }
535
+
536
+ // Graceful shutdown: reject new non-health requests with 503
537
+ if (shuttingDown) {
538
+ const response = new Response(
539
+ JSON.stringify({
540
+ error: "Service unavailable",
541
+ code: "SHUTTING_DOWN",
542
+ message: "Hub is shutting down gracefully",
543
+ }),
544
+ {
545
+ status: 503,
546
+ headers: {
547
+ "Content-Type": "application/json",
548
+ "Retry-After": "10", // Suggest client retry in 10s
549
+ },
550
+ }
551
+ );
552
+ emitLog({
553
+ ts: new Date().toISOString(),
554
+ level: "warn",
555
+ msg: "request_rejected_shutdown",
556
+ method: req.method,
557
+ path: url.pathname,
558
+ status: 503,
559
+ duration_ms: Date.now() - startMs,
560
+ instance_id: instanceId,
561
+ request_id: requestId,
562
+ });
563
+ return withSecurityHeaders(withRequestIdHeader(response, requestId));
564
+ }
565
+
566
+ // GET /ws - WebSocket upgrade endpoint
567
+ if (url.pathname === "/ws") {
568
+ if (!authToken) {
569
+ const response = new Response(
570
+ JSON.stringify({
571
+ error: "Service unavailable",
572
+ code: "NO_AUTH_CONFIGURED",
573
+ }),
574
+ {
575
+ status: 503,
576
+ headers: { "Content-Type": "application/json" },
577
+ }
578
+ );
579
+ emitLog({
580
+ ts: new Date().toISOString(),
581
+ level: "warn",
582
+ msg: "request",
583
+ method: req.method,
584
+ path: url.pathname,
585
+ status: 503,
586
+ duration_ms: Date.now() - startMs,
587
+ instance_id: instanceId,
588
+ request_id: requestId,
589
+ });
590
+ return withSecurityHeaders(withRequestIdHeader(response, requestId));
591
+ }
592
+
593
+ // WS upgrade: log the upgrade attempt
594
+ emitLog({
595
+ ts: new Date().toISOString(),
596
+ level: "info",
597
+ msg: "ws_upgrade",
598
+ method: req.method,
599
+ path: url.pathname,
600
+ status: 101,
601
+ duration_ms: Date.now() - startMs,
602
+ instance_id: instanceId,
603
+ request_id: requestId,
604
+ });
605
+ return wsHandlers.upgrade(req, bunServer);
606
+ }
607
+
608
+ return (async () => {
609
+ // Track event IDs produced by mutations
610
+ let capturedEventIds: number[] | undefined;
611
+
612
+ // Apply rate limiting to all non-health endpoints
613
+ let rateLimitResult: { allowed: boolean; limit: number; remaining: number; resetAt: number } | null = null;
614
+ if (rateLimiter) {
615
+ rateLimitResult = rateLimiter.check(req);
616
+ if (!rateLimitResult.allowed) {
617
+ const response = rateLimitedResponse(rateLimitResult);
618
+ emitLog({
619
+ ts: new Date().toISOString(),
620
+ level: "warn",
621
+ msg: "request",
622
+ method: req.method,
623
+ path: url.pathname,
624
+ status: response.status,
625
+ duration_ms: Date.now() - startMs,
626
+ instance_id: instanceId,
627
+ request_id: requestId,
628
+ });
629
+ return withRequestIdHeader(response, requestId);
630
+ }
631
+ }
632
+
633
+ // Helper to add rate limit headers to response
634
+ const withRateLimitHeaders = (response: Response): Response => {
635
+ if (rateLimitResult) {
636
+ return addRateLimitHeaders(response, rateLimitResult);
637
+ }
638
+ return response;
639
+ };
640
+
641
+ // Helper to finalize response with logging and headers
642
+ const finalizeResponse = (response: Response): Response => {
643
+ const finalResponse = withSecurityHeaders(withRequestIdHeader(withRateLimitHeaders(response), requestId));
644
+ emitLog({
645
+ ts: new Date().toISOString(),
646
+ level: response.status >= 400 ? (response.status >= 500 ? "error" : "warn") : "info",
647
+ msg: "request",
648
+ method: req.method,
649
+ path: url.pathname,
650
+ status: response.status,
651
+ duration_ms: Date.now() - startMs,
652
+ instance_id: instanceId,
653
+ request_id: requestId,
654
+ ...(capturedEventIds && capturedEventIds.length > 0 ? { event_ids: capturedEventIds } : {}),
655
+ });
656
+ return finalResponse;
657
+ };
658
+
659
+ // POST /api/v1/_ping - authenticated ping (sample mutation endpoint)
660
+ // Demonstrates auth + rate limiting + body parsing middleware usage
661
+ if (url.pathname === "/api/v1/_ping" && req.method === "POST") {
662
+ if (!authToken) {
663
+ // Hub started without auth token - reject all mutations
664
+ return finalizeResponse(
665
+ new Response(
666
+ JSON.stringify({ error: "Service unavailable", code: "NO_AUTH_CONFIGURED" }),
667
+ { status: 503, headers: { "Content-Type": "application/json" } }
668
+ )
669
+ );
670
+ }
671
+
672
+ const authResult = requireAuth(req, authToken);
673
+ if (authResult.ok === false) {
674
+ return finalizeResponse(authResult.response);
675
+ }
676
+
677
+ // Parse JSON body with size limit (optional body for _ping)
678
+ const bodyResult = await readJsonBody<{ echo?: string }>(req, {
679
+ maxBytes: SIZE_LIMITS.MESSAGE_BODY,
680
+ });
681
+
682
+ // If body provided but invalid, return error
683
+ if (bodyResult.ok === false) {
684
+ // Check if it's a content-type issue (no body is ok for ping)
685
+ const contentType = req.headers.get("Content-Type");
686
+ if (contentType && contentType.includes("application/json")) {
687
+ return finalizeResponse(bodyResult.response);
688
+ }
689
+ }
690
+
691
+ // Authenticated - return pong (with optional echo)
692
+ const responseBody: { pong: boolean; instance_id: string; echo?: string } = {
693
+ pong: true,
694
+ instance_id: instanceId,
695
+ };
696
+
697
+ if (bodyResult.ok && bodyResult.data?.echo) {
698
+ responseBody.echo = bodyResult.data.echo;
699
+ }
700
+
701
+ return finalizeResponse(Response.json(responseBody));
702
+ }
703
+
704
+ // /ui/* - HTML UI endpoints (no auth required for GET, token embedded in page)
705
+ if (url.pathname.startsWith("/ui")) {
706
+ // UI is only available if authToken is configured
707
+ if (!authToken) {
708
+ return finalizeResponse(
709
+ new Response("UI unavailable: no auth token configured", { status: 503 })
710
+ );
711
+ }
712
+
713
+ // Determine base URL for API calls
714
+ const baseUrl = `http://${boundHost}:${boundPort}`;
715
+
716
+ const uiResponse = handleUiRequest(req, {
717
+ baseUrl,
718
+ authToken,
719
+ });
720
+
721
+ if (uiResponse) {
722
+ return finalizeResponse(uiResponse);
723
+ }
724
+
725
+ // UI route not found - fall through to 404
726
+ }
727
+
728
+ // /api/v1/* - HTTP API endpoints (channels/topics/messages/attachments/events)
729
+ if (url.pathname.startsWith("/api/v1/") && url.pathname !== "/api/v1/_ping") {
730
+ const method = req.method.toUpperCase();
731
+ const isMutation = method !== "GET" && method !== "HEAD";
732
+
733
+ // Hub started without auth token - reject all mutations
734
+ if (isMutation && !authToken) {
735
+ return finalizeResponse(
736
+ new Response(
737
+ JSON.stringify({
738
+ error: "Service unavailable",
739
+ code: "NO_AUTH_CONFIGURED",
740
+ }),
741
+ { status: 503, headers: { "Content-Type": "application/json" } }
742
+ )
743
+ );
744
+ }
745
+
746
+ const apiCtx: ApiV1Context = {
747
+ db,
748
+ authToken: authToken ?? "",
749
+ instanceId,
750
+ urlExtraction: options.urlExtraction,
751
+ onEventIds(eventIds) {
752
+ capturedEventIds = eventIds;
753
+ wsHub.publishEventIds(eventIds);
754
+ maybeSchedulePluginsForEventIds(eventIds);
755
+ },
756
+ };
757
+
758
+ const apiResponse = await handleApiV1(req, apiCtx);
759
+ return finalizeResponse(apiResponse);
760
+ }
761
+
762
+ // 404 for all other routes
763
+ return finalizeResponse(new Response("Not Found", { status: 404 }));
764
+ })();
765
+ },
766
+
767
+ websocket: {
768
+ open: wsHandlers.open,
769
+ message: wsHandlers.message,
770
+ close: wsHandlers.close,
771
+ },
772
+ });
773
+
774
+ // Bun's types allow unix sockets (port/hostname undefined). We don't support that in v1.
775
+ const boundPort = server.port;
776
+ const boundHost = server.hostname;
777
+ if (boundPort == null || boundHost == null) {
778
+ await server.stop(true);
779
+ db.close();
780
+ if (daemonMode && workspaceRoot) {
781
+ await releaseWriterLock({ workspaceRoot });
782
+ }
783
+ throw new Error(
784
+ "Hub server must bind to hostname+port (unix sockets not supported)"
785
+ );
786
+ }
787
+
788
+ // Daemon mode: write server.json after successful server start
789
+ if (daemonMode && workspaceRoot && authToken) {
790
+ try {
791
+ const serverJsonData: ServerJsonData = {
792
+ instance_id: instanceId,
793
+ db_id: effectiveDbId,
794
+ port: boundPort,
795
+ host: boundHost,
796
+ auth_token: authToken,
797
+ pid: process.pid,
798
+ started_at: new Date(startTimeMs).toISOString(),
799
+ protocol_version: PROTOCOL_VERSION,
800
+ schema_version: effectiveSchemaVersion,
801
+ };
802
+
803
+ await writeServerJson({ workspaceRoot, data: serverJsonData });
804
+ } catch (err) {
805
+ // Failed to write server.json - clean up and fail
806
+ await server.stop(true);
807
+ db.close();
808
+ await releaseWriterLock({ workspaceRoot });
809
+ throw err;
810
+ }
811
+ }
812
+
813
+ return {
814
+ server,
815
+ instanceId,
816
+ port: boundPort,
817
+ host: boundHost,
818
+
819
+ async stop() {
820
+ // Set shutdown flag to reject new work
821
+ shuttingDown = true;
822
+
823
+ try {
824
+ // Wait for in-flight requests to complete (with timeout)
825
+ const drainTimeout = 10000; // 10s per plan spec
826
+ if (inflightPromises.size > 0) {
827
+ const drainPromise = Promise.all(Array.from(inflightPromises));
828
+ await Promise.race([drainPromise, Bun.sleep(drainTimeout)]);
829
+ }
830
+
831
+ // Close all WebSocket connections (code 1001 = going away)
832
+ wsHub.closeAll?.();
833
+ } finally {
834
+ rateLimiter?.stopCleanup();
835
+
836
+ // Bun 1.3.x: Server.stop(true) can hang indefinitely after a WebSocket
837
+ // connection has been accepted (even if it was later closed). We still
838
+ // want to initiate shutdown, but we must not await forever.
839
+ const stopPromise = server.stop(true).catch(() => {
840
+ // Ignore stop errors during shutdown
841
+ });
842
+
843
+ // Wait a short, bounded amount of time for the server to stop.
844
+ // If it doesn't resolve, we proceed with cleanup anyway.
845
+ await Promise.race([stopPromise, Bun.sleep(250)]);
846
+
847
+ // WAL checkpoint (best-effort, TRUNCATE mode to reclaim space)
848
+ // Do this BEFORE closing the database
849
+ try {
850
+ db.run("PRAGMA wal_checkpoint(TRUNCATE)");
851
+ } catch (err) {
852
+ // Best-effort; log and continue (only if not test env)
853
+ if (!isTestEnvironment()) {
854
+ console.warn("WAL checkpoint failed during shutdown:", err);
855
+ }
856
+ }
857
+
858
+ // Close database
859
+ db.close();
860
+
861
+ // Daemon mode cleanup: remove lock + server.json
862
+ if (daemonMode && workspaceRoot) {
863
+ try {
864
+ await removeServerJson({ workspaceRoot });
865
+ } catch (err) {
866
+ console.warn("Failed to remove server.json during shutdown:", err);
867
+ }
868
+
869
+ try {
870
+ await releaseWriterLock({ workspaceRoot });
871
+ } catch (err) {
872
+ console.warn("Failed to release writer lock during shutdown:", err);
873
+ }
874
+ }
875
+ }
876
+ },
877
+ };
878
+ }