brainbank 0.1.0-beta.1

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.
Files changed (137) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +155 -0
  3. package/assets/architecture.png +0 -0
  4. package/bin/brainbank +18 -0
  5. package/bin/brainbank-mcp +19 -0
  6. package/dist/chunk-3YBCD6DI.js +117 -0
  7. package/dist/chunk-3YBCD6DI.js.map +1 -0
  8. package/dist/chunk-63GBCDS5.js +3249 -0
  9. package/dist/chunk-63GBCDS5.js.map +1 -0
  10. package/dist/chunk-DMFMTOHF.js +123 -0
  11. package/dist/chunk-DMFMTOHF.js.map +1 -0
  12. package/dist/chunk-FQYKWB2Q.js +136 -0
  13. package/dist/chunk-FQYKWB2Q.js.map +1 -0
  14. package/dist/chunk-IMJJ2VEM.js +74 -0
  15. package/dist/chunk-IMJJ2VEM.js.map +1 -0
  16. package/dist/chunk-M744PCJQ.js +43 -0
  17. package/dist/chunk-M744PCJQ.js.map +1 -0
  18. package/dist/chunk-O3J6ZIXK.js +82 -0
  19. package/dist/chunk-O3J6ZIXK.js.map +1 -0
  20. package/dist/chunk-OPH7GZ7U.js +124 -0
  21. package/dist/chunk-OPH7GZ7U.js.map +1 -0
  22. package/dist/chunk-PXEWQMN7.js +89 -0
  23. package/dist/chunk-PXEWQMN7.js.map +1 -0
  24. package/dist/chunk-RDQYDLYZ.js +69 -0
  25. package/dist/chunk-RDQYDLYZ.js.map +1 -0
  26. package/dist/chunk-VIIHPCC4.js +254 -0
  27. package/dist/chunk-VIIHPCC4.js.map +1 -0
  28. package/dist/chunk-WCQVDF3K.js +14 -0
  29. package/dist/chunk-WCQVDF3K.js.map +1 -0
  30. package/dist/cli.d.ts +1 -0
  31. package/dist/cli.js +3076 -0
  32. package/dist/cli.js.map +1 -0
  33. package/dist/haiku-expander-YRSIPGKP.js +8 -0
  34. package/dist/haiku-expander-YRSIPGKP.js.map +1 -0
  35. package/dist/haiku-pruner-SHAXUPY6.js +8 -0
  36. package/dist/haiku-pruner-SHAXUPY6.js.map +1 -0
  37. package/dist/http-server-QUXHLWUM.js +9 -0
  38. package/dist/http-server-QUXHLWUM.js.map +1 -0
  39. package/dist/index.d.ts +2161 -0
  40. package/dist/index.js +357 -0
  41. package/dist/index.js.map +1 -0
  42. package/dist/local-embedding-NZQTILGV.js +8 -0
  43. package/dist/local-embedding-NZQTILGV.js.map +1 -0
  44. package/dist/mcp.d.ts +2 -0
  45. package/dist/mcp.js +334 -0
  46. package/dist/mcp.js.map +1 -0
  47. package/dist/openai-embedding-ZP5TSUJG.js +8 -0
  48. package/dist/openai-embedding-ZP5TSUJG.js.map +1 -0
  49. package/dist/perplexity-context-embedding-GI5PHE6X.js +9 -0
  50. package/dist/perplexity-context-embedding-GI5PHE6X.js.map +1 -0
  51. package/dist/perplexity-embedding-KZRYGJRC.js +10 -0
  52. package/dist/perplexity-embedding-KZRYGJRC.js.map +1 -0
  53. package/dist/plugin-IKQ6IRSJ.js +32 -0
  54. package/dist/plugin-IKQ6IRSJ.js.map +1 -0
  55. package/dist/resolve-ASGLBNUC.js +10 -0
  56. package/dist/resolve-ASGLBNUC.js.map +1 -0
  57. package/dist/stats-tui-ZY2NQSEA.js +1904 -0
  58. package/dist/stats-tui-ZY2NQSEA.js.map +1 -0
  59. package/package.json +96 -0
  60. package/src/brainbank.ts +617 -0
  61. package/src/cli/commands/collection.ts +77 -0
  62. package/src/cli/commands/context.ts +179 -0
  63. package/src/cli/commands/daemon.ts +100 -0
  64. package/src/cli/commands/docs.ts +71 -0
  65. package/src/cli/commands/files.ts +69 -0
  66. package/src/cli/commands/help.ts +77 -0
  67. package/src/cli/commands/index.ts +482 -0
  68. package/src/cli/commands/kv.ts +140 -0
  69. package/src/cli/commands/mcp-export.ts +273 -0
  70. package/src/cli/commands/mcp.ts +6 -0
  71. package/src/cli/commands/reembed.ts +30 -0
  72. package/src/cli/commands/scan.ts +336 -0
  73. package/src/cli/commands/search.ts +203 -0
  74. package/src/cli/commands/stats.ts +68 -0
  75. package/src/cli/commands/status.ts +47 -0
  76. package/src/cli/commands/watch.ts +47 -0
  77. package/src/cli/factory/brain-context.ts +43 -0
  78. package/src/cli/factory/builtin-registration.ts +87 -0
  79. package/src/cli/factory/config-loader.ts +77 -0
  80. package/src/cli/factory/index.ts +69 -0
  81. package/src/cli/factory/plugin-loader.ts +325 -0
  82. package/src/cli/index.ts +71 -0
  83. package/src/cli/server-client.ts +178 -0
  84. package/src/cli/tui/index-tui.tsx +667 -0
  85. package/src/cli/tui/stats-data.ts +523 -0
  86. package/src/cli/tui/stats-search.ts +262 -0
  87. package/src/cli/tui/stats-tui.tsx +1465 -0
  88. package/src/cli/tui/tree-scanner.ts +650 -0
  89. package/src/cli/utils.ts +137 -0
  90. package/src/config.ts +49 -0
  91. package/src/constants.ts +21 -0
  92. package/src/db/adapter.ts +112 -0
  93. package/src/db/metadata.ts +130 -0
  94. package/src/db/migrations.ts +66 -0
  95. package/src/db/sqlite-adapter.ts +218 -0
  96. package/src/db/tracker.ts +91 -0
  97. package/src/engine/index-api.ts +81 -0
  98. package/src/engine/reembed.ts +206 -0
  99. package/src/engine/search-api.ts +218 -0
  100. package/src/index.ts +154 -0
  101. package/src/lib/fts.ts +57 -0
  102. package/src/lib/languages.ts +180 -0
  103. package/src/lib/logger.ts +126 -0
  104. package/src/lib/math.ts +87 -0
  105. package/src/lib/provider-key.ts +20 -0
  106. package/src/lib/prune.ts +71 -0
  107. package/src/lib/rrf.ts +133 -0
  108. package/src/lib/write-lock.ts +108 -0
  109. package/src/mcp/mcp-server.ts +195 -0
  110. package/src/mcp/workspace-factory.ts +68 -0
  111. package/src/mcp/workspace-pool.ts +224 -0
  112. package/src/plugin.ts +381 -0
  113. package/src/providers/embeddings/embedding-worker-thread.ts +95 -0
  114. package/src/providers/embeddings/embedding-worker.ts +141 -0
  115. package/src/providers/embeddings/local-embedding.ts +115 -0
  116. package/src/providers/embeddings/openai-embedding.ts +167 -0
  117. package/src/providers/embeddings/perplexity-context-embedding.ts +195 -0
  118. package/src/providers/embeddings/perplexity-embedding.ts +165 -0
  119. package/src/providers/embeddings/resolve.ts +34 -0
  120. package/src/providers/pruners/haiku-expander.ts +166 -0
  121. package/src/providers/pruners/haiku-pruner.ts +112 -0
  122. package/src/providers/vector/hnsw-index.ts +174 -0
  123. package/src/providers/vector/hnsw-loader.ts +129 -0
  124. package/src/search/bm25-boost.ts +69 -0
  125. package/src/search/context-builder.ts +251 -0
  126. package/src/search/keyword/composite-bm25-search.ts +47 -0
  127. package/src/search/types.ts +37 -0
  128. package/src/search/vector/composite-vector-search.ts +61 -0
  129. package/src/search/vector/mmr.ts +64 -0
  130. package/src/services/collection.ts +384 -0
  131. package/src/services/daemon.ts +87 -0
  132. package/src/services/http-server.ts +336 -0
  133. package/src/services/kv-service.ts +64 -0
  134. package/src/services/plugin-registry.ts +77 -0
  135. package/src/services/watch.ts +340 -0
  136. package/src/services/webhook-server.ts +100 -0
  137. package/src/types.ts +493 -0
