@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/README.md +126 -0
- package/package.json +36 -0
- package/src/agentlipd.ts +309 -0
- package/src/apiV1.ts +1468 -0
- package/src/authMiddleware.ts +134 -0
- package/src/authToken.ts +32 -0
- package/src/bodyParser.ts +272 -0
- package/src/config.ts +273 -0
- package/src/derivedStaleness.ts +255 -0
- package/src/extractorDerived.ts +374 -0
- package/src/index.ts +878 -0
- package/src/linkifierDerived.ts +407 -0
- package/src/lock.ts +172 -0
- package/src/pluginRuntime.ts +402 -0
- package/src/pluginWorker.ts +296 -0
- package/src/rateLimiter.ts +286 -0
- package/src/serverJson.ts +138 -0
- package/src/ui.ts +843 -0
- package/src/wsEndpoint.ts +481 -0
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
|
+
}
|