@pattern-js/runtime-node 0.2.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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/dist/bin/pattern.d.ts +20 -0
  4. package/dist/bin/pattern.d.ts.map +1 -0
  5. package/dist/bin/pattern.js +365 -0
  6. package/dist/bin/pattern.js.map +1 -0
  7. package/dist/cli.d.ts +21 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +75 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/filesystem.d.ts +38 -0
  12. package/dist/filesystem.d.ts.map +1 -0
  13. package/dist/filesystem.js +59 -0
  14. package/dist/filesystem.js.map +1 -0
  15. package/dist/http.d.ts +94 -0
  16. package/dist/http.d.ts.map +1 -0
  17. package/dist/http.js +799 -0
  18. package/dist/http.js.map +1 -0
  19. package/dist/index.d.ts +21 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +23 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/load/generator.d.ts +19 -0
  24. package/dist/load/generator.d.ts.map +1 -0
  25. package/dist/load/generator.js +94 -0
  26. package/dist/load/generator.js.map +1 -0
  27. package/dist/load/index.d.ts +43 -0
  28. package/dist/load/index.d.ts.map +1 -0
  29. package/dist/load/index.js +87 -0
  30. package/dist/load/index.js.map +1 -0
  31. package/dist/load/recorder.d.ts +36 -0
  32. package/dist/load/recorder.d.ts.map +1 -0
  33. package/dist/load/recorder.js +106 -0
  34. package/dist/load/recorder.js.map +1 -0
  35. package/dist/load/report.d.ts +28 -0
  36. package/dist/load/report.d.ts.map +1 -0
  37. package/dist/load/report.js +84 -0
  38. package/dist/load/report.js.map +1 -0
  39. package/dist/load/types.d.ts +85 -0
  40. package/dist/load/types.d.ts.map +1 -0
  41. package/dist/load/types.js +51 -0
  42. package/dist/load/types.js.map +1 -0
  43. package/dist/mods.d.ts +23 -0
  44. package/dist/mods.d.ts.map +1 -0
  45. package/dist/mods.js +48 -0
  46. package/dist/mods.js.map +1 -0
  47. package/dist/project.d.ts +94 -0
  48. package/dist/project.d.ts.map +1 -0
  49. package/dist/project.js +168 -0
  50. package/dist/project.js.map +1 -0
  51. package/dist/schedule.d.ts +33 -0
  52. package/dist/schedule.d.ts.map +1 -0
  53. package/dist/schedule.js +132 -0
  54. package/dist/schedule.js.map +1 -0
  55. package/dist/trace/index.d.ts +32 -0
  56. package/dist/trace/index.d.ts.map +1 -0
  57. package/dist/trace/index.js +48 -0
  58. package/dist/trace/index.js.map +1 -0
  59. package/dist/trace/memory.d.ts +83 -0
  60. package/dist/trace/memory.d.ts.map +1 -0
  61. package/dist/trace/memory.js +260 -0
  62. package/dist/trace/memory.js.map +1 -0
  63. package/dist/trace/sqlite.d.ts +80 -0
  64. package/dist/trace/sqlite.d.ts.map +1 -0
  65. package/dist/trace/sqlite.js +344 -0
  66. package/dist/trace/sqlite.js.map +1 -0
  67. package/dist/worker/entry.d.ts +16 -0
  68. package/dist/worker/entry.d.ts.map +1 -0
  69. package/dist/worker/entry.js +135 -0
  70. package/dist/worker/entry.js.map +1 -0
  71. package/dist/worker-pool.d.ts +49 -0
  72. package/dist/worker-pool.d.ts.map +1 -0
  73. package/dist/worker-pool.js +203 -0
  74. package/dist/worker-pool.js.map +1 -0
  75. package/dist/ws-registry.d.ts +39 -0
  76. package/dist/ws-registry.d.ts.map +1 -0
  77. package/dist/ws-registry.js +99 -0
  78. package/dist/ws-registry.js.map +1 -0
  79. package/dist/ws.d.ts +72 -0
  80. package/dist/ws.d.ts.map +1 -0
  81. package/dist/ws.js +236 -0
  82. package/dist/ws.js.map +1 -0
  83. package/package.json +60 -0