@@ -0,0 +1,340 @@
1
+ /**
2
+ * BrainBank — Watcher
3
+ *
4
+ * Thin coordinator for plugin-driven watching. Each plugin CAN drive its own
5
+ * watching via WatchablePlugin.watch(). For IndexablePlugins that don't
6
+ * implement WatchablePlugin, the Watcher provides a single shared fs.watch
7
+ * tree with fan-out routing so each plugin only receives relevant events.
8
+ *
9
+ * Responsibilities:
10
+ * 1. Call `plugin.watch(onEvent)` for each WatchablePlugin
11
+ * 2. For IndexablePlugins without watch(), share one recursive fs.watch tree
12
+ * 3. Route events to the correct plugin based on sub-repo scope
13
+ * 4. Dedup macOS double-fire events (change+rename per save)
14
+ * 5. Apply per-plugin debounce from `plugin.watchConfig()`
15
+ * 6. On event: call `plugin.indexItems([id])` or `plugin.index()` for re-indexing
16
+ * 7. Call `handle.stop()` on `close()`
17
+ *
18
+ * const watcher = brain.watch({ debounceMs: 2000 });
19
+ * watcher.close(); // stop watching
20
+ */
21
+
22
+ import type { Plugin } from '@/plugin.ts';
23
+ import type { WatchEvent, WatchHandle } from '@/types.ts';
24
+
25
+ import * as fs from 'node:fs';
26
+ import * as path from 'node:path';
27
+ import { isIndexable, isWatchable } from '@/plugin.ts';
28
+ import { isSupported, isIgnoredDir, matchesGlob } from '@/lib/languages.ts';
29
+
30
+ /** Doc file extensions that the docs plugin indexes. */
31
+ const DOC_EXTENSIONS = new Set(['.md', '.mdx', '.txt', '.rst']);
32
+
33
+
34
+ export interface WatchOptions {
35
+ /** Default debounce for plugins that don't specify watchConfig. Default: 2000 */
36
+ debounceMs?: number;
37
+ /** Glob patterns to ignore (from config.json code.ignore). */
38
+ ignore?: string[];
39
+ /** Called when a source triggers re-indexing. */
40
+ onIndex?: (sourceId: string, pluginName: string) => void;
41
+ /** Called on errors. */
42
+ onError?: (error: Error) => void;
43
+ }
44
+
45
+
46
+ /** Pending event batch for a single plugin. */
47
+ interface PluginBatch {
48
+ plugin: Plugin;
49
+ handle: WatchHandle;
50
+ events: WatchEvent[];
51
+ timer: ReturnType<typeof setTimeout> | null;
52
+ flushing: boolean;
53
+ }
54
+
55
+
56
+ /** Plugin-driven watcher that coordinates re-indexing across all WatchablePlugins. */
57
+ export class Watcher {
58
+ private _active = true;
59
+ private _batches = new Map<string, PluginBatch>();
60
+ private _reindexFn: () => Promise<void>;
61
+ private _options: WatchOptions;
62
+ private _keepalive: ReturnType<typeof setInterval> | null = null;
63
+
64
+ constructor(
65
+ reindexFn: () => Promise<void>,
66
+ plugins: Plugin[],
67
+ options: WatchOptions = {},
68
+ repoPath?: string,
69
+ ) {
70
+ this._reindexFn = reindexFn;
71
+ this._options = options;
72
+ this._startWatching(plugins, repoPath);
73
+ }
74
+
75
+ /** Whether the watcher is active. */
76
+ get active(): boolean { return this._active; }
77
+
78
+ /** Stop all plugin watchers. */
79
+ async close(): Promise<void> {
80
+ this._active = false;
81
+
82
+ if (this._keepalive) {
83
+ clearInterval(this._keepalive);
84
+ this._keepalive = null;
85
+ }
86
+
87
+ for (const batch of this._batches.values()) {
88
+ if (batch.timer) clearTimeout(batch.timer);
89
+ try {
90
+ await batch.handle.stop();
91
+ } catch (err) {
92
+ this._options.onError?.(err instanceof Error ? err : new Error(String(err)));
93
+ }
94
+ }
95
+ this._batches.clear();
96
+ }
97
+
98
+
99
+ /** Start watching for each WatchablePlugin, with shared fs.watch fallback. */
100
+ private _startWatching(plugins: Plugin[], repoPath?: string): void {
101
+ let hasAnyWatcher = false;
102
+ const fallbackPlugins: Plugin[] = [];
103
+
104
+ for (const plugin of plugins) {
105
+ if (isWatchable(plugin)) {
106
+ // Plugin-driven watching
107
+ try {
108
+ const handle = plugin.watch((event) => this._onEvent(plugin, event));
109
+
110
+ this._batches.set(plugin.name, {
111
+ plugin,
112
+ handle,
113
+ events: [],
114
+ timer: null,
115
+ flushing: false,
116
+ });
117
+ hasAnyWatcher = true;
118
+ } catch (err) {
119
+ this._options.onError?.(err instanceof Error ? err : new Error(String(err)));
120
+ }
121
+ } else if (isIndexable(plugin) && repoPath) {
122
+ // Collect for shared fs.watch fallback
123
+ fallbackPlugins.push(plugin);
124
+ }
125
+ }
126
+
127
+ // Create a SINGLE shared fs.watch tree for all fallback plugins
128
+ if (fallbackPlugins.length > 0 && repoPath) {
129
+ const sharedHandle = this._startSharedFsWatch(fallbackPlugins, repoPath);
130
+ if (sharedHandle) {
131
+ // Register batches for each fallback plugin with the shared handle
132
+ for (const plugin of fallbackPlugins) {
133
+ this._batches.set(plugin.name, {
134
+ plugin,
135
+ handle: sharedHandle,
136
+ events: [],
137
+ timer: null,
138
+ flushing: false,
139
+ });
140
+ }
141
+ hasAnyWatcher = true;
142
+ }
143
+ }
144
+
145
+ // Keep the Node event loop alive even if no native watchers are active
146
+ if (hasAnyWatcher) {
147
+ this._keepalive = setInterval(() => {}, 60_000);
148
+ this._keepalive.unref?.(); // allow graceful exit on SIGINT
149
+ }
150
+ }
151
+
152
+
153
+ /**
154
+ * Single shared recursive fs.watch that fans out events to multiple plugins.
155
+ * Each event is routed based on file extension (docs → .md only, code → isSupported).
156
+ */
157
+ private _startSharedFsWatch(plugins: Plugin[], repoPath: string): WatchHandle | null {
158
+ const watchers: fs.FSWatcher[] = [];
159
+ const ignorePatterns = this._options.ignore ?? [];
160
+
161
+ // Dedup: macOS fs.watch fires both 'change' + 'rename' for a single save.
162
+ const recentEvents = new Map<string, number>();
163
+ const DEDUP_MS = 100;
164
+
165
+ // Pre-compute routing info per plugin
166
+ const routes = plugins.map(plugin => {
167
+ return { plugin, baseName: plugin.name };
168
+ });
169
+
170
+ const watchDir = (dir: string): void => {
171
+ try {
172
+ const watcher = fs.watch(dir, { persistent: true }, (_eventType, filename) => {
173
+ if (!filename || !this._active) return;
174
+ const fullPath = path.join(dir, filename);
175
+ const relPath = path.relative(repoPath, fullPath);
176
+ const ext = path.extname(fullPath).toLowerCase();
177
+
178
+ // Config ignore: skip files matching user-defined glob patterns
179
+ if (ignorePatterns.length > 0 && matchesGlob(relPath, ignorePatterns)) return;
180
+
181
+ // Dedup: skip if we already saw this file within DEDUP_MS
182
+ const now = Date.now();
183
+ const lastSeen = recentEvents.get(relPath);
184
+ if (lastSeen && now - lastSeen < DEDUP_MS) return;
185
+ recentEvents.set(relPath, now);
186
+
187
+ const event: WatchEvent = {
188
+ type: 'update',
189
+ sourceId: relPath,
190
+ sourceName: 'file',
191
+ };
192
+
193
+ // Fan out to matching plugins
194
+ for (const { plugin, baseName } of routes) {
195
+ // Extension-based routing
196
+ if (baseName === 'docs') {
197
+ // Docs plugin only cares about doc files
198
+ if (!DOC_EXTENSIONS.has(ext)) continue;
199
+ } else {
200
+ // Code/git plugins only care about supported source files
201
+ if (!isSupported(fullPath)) continue;
202
+ }
203
+
204
+ this._onEvent(plugin, event);
205
+ }
206
+ });
207
+
208
+ watcher.on('error', (err) => {
209
+ this._options.onError?.(err instanceof Error ? err : new Error(String(err)));
210
+ });
211
+
212
+ watchers.push(watcher);
213
+ } catch {
214
+ // Directory might not exist or be inaccessible — skip
215
+ }
216
+
217
+ // Recurse into subdirectories
218
+ try {
219
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
220
+ if (!entry.isDirectory()) continue;
221
+ if (isIgnoredDir(entry.name)) continue;
222
+ if (entry.name.startsWith('.')) continue;
223
+ // Skip directories matching config ignore patterns
224
+ const dirRel = path.relative(repoPath, path.join(dir, entry.name));
225
+ if (ignorePatterns.length > 0 && matchesGlob(dirRel + '/', ignorePatterns)) continue;
226
+ watchDir(path.join(dir, entry.name));
227
+ }
228
+ } catch {
229
+ // Directory read failed — skip
230
+ }
231
+ };
232
+
233
+ watchDir(repoPath);
234
+
235
+ if (watchers.length === 0) return null;
236
+
237
+ // Periodically clean up stale dedup entries to prevent memory leak
238
+ const cleanupInterval = setInterval(() => {
239
+ const cutoff = Date.now() - 10_000;
240
+ for (const [key, ts] of recentEvents) {
241
+ if (ts < cutoff) recentEvents.delete(key);
242
+ }
243
+ }, 30_000);
244
+ cleanupInterval.unref?.();
245
+
246
+ let stopped = false;
247
+ return {
248
+ get active() { return !stopped; },
249
+ async stop() {
250
+ if (stopped) return;
251
+ stopped = true;
252
+ clearInterval(cleanupInterval);
253
+ for (const w of watchers) {
254
+ try { w.close(); } catch { /* safe to ignore */ }
255
+ }
256
+ watchers.length = 0;
257
+ },
258
+ };
259
+ }
260
+
261
+
262
+ /** Handle an incoming event from a plugin. */
263
+ private _onEvent(plugin: Plugin, event: WatchEvent): void {
264
+ if (!this._active) return;
265
+
266
+ const batch = this._batches.get(plugin.name);
267
+ if (!batch) return;
268
+
269
+ batch.events.push(event);
270
+
271
+ // Resolve debounce: plugin config > global options > 2000ms default
272
+ const pluginDebounce = isWatchable(plugin)
273
+ ? plugin.watchConfig?.()?.debounceMs
274
+ : undefined;
275
+ const debounceMs = pluginDebounce ?? this._options.debounceMs ?? 2000;
276
+
277
+ // Check batch size limit
278
+ const batchSize = isWatchable(plugin)
279
+ ? plugin.watchConfig?.()?.batchSize
280
+ : undefined;
281
+
282
+ const shouldFlushNow = debounceMs === 0
283
+ || (batchSize !== undefined && batch.events.length >= batchSize);
284
+
285
+ if (shouldFlushNow) {
286
+ if (batch.timer) clearTimeout(batch.timer);
287
+ batch.timer = null;
288
+ void this._flush(batch);
289
+ return;
290
+ }
291
+
292
+ // Debounce: reset timer on each new event
293
+ if (batch.timer) clearTimeout(batch.timer);
294
+ batch.timer = setTimeout(() => void this._flush(batch), debounceMs);
295
+ }
296
+
297
+ /** Flush pending events for a plugin — trigger re-indexing. */
298
+ private async _flush(batch: PluginBatch): Promise<void> {
299
+ if (batch.flushing || batch.events.length === 0) return;
300
+ batch.flushing = true;
301
+
302
+ const { onIndex, onError } = this._options;
303
+
304
+ try {
305
+ const events = [...batch.events];
306
+ batch.events.length = 0;
307
+
308
+ const ids = events.map(e => e.sourceId);
309
+
310
+ // Try granular re-index first, fall back to full re-index
311
+ if (isIndexable(batch.plugin) && batch.plugin.indexItems) {
312
+ await batch.plugin.indexItems(ids);
313
+ for (const id of ids) {
314
+ onIndex?.(id, batch.plugin.name);
315
+ }
316
+ } else if (isIndexable(batch.plugin)) {
317
+ await batch.plugin.index();
318
+ for (const id of ids) {
319
+ onIndex?.(id, batch.plugin.name);
320
+ }
321
+ } else {
322
+ // Plugin is watchable but not indexable — use global re-index
323
+ await this._reindexFn();
324
+ for (const id of ids) {
325
+ onIndex?.(id, batch.plugin.name);
326
+ }
327
+ }
328
+ } catch (err) {
329
+ onError?.(err instanceof Error ? err : new Error(String(err)));
330
+ } finally {
331
+ batch.flushing = false;
332
+
333
+ // If new events arrived during flush, schedule another flush
334
+ if (batch.events.length > 0 && this._active) {
335
+ const debounceMs = this._options.debounceMs ?? 2000;
336
+ batch.timer = setTimeout(() => void this._flush(batch), debounceMs);
337
+ }
338
+ }
339
+ }
340
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * BrainBank — Webhook Server
3
+ *
4
+ * Optional shared HTTP server for push-based watch plugins (e.g. Jira, GitHub).
5
+ * Opt-in: only created when `new BrainBank({ webhookPort: 4242 })` is configured.
6
+ *
7
+ * Plugins register routes during `watch()`:
8
+ * ctx.webhookServer?.register('jira', '/jira/webhook', handler);
9
+ *
10
+ * Each plugin gets its own path namespace. Unregistering cleans up the route.
11
+ */
12
+
13
+ import * as http from 'node:http';
14
+
15
+
16
+ /** Handler for incoming webhook payloads. */
17
+ export type WebhookHandler = (body: unknown) => void;
18
+
19
+ interface Route {
20
+ pluginName: string;
21
+ path: string;
22
+ handler: WebhookHandler;
23
+ }
24
+
25
+
26
+ /** Shared HTTP server for push-based watch plugins. */
27
+ export class WebhookServer {
28
+ private _server: http.Server | null = null;
29
+ private _routes: Route[] = [];
30
+ private _listening = false;
31
+
32
+ /** Start listening on the specified port. */
33
+ listen(port: number): void {
34
+ if (this._listening) return;
35
+
36
+ this._server = http.createServer((req, res) => {
37
+ this._handleRequest(req, res);
38
+ });
39
+
40
+ this._server.listen(port);
41
+ this._listening = true;
42
+ }
43
+
44
+ /** Register a webhook route for a plugin. */
45
+ register(pluginName: string, path: string, handler: WebhookHandler): void {
46
+ // Normalize path to start with /
47
+ const normalizedPath = path.startsWith('/') ? path : `/${path}`;
48
+ this._routes.push({ pluginName, path: normalizedPath, handler });
49
+ }
50
+
51
+ /** Remove all routes for a plugin. */
52
+ unregister(pluginName: string): void {
53
+ this._routes = this._routes.filter(r => r.pluginName !== pluginName);
54
+ }
55
+
56
+ /** Stop the server and clear all routes. */
57
+ close(): void {
58
+ this._server?.close();
59
+ this._server = null;
60
+ this._routes = [];
61
+ this._listening = false;
62
+ }
63
+
64
+ /** Whether the server is currently listening. */
65
+ get active(): boolean {
66
+ return this._listening;
67
+ }
68
+
69
+ /** Route incoming POST requests to the matching handler. */
70
+ private _handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
71
+ if (req.method !== 'POST') {
72
+ res.writeHead(405, { 'Content-Type': 'application/json' });
73
+ res.end(JSON.stringify({ error: 'Method not allowed' }));
74
+ return;
75
+ }
76
+
77
+ const route = this._routes.find(r => req.url === r.path);
78
+ if (!route) {
79
+ res.writeHead(404, { 'Content-Type': 'application/json' });
80
+ res.end(JSON.stringify({ error: 'Not found' }));
81
+ return;
82
+ }
83
+
84
+ const chunks: Buffer[] = [];
85
+ req.on('data', (chunk: Buffer) => chunks.push(chunk));
86
+ req.on('end', () => {
87
+ try {
88
+ const raw = Buffer.concat(chunks).toString('utf8');
89
+ const body = raw ? JSON.parse(raw) as unknown : {};
90
+ route.handler(body);
91
+ res.writeHead(200, { 'Content-Type': 'application/json' });
92
+ res.end(JSON.stringify({ ok: true }));
93
+ } catch {
94
+ // Malformed JSON — still acknowledge receipt
95
+ res.writeHead(400, { 'Content-Type': 'application/json' });
96
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
97
+ }
98
+ });
99
+ }
100
+ }