@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.
- package/LICENSE +21 -0
- package/README.md +70 -0
- package/dist/bin/pattern.d.ts +20 -0
- package/dist/bin/pattern.d.ts.map +1 -0
- package/dist/bin/pattern.js +365 -0
- package/dist/bin/pattern.js.map +1 -0
- package/dist/cli.d.ts +21 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +75 -0
- package/dist/cli.js.map +1 -0
- package/dist/filesystem.d.ts +38 -0
- package/dist/filesystem.d.ts.map +1 -0
- package/dist/filesystem.js +59 -0
- package/dist/filesystem.js.map +1 -0
- package/dist/http.d.ts +94 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +799 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/load/generator.d.ts +19 -0
- package/dist/load/generator.d.ts.map +1 -0
- package/dist/load/generator.js +94 -0
- package/dist/load/generator.js.map +1 -0
- package/dist/load/index.d.ts +43 -0
- package/dist/load/index.d.ts.map +1 -0
- package/dist/load/index.js +87 -0
- package/dist/load/index.js.map +1 -0
- package/dist/load/recorder.d.ts +36 -0
- package/dist/load/recorder.d.ts.map +1 -0
- package/dist/load/recorder.js +106 -0
- package/dist/load/recorder.js.map +1 -0
- package/dist/load/report.d.ts +28 -0
- package/dist/load/report.d.ts.map +1 -0
- package/dist/load/report.js +84 -0
- package/dist/load/report.js.map +1 -0
- package/dist/load/types.d.ts +85 -0
- package/dist/load/types.d.ts.map +1 -0
- package/dist/load/types.js +51 -0
- package/dist/load/types.js.map +1 -0
- package/dist/mods.d.ts +23 -0
- package/dist/mods.d.ts.map +1 -0
- package/dist/mods.js +48 -0
- package/dist/mods.js.map +1 -0
- package/dist/project.d.ts +94 -0
- package/dist/project.d.ts.map +1 -0
- package/dist/project.js +168 -0
- package/dist/project.js.map +1 -0
- package/dist/schedule.d.ts +33 -0
- package/dist/schedule.d.ts.map +1 -0
- package/dist/schedule.js +132 -0
- package/dist/schedule.js.map +1 -0
- package/dist/trace/index.d.ts +32 -0
- package/dist/trace/index.d.ts.map +1 -0
- package/dist/trace/index.js +48 -0
- package/dist/trace/index.js.map +1 -0
- package/dist/trace/memory.d.ts +83 -0
- package/dist/trace/memory.d.ts.map +1 -0
- package/dist/trace/memory.js +260 -0
- package/dist/trace/memory.js.map +1 -0
- package/dist/trace/sqlite.d.ts +80 -0
- package/dist/trace/sqlite.d.ts.map +1 -0
- package/dist/trace/sqlite.js +344 -0
- package/dist/trace/sqlite.js.map +1 -0
- package/dist/worker/entry.d.ts +16 -0
- package/dist/worker/entry.d.ts.map +1 -0
- package/dist/worker/entry.js +135 -0
- package/dist/worker/entry.js.map +1 -0
- package/dist/worker-pool.d.ts +49 -0
- package/dist/worker-pool.d.ts.map +1 -0
- package/dist/worker-pool.js +203 -0
- package/dist/worker-pool.js.map +1 -0
- package/dist/ws-registry.d.ts +39 -0
- package/dist/ws-registry.d.ts.map +1 -0
- package/dist/ws-registry.js +99 -0
- package/dist/ws-registry.js.map +1 -0
- package/dist/ws.d.ts +72 -0
- package/dist/ws.d.ts.map +1 -0
- package/dist/ws.js +236 -0
- package/dist/ws.js.map +1 -0
- 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
|