package/dist/http.js ADDED
@@ -0,0 +1,799 @@
1
+ /**
2
+ * @pattern-js/runtime-node — HTTP host (§6, §7).
3
+ *
4
+ * Routing is **declarative**: the host derives its routes from the
5
+ * `boundary.http.request` nodes of the workflows registered on the engine. Each
6
+ * node's config carries the method, path, port, CORS policy, and JSON-Schema
7
+ * validation for body/query — there is no programmatic route table. When
8
+ * workflows change at runtime (e.g. loaded/updated from a DB), the host
9
+ * re-derives its routes live, opening/closing servers per declared port.
10
+ *
11
+ * Response modes: `buffered`, `sse` (Server-Sent Events), `chunked`.
12
+ */
13
+ import { Buffer } from "node:buffer";
14
+ import { createServer } from "node:http";
15
+ import { extname } from "node:path";
16
+ import { Readable } from "node:stream";
17
+ import { ANONYMOUS, AUTH_LOGIN_URL, jsonSchemaToZod, principalToUser, } from "@pattern-js/core";
18
+ import { filesystems } from "./filesystem.js";
19
+ const DEFAULT_MAX_BODY = 10 * 1024 * 1024;
20
+ /** Thrown by `readBody` when a buffered body exceeds the configured cap. */
21
+ class BodyTooLargeError extends Error {
22
+ limit;
23
+ constructor(limit) {
24
+ super(`request body exceeds ${limit} bytes`);
25
+ this.limit = limit;
26
+ }
27
+ }
28
+ /**
29
+ * Inject the app bootstrap into entry HTML: a `<base href="${mount}/">` (so a
30
+ * bundle built with relative asset URLs resolves under ANY mount) and a
31
+ * `window.__APP__` carrying the app's manifest plus the mount/apiBase the host
32
+ * resolved. Inserted right after `<head>` so the base governs every relative URL
33
+ * below it. JSON `<` is escaped so a stray `</script>` can't break out.
34
+ */
35
+ function injectBootstrap(bytes, mount, manifest) {
36
+ let html;
37
+ try {
38
+ html = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
39
+ }
40
+ catch {
41
+ return bytes;
42
+ }
43
+ const baseHref = `${mount}/`.replace(/\/{2,}/g, "/"); // "" → "/", "/sales" → "/sales/"
44
+ // apiBase defaults to this mount's /api, but a manifest may point at a SHARED
45
+ // backend elsewhere (a branded SPA at /sales calling /chat/api).
46
+ const apiBase = typeof manifest.apiBase === "string" ? manifest.apiBase : `${mount}/api`;
47
+ const boot = { ...manifest, mount: mount || "/", apiBase };
48
+ const json = JSON.stringify(boot).replace(/</g, "\\u003c");
49
+ const inject = `<base href="${baseHref}"><script>window.__APP__=${json}</script>`;
50
+ const head = html.match(/<head[^>]*>/i);
51
+ html = head ? html.slice(0, head.index + head[0].length) + inject + html.slice(head.index + head[0].length) : inject + html;
52
+ return new TextEncoder().encode(html);
53
+ }
54
+ /** Read a positive integer port from the PORT env var, if valid. */
55
+ function envPort() {
56
+ const raw = process.env.PORT;
57
+ if (!raw)
58
+ return undefined;
59
+ const n = Number(raw);
60
+ return Number.isInteger(n) && n > 0 ? n : undefined;
61
+ }
62
+ /**
63
+ * Per-segment route specificity: a static segment (2) outranks a `:param` (1).
64
+ * Compared left-to-right, this makes a HARDWIRED path win over a parametrized
65
+ * one that also matches — e.g. `/api/sales/turns` takes precedence over the
66
+ * generic `/api/:ns/turns`. So forking a workflow with a concrete path in place
67
+ * of a `:param` overrides the generic handler, for any route, not just chat.
68
+ */
69
+ function routeSpec(path) {
70
+ return path
71
+ .split("/")
72
+ .filter(Boolean)
73
+ .map((seg) => (seg.startsWith(":") ? 1 : 2));
74
+ }
75
+ /** Order routes most-specific-first (stable, so equal specificity keeps registration order). */
76
+ function bySpecificity(a, b) {
77
+ const n = Math.max(a.spec.length, b.spec.length);
78
+ for (let i = 0; i < n; i++) {
79
+ const x = a.spec[i] ?? -1;
80
+ const y = b.spec[i] ?? -1;
81
+ if (x !== y)
82
+ return y - x; // higher specificity first
83
+ }
84
+ return 0;
85
+ }
86
+ function compilePath(path) {
87
+ const paramNames = [];
88
+ // Escape regex specials (`:` is not one, so params survive as `:name`), then
89
+ // turn each `:param` segment into a capture group.
90
+ const pattern = path
91
+ .replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
92
+ .replace(/:([A-Za-z0-9_]+)/g, (_, name) => {
93
+ paramNames.push(name);
94
+ return "([^/]+)";
95
+ });
96
+ return { regex: new RegExp(`^${pattern}/?$`), paramNames };
97
+ }
98
+ /** Normalize a mount prefix: leading slash, no trailing slash; root → "". */
99
+ function normalizeMount(mount) {
100
+ let m = mount.trim();
101
+ if (!m.startsWith("/"))
102
+ m = `/${m}`;
103
+ m = m.replace(/\/+$/, "");
104
+ return m; // "" for root
105
+ }
106
+ /** Is `pathname` at or under `mount` ("" matches everything)? */
107
+ function pathUnderMount(pathname, mount) {
108
+ if (mount === "")
109
+ return true;
110
+ return pathname === mount || pathname.startsWith(`${mount}/`);
111
+ }
112
+ const MIME = {
113
+ ".html": "text/html; charset=utf-8",
114
+ ".js": "text/javascript; charset=utf-8",
115
+ ".mjs": "text/javascript; charset=utf-8",
116
+ ".css": "text/css; charset=utf-8",
117
+ ".json": "application/json; charset=utf-8",
118
+ ".map": "application/json; charset=utf-8",
119
+ ".svg": "image/svg+xml",
120
+ ".png": "image/png",
121
+ ".jpg": "image/jpeg",
122
+ ".jpeg": "image/jpeg",
123
+ ".gif": "image/gif",
124
+ ".webp": "image/webp",
125
+ ".avif": "image/avif",
126
+ ".ico": "image/x-icon",
127
+ ".woff": "font/woff",
128
+ ".woff2": "font/woff2",
129
+ ".ttf": "font/ttf",
130
+ ".txt": "text/plain; charset=utf-8",
131
+ ".wasm": "application/wasm",
132
+ };
133
+ /** Content-type for a served asset, from its extension. */
134
+ function mimeType(path) {
135
+ return MIME[extname(path).toLowerCase()] ?? "application/octet-stream";
136
+ }
137
+ function normalizeCors(cors) {
138
+ if (!cors)
139
+ return undefined;
140
+ if (cors === true)
141
+ return { origin: "*", credentials: false };
142
+ const c = cors;
143
+ return { origin: c.origin ?? "*", credentials: c.credentials ?? false, methods: c.methods, headers: c.headers, maxAge: c.maxAge, exposeHeaders: c.exposeHeaders };
144
+ }
145
+ export class HttpHost {
146
+ engine;
147
+ opts;
148
+ servers = new Map();
149
+ /** Live sockets per server, so shutdown can force-close lingering connections
150
+ * (e.g. an open SSE stream that would otherwise keep `close()` pending). */
151
+ sockets = new Map();
152
+ routes = [];
153
+ apps = [];
154
+ unsubscribe;
155
+ defaultPort;
156
+ serverListeners = new Set();
157
+ constructor(engine, opts = {}) {
158
+ this.engine = engine;
159
+ this.opts = opts;
160
+ // Port resolution for a route: its op `config.port`, else this default.
161
+ // Default = explicit `defaultPort` (e.g. from pattern.config.json http.port),
162
+ // else the PORT env var, else 3000.
163
+ this.defaultPort = opts.defaultPort ?? envPort() ?? 3000;
164
+ }
165
+ /** Derive routes from registered workflows, open servers, and watch for changes. */
166
+ async start() {
167
+ await this.rebuild();
168
+ this.warnIfUnenforceableAuth();
169
+ this.unsubscribe = this.engine.onWorkflowsChanged(() => {
170
+ void this.rebuild().catch((err) => console.error("[pattern] http rebuild failed:", err));
171
+ });
172
+ return { ports: [...this.servers.keys()], close: () => this.close() };
173
+ }
174
+ /**
175
+ * Loud, honest signal: with NO auth provider installed, any route that
176
+ * *declares* `requireAuth` can't be enforced (nobody can authenticate) — the
177
+ * engine serves it open (advisory). Say so plainly at boot so it's never a
178
+ * silent surprise. Add a provider and the same declarations are enforced.
179
+ */
180
+ warnIfUnenforceableAuth() {
181
+ if (this.engine.hasAuthProvider())
182
+ return;
183
+ const gated = [...this.routes, ...this.apps].filter((r) => r.requireAuth).length;
184
+ if (!gated)
185
+ return;
186
+ console.warn(`\n[pattern] ⚠ ${gated} route(s) declare requireAuth but NO auth provider is installed —\n` +
187
+ `[pattern] they are NOT enforced and serve UNAUTHENTICATED (anyone who can reach the\n` +
188
+ `[pattern] port has access). Add an auth provider (e.g. @pattern-js/mod-identity) to enforce\n` +
189
+ `[pattern] them. Routes without requireAuth are unaffected.\n`);
190
+ }
191
+ /** Tail of the rebuild queue — see `rebuild()`. */
192
+ rebuilding = Promise.resolve();
193
+ /**
194
+ * Scan workflows for `boundary.http.request` routes and reconcile servers.
195
+ * Rebuilds are serialized: workflow-change events can fire in rapid bursts
196
+ * (project load, admin batch deploys), and two interleaved reconciles would
197
+ * race `openServer` on the same port into EADDRINUSE.
198
+ */
199
+ rebuild() {
200
+ const next = this.rebuilding.then(() => this.rebuildNow());
201
+ // The queue itself never rejects (errors surface to each caller via `next`).
202
+ this.rebuilding = next.catch(() => { });
203
+ return next;
204
+ }
205
+ async rebuildNow() {
206
+ this.routes = this.scanRoutes();
207
+ this.apps = await this.scanApps();
208
+ const needed = new Set([...this.routes.map((r) => r.port), ...this.apps.map((a) => a.port)]);
209
+ for (const port of needed) {
210
+ if (!this.servers.has(port))
211
+ await this.openServer(port);
212
+ }
213
+ for (const port of [...this.servers.keys()]) {
214
+ if (!needed.has(port)) {
215
+ await this.closeServer(port);
216
+ }
217
+ }
218
+ }
219
+ /** Close one server, destroying any lingering sockets first. */
220
+ closeServer(port) {
221
+ const server = this.servers.get(port);
222
+ if (!server)
223
+ return Promise.resolve();
224
+ for (const socket of this.sockets.get(port) ?? [])
225
+ socket.destroy();
226
+ this.sockets.delete(port);
227
+ this.servers.delete(port);
228
+ return new Promise((r) => server.close(() => r()));
229
+ }
230
+ scanRoutes() {
231
+ const routes = [];
232
+ for (const wf of this.engine.workflows.list()) {
233
+ for (const node of wf.nodes) {
234
+ const op = this.engine.ops.get(node.op);
235
+ if (op?.type !== "boundary.http.request")
236
+ continue;
237
+ const cfg = parseConfig(op, node.config);
238
+ if (!cfg.path)
239
+ continue; // not routable without a path
240
+ const { regex, paramNames } = compilePath(cfg.path);
241
+ routes.push({
242
+ method: String(cfg.method ?? "GET").toUpperCase(),
243
+ path: cfg.path,
244
+ port: cfg.port ?? this.defaultPort,
245
+ regex,
246
+ paramNames,
247
+ spec: routeSpec(cfg.path),
248
+ workflowId: wf.id,
249
+ trigger: node.id,
250
+ bodyMode: cfg.bodyMode === "stream" ? "stream" : "buffered",
251
+ cors: normalizeCors(cfg.cors),
252
+ bodySchema: cfg.body ? jsonSchemaToZod(cfg.body) : undefined,
253
+ querySchema: cfg.query ? jsonSchemaToZod(cfg.query, { coerce: true }) : undefined,
254
+ paramsSchema: cfg.params ? jsonSchemaToZod(cfg.params, { coerce: true }) : undefined,
255
+ requireAuth: cfg.requireAuth,
256
+ cancelOnDisconnect: Boolean(cfg.cancelOnDisconnect),
257
+ });
258
+ }
259
+ }
260
+ // Most-specific-first, so a hardwired path out-matches a generic :param one.
261
+ return routes.sort(bySpecificity);
262
+ }
263
+ /**
264
+ * Scan workflows for `boundary.http.app` mounts (admin-spec P1). The trigger
265
+ * declares the HTTP side (mount/port/cors/auth); the app itself is *resolved
266
+ * by running the workflow once* — the run flows trigger → app op (e.g.
267
+ * `core.app.static`, `admin.app`) → the `boundary.http.app.serve` out-gate,
268
+ * whose captured `app` descriptor tells the host what to serve.
269
+ */
270
+ async scanApps() {
271
+ const apps = [];
272
+ for (const wf of this.engine.workflows.list()) {
273
+ for (const node of wf.nodes) {
274
+ const op = this.engine.ops.get(node.op);
275
+ if (op?.type !== "boundary.http.app")
276
+ continue;
277
+ const cfg = parseConfig(op, node.config);
278
+ const mount = normalizeMount(cfg.mount ?? "/");
279
+ let app;
280
+ try {
281
+ const result = await this.engine.runFrom(wf, node.id, { mount: cfg.mount ?? "/" }, ANONYMOUS);
282
+ if (result.status === "error")
283
+ throw result.error;
284
+ const payload = firstOutgate(result, wf, "boundary.http.app.serve");
285
+ app = payload?.app;
286
+ }
287
+ catch (err) {
288
+ console.error(`[pattern] app workflow "${wf.id}" failed to resolve its app:`, err);
289
+ continue;
290
+ }
291
+ if (!app || typeof app.filesystem !== "string") {
292
+ console.error(`[pattern] app workflow "${wf.id}" produced no app descriptor — not mounted`);
293
+ continue;
294
+ }
295
+ apps.push({
296
+ mount,
297
+ port: cfg.port ?? this.defaultPort,
298
+ filesystem: app.filesystem,
299
+ spaFallback: typeof app.spaFallback === "string" ? app.spaFallback : "index.html",
300
+ immutableAssets: Boolean(app.immutableAssets),
301
+ cors: normalizeCors(cfg.cors),
302
+ requireAuth: cfg.requireAuth,
303
+ manifest: app.manifest && typeof app.manifest === "object" ? app.manifest : undefined,
304
+ });
305
+ }
306
+ }
307
+ // Longest mount first, so "/admin/sub" wins over "/admin" / "/".
308
+ apps.sort((a, b) => b.mount.length - a.mount.length);
309
+ return apps;
310
+ }
311
+ /** The first app mount on `port` whose prefix matches `pathname`. */
312
+ matchApp(port, pathname) {
313
+ return this.apps.find((a) => a.port === port && pathUnderMount(pathname, a.mount));
314
+ }
315
+ openServer(port) {
316
+ return new Promise((resolve, reject) => {
317
+ // The backstop: NOTHING a request does may become an unhandled rejection
318
+ // (plain `node` kills the process on those). E.g. a streaming out-gate
319
+ // whose producer fails AFTER the run settled errors the response stream
320
+ // mid-write — that's a request-level failure, not a process-level one.
321
+ const server = createServer((req, res) => this.handle(req, res, port).catch((err) => {
322
+ console.error("[pattern] request handler failed:", err);
323
+ try {
324
+ if (!res.headersSent)
325
+ res.writeHead(500, { "content-type": "application/json" });
326
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
327
+ }
328
+ catch {
329
+ res.destroy();
330
+ }
331
+ }));
332
+ // Surface listen errors (e.g. EADDRINUSE) as a rejection instead of an
333
+ // unhandled 'error' event that would crash the process.
334
+ server.once("error", (err) => reject(err));
335
+ // Track live sockets so close() can force them shut.
336
+ const live = new Set();
337
+ this.sockets.set(port, live);
338
+ server.on("connection", (socket) => {
339
+ live.add(socket);
340
+ socket.once("close", () => live.delete(socket));
341
+ });
342
+ server.listen(port, this.opts.host, () => {
343
+ server.removeAllListeners("error");
344
+ this.servers.set(port, server);
345
+ for (const cb of this.serverListeners)
346
+ cb(server, port);
347
+ resolve();
348
+ });
349
+ });
350
+ }
351
+ /** The live http.Server for `port`, if open (e.g. to attach a WS host). */
352
+ server(port) {
353
+ return this.servers.get(port);
354
+ }
355
+ /**
356
+ * Run `cb` for every open server, now and as new ports open (route
357
+ * reconciliation opens/closes servers live) — the WS auto-wiring seam.
358
+ */
359
+ onServer(cb) {
360
+ for (const [port, server] of this.servers)
361
+ cb(server, port);
362
+ this.serverListeners.add(cb);
363
+ return () => this.serverListeners.delete(cb);
364
+ }
365
+ match(port, method, pathname) {
366
+ for (const route of this.routes) {
367
+ if (route.port !== port)
368
+ continue;
369
+ if (route.method !== "ANY" && route.method !== method)
370
+ continue;
371
+ const m = route.regex.exec(pathname);
372
+ if (!m)
373
+ continue;
374
+ const params = {};
375
+ route.paramNames.forEach((name, i) => (params[name] = decodeURIComponent(m[i + 1] ?? "")));
376
+ return { route, params };
377
+ }
378
+ return undefined;
379
+ }
380
+ async handle(req, res, port) {
381
+ // Build the URL from the real Host header (and forwarded proto), so the
382
+ // trigger's `url` output carries the actual request origin — workflows that
383
+ // build absolute links (magic-link callbacks) need the right host + port.
384
+ const fwdProto = req.headers["x-forwarded-proto"];
385
+ const proto = (Array.isArray(fwdProto) ? fwdProto[0] : fwdProto)?.split(",")[0]?.trim() || "http";
386
+ const host = req.headers.host || `localhost:${port}`;
387
+ const url = new URL(req.url ?? "/", `${proto}://${host}`);
388
+ const method = (req.method ?? "GET").toUpperCase();
389
+ // CORS preflight: any route on this port + path that declares CORS.
390
+ if (method === "OPTIONS") {
391
+ const cors = this.routes.find((r) => r.port === port && r.cors && r.regex.test(url.pathname))?.cors ??
392
+ this.matchApp(port, url.pathname)?.cors;
393
+ if (cors)
394
+ return this.preflight(req, res, cors, port, url.pathname);
395
+ }
396
+ const matched = this.match(port, method, url.pathname);
397
+ if (!matched) {
398
+ // API routes take precedence; fall back to static app mounts (P1).
399
+ const app = this.matchApp(port, url.pathname);
400
+ if (app)
401
+ return this.serveApp(req, res, app, url.pathname);
402
+ res.writeHead(404, { "content-type": "text/plain" }).end("Not Found");
403
+ return;
404
+ }
405
+ const { route, params } = matched;
406
+ if (route.cors)
407
+ applyCors(res, route.cors, req.headers.origin);
408
+ const workflow = this.engine.workflows.get(route.workflowId);
409
+ if (!workflow) {
410
+ res.writeHead(500).end("workflow not registered");
411
+ return;
412
+ }
413
+ // ── Auth (§9) ──
414
+ const headers = new Headers();
415
+ for (const [k, v] of Object.entries(req.headers)) {
416
+ if (typeof v === "string")
417
+ headers.set(k, v);
418
+ else if (Array.isArray(v))
419
+ headers.set(k, v.join(", "));
420
+ }
421
+ const principal = await this.engine.authenticate({ headers, raw: req });
422
+ const auth = this.engine.authorize(principal, route.requireAuth);
423
+ if (!auth.ok) {
424
+ res.writeHead(401, { "content-type": "text/plain" }).end(`Unauthorized: ${auth.reason}`);
425
+ return;
426
+ }
427
+ // ── Validate params, query & body against the declared JSON Schemas (§7) ──
428
+ const headersObj = {};
429
+ headers.forEach((v, k) => (headersObj[k] = v));
430
+ let routeParams = params;
431
+ if (route.paramsSchema) {
432
+ const parsed = route.paramsSchema.safeParse(routeParams);
433
+ if (!parsed.success)
434
+ return badRequest(res, "params", parsed.error);
435
+ routeParams = parsed.data;
436
+ }
437
+ let query = Object.fromEntries(url.searchParams.entries());
438
+ if (route.querySchema) {
439
+ const parsed = route.querySchema.safeParse(query);
440
+ if (!parsed.success)
441
+ return badRequest(res, "query", parsed.error);
442
+ query = parsed.data;
443
+ }
444
+ let body;
445
+ if (route.bodyMode === "stream") {
446
+ body = Readable.toWeb(req);
447
+ }
448
+ else {
449
+ try {
450
+ body = await readBody(req, headers.get("content-type"), this.opts.maxBodyBytes ?? DEFAULT_MAX_BODY);
451
+ }
452
+ catch (err) {
453
+ if (err instanceof BodyTooLargeError) {
454
+ res.writeHead(413, { "content-type": "application/json" }).end(JSON.stringify({ error: err.message }));
455
+ return;
456
+ }
457
+ throw err;
458
+ }
459
+ if (route.bodySchema) {
460
+ const parsed = route.bodySchema.safeParse(body);
461
+ if (!parsed.success)
462
+ return badRequest(res, "body", parsed.error);
463
+ body = parsed.data;
464
+ }
465
+ }
466
+ const input = {
467
+ method,
468
+ url: url.toString(),
469
+ path: url.pathname,
470
+ headers: headersObj,
471
+ cookies: parseCookies(headersObj.cookie),
472
+ query,
473
+ params: routeParams,
474
+ body,
475
+ // Seeds the trigger's `user` output port (§9): the resolved principal
476
+ // flattened for wiring, null when anonymous.
477
+ user: principalToUser(principal),
478
+ };
479
+ // The run is independent of the connection by default — a chat turn or a
480
+ // long task keeps running (and persisting) if the client drops, and replays
481
+ // on reconnect. Only when the route opts in (`cancelOnDisconnect`) do we
482
+ // cancel on a client disconnect — for a pure passthrough stream with nothing
483
+ // worth finishing. Then: abort propagates to the run's producers so they stop
484
+ // writing to a dead socket and the trace true-end fires promptly.
485
+ // We can't gate on `res.writableFinished`: a mid-stream disconnect makes the
486
+ // write fail, `writeResponse` ends the response, and only THEN does `close`
487
+ // fire (finished already true). So we track our own "delivered in full" flag.
488
+ const ac = new AbortController();
489
+ let delivered = false;
490
+ if (route.cancelOnDisconnect) {
491
+ res.on("close", () => {
492
+ if (!delivered)
493
+ ac.abort(new Error("client disconnected"));
494
+ });
495
+ }
496
+ let result;
497
+ try {
498
+ result = await this.engine.runFrom(workflow, route.trigger, input, principal, ac.signal);
499
+ }
500
+ catch (err) {
501
+ delivered = true;
502
+ return writeError(res, err);
503
+ }
504
+ if (result.status === "error") {
505
+ delivered = true;
506
+ return writeError(res, result.error);
507
+ }
508
+ const payload = firstOutgate(result, workflow, "boundary.http.response");
509
+ if (!payload) {
510
+ delivered = true;
511
+ res.writeHead(204).end();
512
+ return;
513
+ }
514
+ await writeResponse(res, payload);
515
+ delivered = true;
516
+ }
517
+ /** Serve a static asset (or SPA fallback) for an app mount (admin-spec P1). */
518
+ async serveApp(req, res, app, pathname) {
519
+ const method = (req.method ?? "GET").toUpperCase();
520
+ if (method !== "GET" && method !== "HEAD") {
521
+ res.writeHead(404, { "content-type": "text/plain" }).end("Not Found");
522
+ return;
523
+ }
524
+ if (app.cors)
525
+ applyCors(res, app.cors, req.headers.origin);
526
+ // Auth (§9): the app mount may require it, like any route.
527
+ if (app.requireAuth) {
528
+ const headers = new Headers();
529
+ for (const [k, v] of Object.entries(req.headers)) {
530
+ if (typeof v === "string")
531
+ headers.set(k, v);
532
+ else if (Array.isArray(v))
533
+ headers.set(k, v.join(", "));
534
+ }
535
+ const principal = await this.engine.authenticate({ headers, raw: req });
536
+ const auth = this.engine.authorize(principal, app.requireAuth);
537
+ if (!auth.ok) {
538
+ // A browser asking for HTML gets bounced to the advertised login page
539
+ // (§9) — the identity mod registers AUTH_LOGIN_URL; fetch/XHR callers
540
+ // still get the bare 401 and handle it client-side.
541
+ const loginUrl = this.engine.service(AUTH_LOGIN_URL);
542
+ const accept = String(req.headers["accept"] ?? "");
543
+ if (loginUrl && accept.includes("text/html")) {
544
+ const next = encodeURIComponent(req.url ?? "/");
545
+ res.writeHead(302, { location: `${loginUrl}?next=${next}` }).end();
546
+ return;
547
+ }
548
+ res.writeHead(401, { "content-type": "text/plain" }).end(`Unauthorized: ${auth.reason}`);
549
+ return;
550
+ }
551
+ }
552
+ const fs = filesystems(this.engine).get(app.filesystem);
553
+ if (!fs) {
554
+ res.writeHead(500, { "content-type": "text/plain" }).end(`filesystem "${app.filesystem}" not registered`);
555
+ return;
556
+ }
557
+ let rel = pathname.slice(app.mount.length).replace(/^\/+/, "");
558
+ if (rel === "")
559
+ rel = app.spaFallback || "index.html";
560
+ const readIfExists = async (p) => (await fs.fileExists(p)) ? fs.readToUint8Array(p) : null;
561
+ let bytes = await readIfExists(rel);
562
+ let servedFallback = false;
563
+ if (bytes == null && app.spaFallback) {
564
+ // Client-side routing: serve the fallback for HTML navigations only.
565
+ const accept = String(req.headers["accept"] ?? "");
566
+ if (accept.includes("text/html")) {
567
+ bytes = await readIfExists(app.spaFallback);
568
+ servedFallback = true;
569
+ rel = app.spaFallback;
570
+ }
571
+ }
572
+ if (bytes == null) {
573
+ res.writeHead(404, { "content-type": "text/plain" }).end("Not Found");
574
+ return;
575
+ }
576
+ const headers = { "content-type": mimeType(rel) };
577
+ const isHtmlEntry = servedFallback || rel === app.spaFallback;
578
+ if (app.immutableAssets && !isHtmlEntry)
579
+ headers["cache-control"] = "public, max-age=31536000, immutable";
580
+ else if (isHtmlEntry)
581
+ headers["cache-control"] = "no-cache";
582
+ // Entry HTML of a bootstrap-aware app: inject a mount-portable <base href>
583
+ // and window.__APP__ so the SAME static bundle works under any mount and
584
+ // learns its brand/apiBase at load. Other assets (and apps without a
585
+ // manifest, e.g. admin) are served byte-for-byte.
586
+ const out = isHtmlEntry && app.manifest ? injectBootstrap(bytes, app.mount, app.manifest) : bytes;
587
+ res.writeHead(200, headers);
588
+ if (method === "HEAD")
589
+ return void res.end();
590
+ res.end(Buffer.from(out));
591
+ }
592
+ preflight(req, res, cors, port, pathname) {
593
+ const methods = cors.methods ?? collectMethods(this.routes, port, pathname);
594
+ applyCors(res, cors, req.headers.origin);
595
+ res.setHeader("Access-Control-Allow-Methods", methods.join(", "));
596
+ res.setHeader("Access-Control-Allow-Headers", cors.headers?.join(", ") ?? req.headers["access-control-request-headers"] ?? "*");
597
+ if (cors.maxAge != null)
598
+ res.setHeader("Access-Control-Max-Age", String(cors.maxAge));
599
+ res.writeHead(204).end();
600
+ }
601
+ async close() {
602
+ this.unsubscribe?.();
603
+ this.unsubscribe = undefined;
604
+ await Promise.all([...this.servers.keys()].map((port) => this.closeServer(port)));
605
+ this.servers.clear();
606
+ this.sockets.clear();
607
+ }
608
+ }
609
+ // ── helpers ──
610
+ function parseConfig(op, config) {
611
+ return op.config ? op.config.parse(config ?? {}) : (config ?? {});
612
+ }
613
+ function collectMethods(routes, port, pathname) {
614
+ const methods = new Set();
615
+ for (const r of routes)
616
+ if (r.port === port && r.regex.test(pathname))
617
+ methods.add(r.method);
618
+ methods.add("OPTIONS");
619
+ return [...methods];
620
+ }
621
+ function applyCors(res, cors, reqOrigin) {
622
+ // With an array allowlist, a non-matching (or absent) request origin gets NO
623
+ // Access-Control-Allow-Origin header at all — echoing any allowlisted value
624
+ // back to an unlisted origin would effectively open CORS to everyone.
625
+ const origin = cors.origin === "*"
626
+ ? "*"
627
+ : Array.isArray(cors.origin)
628
+ ? reqOrigin && cors.origin.includes(reqOrigin)
629
+ ? reqOrigin
630
+ : undefined
631
+ : cors.origin;
632
+ if (origin !== undefined)
633
+ res.setHeader("Access-Control-Allow-Origin", origin);
634
+ if (origin !== "*")
635
+ res.setHeader("Vary", "Origin");
636
+ if (cors.credentials)
637
+ res.setHeader("Access-Control-Allow-Credentials", "true");
638
+ if (cors.exposeHeaders?.length)
639
+ res.setHeader("Access-Control-Expose-Headers", cors.exposeHeaders.join(", "));
640
+ }
641
+ function badRequest(res, where, error) {
642
+ res.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({
643
+ error: `invalid ${where}`,
644
+ issues: error.issues.map((i) => ({ path: i.path.join("."), message: i.message })),
645
+ }));
646
+ }
647
+ async function readBody(req, contentType, maxBytes = DEFAULT_MAX_BODY) {
648
+ const chunks = [];
649
+ let size = 0;
650
+ for await (const c of req) {
651
+ size += c.length;
652
+ if (size > maxBytes) {
653
+ req.destroy(); // stop the client from streaming the rest into memory
654
+ throw new BodyTooLargeError(maxBytes);
655
+ }
656
+ chunks.push(c);
657
+ }
658
+ const buf = Buffer.concat(chunks);
659
+ if (buf.length === 0)
660
+ return undefined;
661
+ const ct = contentType ?? "";
662
+ if (ct.includes("application/json")) {
663
+ try {
664
+ return JSON.parse(buf.toString("utf8"));
665
+ }
666
+ catch {
667
+ return buf.toString("utf8");
668
+ }
669
+ }
670
+ // Browser form posts → a plain object, so a workflow can decompose the fields
671
+ // (core.object.extract) just like a JSON body. Ops never parse forms.
672
+ if (ct.includes("application/x-www-form-urlencoded")) {
673
+ return Object.fromEntries(new URLSearchParams(buf.toString("utf8")).entries());
674
+ }
675
+ if (ct.startsWith("text/") || ct === "")
676
+ return buf.toString("utf8");
677
+ return new Uint8Array(buf);
678
+ }
679
+ /** Parse a request `Cookie` header into a name→value record (network-IN). */
680
+ function parseCookies(header) {
681
+ const out = {};
682
+ if (!header)
683
+ return out;
684
+ for (const part of header.split(";")) {
685
+ const eq = part.indexOf("=");
686
+ if (eq < 0)
687
+ continue;
688
+ const name = part.slice(0, eq).trim();
689
+ if (!name)
690
+ continue;
691
+ try {
692
+ out[name] = decodeURIComponent(part.slice(eq + 1).trim());
693
+ }
694
+ catch {
695
+ out[name] = part.slice(eq + 1).trim();
696
+ }
697
+ }
698
+ return out;
699
+ }
700
+ /** Serialize one Set-Cookie value from `{ name: value | { value, … } }` (network-OUT). */
701
+ function serializeCookie(name, spec) {
702
+ if (spec === null || spec === undefined)
703
+ return `${name}=; Path=/; Max-Age=0; SameSite=Lax`;
704
+ if (typeof spec !== "object")
705
+ return `${name}=${encodeURIComponent(String(spec))}; Path=/; SameSite=Lax; HttpOnly`;
706
+ const o = spec;
707
+ const parts = [`${name}=${encodeURIComponent(String(o.value ?? ""))}`, `Path=${o.path ?? "/"}`];
708
+ if (o.maxAge != null)
709
+ parts.push(`Max-Age=${o.maxAge}`);
710
+ if (o.expires)
711
+ parts.push(`Expires=${o.expires}`);
712
+ if (o.domain)
713
+ parts.push(`Domain=${o.domain}`);
714
+ parts.push(`SameSite=${o.sameSite ?? "Lax"}`);
715
+ if (o.httpOnly !== false)
716
+ parts.push("HttpOnly");
717
+ if (o.secure)
718
+ parts.push("Secure");
719
+ return parts.join("; ");
720
+ }
721
+ async function writeResponse(res, payload) {
722
+ const mode = payload.mode ?? "buffered";
723
+ // A redirect is just 302 + Location (status overridable for 301/307/308).
724
+ const redirecting = typeof payload.redirect === "string" && payload.redirect.length > 0;
725
+ const status = payload.status ?? (redirecting ? 302 : 200);
726
+ const headers = { ...(payload.headers ?? {}) };
727
+ if (redirecting)
728
+ headers.location = payload.redirect;
729
+ if (payload.cookies && typeof payload.cookies === "object") {
730
+ const set = Object.entries(payload.cookies).map(([n, s]) => serializeCookie(n, s));
731
+ if (set.length)
732
+ headers["set-cookie"] = set;
733
+ }
734
+ if (mode === "sse" || mode === "chunked") {
735
+ const sse = mode === "sse";
736
+ res.writeHead(status, sse
737
+ ? { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive", ...headers }
738
+ : { "transfer-encoding": "chunked", ...headers });
739
+ const src = payload.stream ?? (payload.body instanceof ReadableStream ? payload.body : undefined);
740
+ if (src) {
741
+ try {
742
+ for await (const chunk of streamIter(src)) {
743
+ if (sse)
744
+ res.write(`data: ${typeof chunk === "string" ? chunk : JSON.stringify(chunk)}\n\n`);
745
+ else
746
+ res.write(chunk instanceof Uint8Array ? chunk : typeof chunk === "string" ? chunk : JSON.stringify(chunk));
747
+ }
748
+ }
749
+ catch (err) {
750
+ // The producer failed mid-stream (headers are long gone) — end the
751
+ // response; consumers recover from their source of truth.
752
+ console.error("[pattern] response stream errored mid-flight:", err instanceof Error ? err.message : err);
753
+ }
754
+ }
755
+ res.end();
756
+ return;
757
+ }
758
+ const body = payload.body;
759
+ if (body == null)
760
+ res.writeHead(status, headers).end();
761
+ else if (typeof body === "string")
762
+ res.writeHead(status, { "content-type": "text/plain; charset=utf-8", ...headers }).end(body);
763
+ else if (body instanceof Uint8Array)
764
+ res.writeHead(status, headers).end(Buffer.from(body));
765
+ else
766
+ res.writeHead(status, { "content-type": "application/json", ...headers }).end(JSON.stringify(body));
767
+ }
768
+ async function* streamIter(stream) {
769
+ const reader = stream.getReader();
770
+ try {
771
+ for (;;) {
772
+ const { done, value } = await reader.read();
773
+ if (done)
774
+ return;
775
+ yield value;
776
+ }
777
+ }
778
+ finally {
779
+ reader.releaseLock();
780
+ }
781
+ }
782
+ function writeError(res, err) {
783
+ const message = err instanceof Error ? err.message : String(err);
784
+ if (!res.headersSent)
785
+ res.writeHead(500, { "content-type": "application/json" });
786
+ res.end(JSON.stringify({ error: message }));
787
+ }
788
+ function firstOutgate(result, workflow, opType) {
789
+ for (const [nodeId, payload] of Object.entries(result.outputs)) {
790
+ if (workflow.nodes.find((n) => n.id === nodeId)?.op === opType)
791
+ return payload;
792
+ }
793
+ return Object.values(result.outputs)[0];
794
+ }
795
+ /** Create an HTTP host that derives its routes from the engine's workflows. */
796
+ export function createHttpHost(engine, opts) {
797
+ return new HttpHost(engine, opts);
798
+ }
799
+ //# sourceMappingURL=http.js.map