@kindgi/handler-runtime 0.0.0-bootstrap.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +218 -2
  3. package/dist/build-extensions.d.ts +75 -0
  4. package/dist/build-extensions.d.ts.map +1 -0
  5. package/dist/build-extensions.js +27 -0
  6. package/dist/build-extensions.js.map +1 -0
  7. package/dist/discovery.d.ts +19 -0
  8. package/dist/discovery.d.ts.map +1 -0
  9. package/dist/discovery.js +81 -0
  10. package/dist/discovery.js.map +1 -0
  11. package/dist/entrypoint.d.ts +10 -0
  12. package/dist/entrypoint.d.ts.map +1 -0
  13. package/dist/entrypoint.js +24 -0
  14. package/dist/entrypoint.js.map +1 -0
  15. package/dist/handler-runner.d.ts +127 -0
  16. package/dist/handler-runner.d.ts.map +1 -0
  17. package/dist/handler-runner.js +318 -0
  18. package/dist/handler-runner.js.map +1 -0
  19. package/dist/index.d.ts +8 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +7 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/kindgi-index-main.d.ts +2 -0
  24. package/dist/kindgi-index-main.d.ts.map +1 -0
  25. package/dist/kindgi-index-main.js +15 -0
  26. package/dist/kindgi-index-main.js.map +1 -0
  27. package/dist/kindgi-index.d.ts +316 -0
  28. package/dist/kindgi-index.d.ts.map +1 -0
  29. package/dist/kindgi-index.js +1201 -0
  30. package/dist/kindgi-index.js.map +1 -0
  31. package/dist/pack-env.d.ts +66 -0
  32. package/dist/pack-env.d.ts.map +1 -0
  33. package/dist/pack-env.js +97 -0
  34. package/dist/pack-env.js.map +1 -0
  35. package/dist/pack-service/index.d.ts +7 -0
  36. package/dist/pack-service/index.d.ts.map +1 -0
  37. package/dist/pack-service/index.js +6 -0
  38. package/dist/pack-service/index.js.map +1 -0
  39. package/dist/pack-service/main.d.ts +47 -0
  40. package/dist/pack-service/main.d.ts.map +1 -0
  41. package/dist/pack-service/main.js +201 -0
  42. package/dist/pack-service/main.js.map +1 -0
  43. package/dist/pack-service/service.d.ts +61 -0
  44. package/dist/pack-service/service.d.ts.map +1 -0
  45. package/dist/pack-service/service.js +341 -0
  46. package/dist/pack-service/service.js.map +1 -0
  47. package/dist/pack-service/supervisor.d.ts +138 -0
  48. package/dist/pack-service/supervisor.d.ts.map +1 -0
  49. package/dist/pack-service/supervisor.js +424 -0
  50. package/dist/pack-service/supervisor.js.map +1 -0
  51. package/dist/protocol.d.ts +104 -0
  52. package/dist/protocol.d.ts.map +1 -0
  53. package/dist/protocol.js +116 -0
  54. package/dist/protocol.js.map +1 -0
  55. package/package.json +87 -4
  56. package/src/build-extensions.ts +100 -0
  57. package/src/discovery.ts +89 -0
  58. package/src/entrypoint.ts +23 -0
  59. package/src/handler-runner.ts +500 -0
  60. package/src/index.ts +66 -0
  61. package/src/kindgi-index-main.ts +17 -0
  62. package/src/kindgi-index.ts +1605 -0
  63. package/src/pack-env.ts +148 -0
  64. package/src/pack-service/index.ts +17 -0
  65. package/src/pack-service/main.ts +246 -0
  66. package/src/pack-service/service.ts +478 -0
  67. package/src/pack-service/supervisor.ts +600 -0
  68. package/src/protocol.ts +214 -0
@@ -0,0 +1,600 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * The pack service as a local child process behind a stable front — how
6
+ * a development loop runs pack code: the same entrypoint and protocol as
7
+ * a deployment, on loopback, with a token only this process knows.
8
+ *
9
+ * The front is one HTTP listener for the supervisor's whole life: a fixed
10
+ * address, the session token. It forwards `POST /v1/invoke` and
11
+ * `GET /v1/info` to whichever child is serving and answers 503 while none
12
+ * is — a caller retries those, since the call provably did not run.
13
+ * `/healthz` answers 200 while the front listens; `/readyz` 200 only while
14
+ * a child serves. So a caller holds one URL and one token however often
15
+ * the code changes.
16
+ *
17
+ * `start(indexPath)` boots a child on that index (loopback, any port).
18
+ * Once the child listens (it prewarms every module first), new calls go to
19
+ * it; the previous child finishes its in-flight calls (SIGTERM drains) and
20
+ * stops. If a child fails to boot, the previous one keeps serving. A child
21
+ * that exits on its own is restarted on the same index.
22
+ *
23
+ * The child's environment is exactly `env()` plus the token and `PORT=0`
24
+ * — nothing of this process's own environment reaches pack code.
25
+ *
26
+ * Forwarding has two parts. The relay ({@link PackRelay}) takes a request
27
+ * body and the call's options — deadline, run and request ids, a cancel
28
+ * signal — and calls the serving child. The HTTP listener maps headers and
29
+ * a closed connection onto those options. Another listener (one that
30
+ * receives calls over a connection the pack side opened) can reuse the
31
+ * relay unchanged.
32
+ */
33
+
34
+ import { type ChildProcess, spawn } from 'node:child_process';
35
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
36
+ import { type IncomingMessage, type Server, type ServerResponse, createServer } from 'node:http';
37
+ import type { AddressInfo } from 'node:net';
38
+ import { createInterface } from 'node:readline';
39
+
40
+ import type { Result } from '@kindgi/types';
41
+
42
+ import { PACK_HEADERS } from '../protocol.js';
43
+ import { PACK_SERVICE_DRAIN_MS } from './main.js';
44
+
45
+ export interface PackServiceSupervisorOptions {
46
+ /**
47
+ * The pack-service process as an argv — the program, then its arguments.
48
+ * The supervisor appends `--index`, `--module-root` and `--host`. Any pack
49
+ * service with that process contract works (`pack-protocol.schema.json`):
50
+ *
51
+ * - Node: `[process.execPath, '--enable-source-maps', <@kindgi/handler-runtime/pack-service-main>]`
52
+ * - Python: `[<the pack's python>, '-m', 'kindgi.pack', 'serve']`
53
+ */
54
+ readonly command: readonly [string, ...string[]];
55
+ /** Where the index's module paths resolve — the pack directory. */
56
+ readonly moduleRoot: string;
57
+ /** The child's whole environment, read at every start. */
58
+ readonly env: () => Promise<Readonly<Record<string, string>>>;
59
+ /** Where the front listens. Default `127.0.0.1`. */
60
+ readonly host?: string;
61
+ /** The front's port. Default `0`: any free port, fixed while the front listens. */
62
+ readonly port?: number;
63
+ /**
64
+ * The token callers of the front send. Default: a new random one for
65
+ * each supervisor. Pass the previous supervisor's (with its `port`) so
66
+ * a caller started with both, such as a runtime running from source
67
+ * for `kindgi dev --runtime-url`, keeps reaching the front across
68
+ * restarts. At least 32 URL-safe base64 characters.
69
+ */
70
+ readonly token?: string;
71
+ /** Each line pack code writes; the service's own JSON log lines arrive as `onEvent`. */
72
+ readonly onLog?: (line: string, stream: 'stdout' | 'stderr') => void;
73
+ readonly onEvent?: (event: PackServiceSupervisorEvent) => void;
74
+ /** How long a child may take to listen. Default 60 000 ms. */
75
+ readonly bootTimeoutMs?: number;
76
+ /**
77
+ * How long a stopping child may drain before it is killed. Default: the
78
+ * child's own drain (`PACK_SERVICE_DRAIN_MS`) plus 2 s, so a call the
79
+ * child would still finish is never cut off by the kill.
80
+ */
81
+ readonly stopGraceMs?: number;
82
+ /** Restarts after a child exits on its own, before giving up. Default 3. */
83
+ readonly maxRestarts?: number;
84
+ /** Wait before such a restart. Default 500 ms. */
85
+ readonly restartDelayMs?: number;
86
+ /** Largest request body the front accepts. Default 10 MiB (the pack service's own limit). */
87
+ readonly maxBodyBytes?: number;
88
+ }
89
+
90
+ export type PackServiceSupervisorEvent =
91
+ | { readonly kind: 'started'; readonly port: number; readonly indexPath: string }
92
+ | { readonly kind: 'boot-failed'; readonly problems: readonly string[] }
93
+ | { readonly kind: 'exited'; readonly code: number | null; readonly signal: string | null }
94
+ | { readonly kind: 'restarting'; readonly attempt: number }
95
+ | { readonly kind: 'gave-up'; readonly attempts: number }
96
+ /** One of the service's own JSON log lines (a call, draining, …). */
97
+ | { readonly kind: 'log'; readonly event: Readonly<Record<string, unknown>> };
98
+
99
+ export interface BootFailure {
100
+ readonly problems: readonly string[];
101
+ }
102
+
103
+ /** One call to forward: a request body and the call's options. */
104
+ export interface PackRelayCall {
105
+ readonly route: 'invoke' | 'info';
106
+ /** The protocol v2 request (JSON text) for `invoke`; absent for `info`. */
107
+ readonly body?: string;
108
+ /** Cancels the call: the child sees its connection close. */
109
+ readonly signal?: AbortSignal;
110
+ /** The call's deadline, passed on to the child. */
111
+ readonly timeoutMs?: number;
112
+ readonly runId?: string;
113
+ readonly requestId?: string;
114
+ /** The caller's protocol version (`kindgi-protocol`), passed on as is. */
115
+ readonly protocol?: string;
116
+ }
117
+
118
+ /**
119
+ * What became of a forwarded call:
120
+ * - `answer` — the child answered (any status, passed through as is);
121
+ * - `unavailable` — not delivered (no child serving, or the connection
122
+ * was refused), so it provably did not run and may be retried;
123
+ * - `failed` — delivered, but no answer came back (the child died
124
+ * mid-call); it may have run, so it must not be retried;
125
+ * - `cancelled` — the caller cancelled it.
126
+ */
127
+ export type PackRelayOutcome =
128
+ | {
129
+ readonly kind: 'answer';
130
+ readonly status: number;
131
+ readonly headers: Readonly<Record<string, string>>;
132
+ readonly body: string;
133
+ }
134
+ | { readonly kind: 'unavailable'; readonly reason: string }
135
+ | { readonly kind: 'failed'; readonly reason: string }
136
+ | { readonly kind: 'cancelled' };
137
+
138
+ /** Forwards calls to the serving child. */
139
+ export interface PackRelay {
140
+ /** Whether a child is serving. */
141
+ readonly serving: () => boolean;
142
+ forward(call: PackRelayCall): Promise<PackRelayOutcome>;
143
+ }
144
+
145
+ export interface PackServiceSupervisor {
146
+ /** The token callers send (`kindgi-pack-token`); the children use it too. */
147
+ readonly token: string;
148
+ /** Forwards one call to the serving child; the front's HTTP listener uses it. */
149
+ readonly relay: PackRelay;
150
+ /** Open the front. Idempotent: later calls return the same address. */
151
+ listen(): Promise<{ readonly url: string; readonly port: number }>;
152
+ /**
153
+ * Boot a child on `indexPath` and switch calls to it once it listens.
154
+ * On failure the previous child, if any, keeps serving.
155
+ */
156
+ start(indexPath: string): Promise<Result<{ readonly port: number }, BootFailure>>;
157
+ /** Stop the serving child; the front then answers 503. */
158
+ stop(): Promise<void>;
159
+ /** Stop the serving child and close the front. */
160
+ close(): Promise<void>;
161
+ }
162
+
163
+ interface Child {
164
+ readonly process: ChildProcess;
165
+ readonly port: number;
166
+ readonly indexPath: string;
167
+ /** Set once this child is being stopped on purpose. */
168
+ retiring: boolean;
169
+ }
170
+
171
+ const DEFAULT_HOST = '127.0.0.1';
172
+ const DEFAULT_BOOT_TIMEOUT_MS = 60_000;
173
+ const DEFAULT_STOP_GRACE_MS = PACK_SERVICE_DRAIN_MS + 2_000;
174
+ const DEFAULT_MAX_RESTARTS = 3;
175
+ const DEFAULT_RESTART_DELAY_MS = 500;
176
+ const DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024;
177
+ /** A token a caller supplies: as long as a generated one (24 bytes, base64url). */
178
+ const PACK_SERVICE_TOKEN = /^[A-Za-z0-9_-]{32,}$/;
179
+ /** Headers of a child's answer that the front passes on. */
180
+ const ANSWER_HEADERS = [
181
+ 'content-type',
182
+ 'retry-after',
183
+ PACK_HEADERS.artifactVersion,
184
+ PACK_HEADERS.durationMs,
185
+ ] as const;
186
+
187
+ export function createPackServiceSupervisor(
188
+ options: PackServiceSupervisorOptions,
189
+ ): PackServiceSupervisor {
190
+ if (options.token !== undefined && !PACK_SERVICE_TOKEN.test(options.token)) {
191
+ throw new Error('A pack service token is at least 32 URL-safe base64 characters.');
192
+ }
193
+ const token = options.token ?? randomBytes(24).toString('base64url');
194
+ const expectedToken = Buffer.from(token);
195
+ const emit = (event: PackServiceSupervisorEvent): void => options.onEvent?.(event);
196
+ let serving: Child | undefined;
197
+ let stopped = false;
198
+ let restarts = 0;
199
+ let front: { readonly server: Server; readonly url: string; readonly port: number } | undefined;
200
+
201
+ async function boot(indexPath: string): Promise<Result<Child, BootFailure>> {
202
+ const env = { ...(await options.env()), KINDGI_PACK_SERVICE_TOKEN: token, PORT: '0' };
203
+ const [program, ...args] = options.command;
204
+ const child = spawn(
205
+ program,
206
+ [...args, '--index', indexPath, '--module-root', options.moduleRoot, '--host', DEFAULT_HOST],
207
+ { env, stdio: ['ignore', 'pipe', 'pipe'] },
208
+ );
209
+ pipeLines(child.stdout, (line) => options.onLog?.(line, 'stdout'));
210
+ return listening(child, indexPath);
211
+ }
212
+
213
+ /** Resolves once the child listens, or with why it never will. */
214
+ function listening(child: ChildProcess, indexPath: string): Promise<Result<Child, BootFailure>> {
215
+ const bootTimeoutMs = options.bootTimeoutMs ?? DEFAULT_BOOT_TIMEOUT_MS;
216
+ return new Promise((resolve) => {
217
+ const problems: string[] = [];
218
+ // A promise settles once; later calls (an exit after listening) are no-ops.
219
+ const done = (outcome: Result<Child, BootFailure>): void => {
220
+ clearTimeout(timer);
221
+ resolve(outcome);
222
+ };
223
+ const fail = (reasons: readonly string[]): void =>
224
+ done({ kind: 'err', error: { problems: reasons } });
225
+ const timer = setTimeout(() => {
226
+ child.kill('SIGKILL');
227
+ fail([`The pack service did not listen within ${bootTimeoutMs} ms`]);
228
+ }, bootTimeoutMs);
229
+
230
+ pipeLines(child.stderr, (line) => {
231
+ const event = serviceEvent(line);
232
+ if (event === undefined) {
233
+ options.onLog?.(line, 'stderr');
234
+ } else if (event.kind === 'listening' && typeof event.port === 'number') {
235
+ done({
236
+ kind: 'ok',
237
+ value: { process: child, port: event.port, indexPath, retiring: false },
238
+ });
239
+ } else if (event.kind === 'boot-failed' || event.kind === 'config-invalid') {
240
+ problems.push(...problemsOf(event));
241
+ } else {
242
+ emit({ kind: 'log', event });
243
+ }
244
+ });
245
+ child.once('error', (cause) => fail([cause.message]));
246
+ child.once('exit', (code, signal) =>
247
+ fail(
248
+ problems.length > 0
249
+ ? problems
250
+ : [`The pack service exited (${signal ?? code}) before listening`],
251
+ ),
252
+ );
253
+ });
254
+ }
255
+
256
+ /** A serving child that exits on its own is restarted on the same index. */
257
+ function watch(child: Child): void {
258
+ child.process.once('exit', (code, signal) => {
259
+ if (child.retiring || stopped) return;
260
+ emit({ kind: 'exited', code, signal });
261
+ if (serving === child) serving = undefined;
262
+ void recover(child.indexPath);
263
+ });
264
+ }
265
+
266
+ async function recover(indexPath: string): Promise<void> {
267
+ while (
268
+ !stopped &&
269
+ serving === undefined &&
270
+ restarts < (options.maxRestarts ?? DEFAULT_MAX_RESTARTS)
271
+ ) {
272
+ restarts += 1;
273
+ emit({ kind: 'restarting', attempt: restarts });
274
+ await new Promise((r) => setTimeout(r, options.restartDelayMs ?? DEFAULT_RESTART_DELAY_MS));
275
+ if (stopped || serving !== undefined) return;
276
+ const booted = await start(indexPath);
277
+ if (booted.kind === 'ok') return;
278
+ }
279
+ if (!stopped && serving === undefined) emit({ kind: 'gave-up', attempts: restarts });
280
+ }
281
+
282
+ async function retire(child: Child | undefined): Promise<void> {
283
+ if (child === undefined) return;
284
+ child.retiring = true;
285
+ if (child.process.exitCode !== null || child.process.signalCode !== null) return;
286
+ const exited = new Promise<void>((done) => child.process.once('exit', () => done()));
287
+ child.process.kill('SIGTERM');
288
+ const grace = setTimeout(
289
+ () => child.process.kill('SIGKILL'),
290
+ options.stopGraceMs ?? DEFAULT_STOP_GRACE_MS,
291
+ );
292
+ await exited;
293
+ clearTimeout(grace);
294
+ }
295
+
296
+ async function start(indexPath: string): Promise<Result<{ readonly port: number }, BootFailure>> {
297
+ stopped = false;
298
+ const booted = await boot(indexPath);
299
+ if (booted.kind === 'err') {
300
+ emit({ kind: 'boot-failed', problems: booted.error.problems });
301
+ return booted;
302
+ }
303
+ const previous = serving;
304
+ serving = booted.value;
305
+ restarts = 0;
306
+ watch(booted.value);
307
+ emit({ kind: 'started', port: booted.value.port, indexPath });
308
+ await retire(previous);
309
+ return { kind: 'ok', value: { port: booted.value.port } };
310
+ }
311
+
312
+ async function stop(): Promise<void> {
313
+ stopped = true;
314
+ const child = serving;
315
+ serving = undefined;
316
+ await retire(child);
317
+ }
318
+
319
+ const relay: PackRelay = {
320
+ serving: () => serving !== undefined,
321
+ forward: (call) => forwardToChild(serving?.port, token, call),
322
+ };
323
+
324
+ function authorized(req: IncomingMessage): boolean {
325
+ const got = req.headers[PACK_HEADERS.token];
326
+ if (typeof got !== 'string') return false;
327
+ const buf = Buffer.from(got);
328
+ return buf.length === expectedToken.length && timingSafeEqual(buf, expectedToken);
329
+ }
330
+
331
+ /** The HTTP listener: maps a request onto a relay call and the outcome onto a response. */
332
+ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
333
+ const admitted = admit(req);
334
+ if (typeof admitted !== 'string') {
335
+ return sendJson(res, admitted.status, admitted.body, admitted.headers);
336
+ }
337
+ const body =
338
+ admitted === 'invoke'
339
+ ? await readBody(req, options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES)
340
+ : undefined;
341
+ if (admitted === 'invoke' && body === undefined) {
342
+ return sendJson(res, 413, { error: 'Request body too large' });
343
+ }
344
+ // The caller gave up (closed the connection) before the answer: cancel the call.
345
+ const controller = new AbortController();
346
+ res.on('close', () => {
347
+ if (!res.writableEnded) controller.abort();
348
+ });
349
+ const outcome = await relay.forward(relayCall(req, admitted, body, controller.signal));
350
+ if (!res.writableEnded && !res.destroyed) writeOutcome(res, outcome);
351
+ }
352
+
353
+ /**
354
+ * The relay route for a request, or the front's own answer: an unknown
355
+ * route, a wrong method, a probe, a bad token.
356
+ */
357
+ function admit(req: IncomingMessage): Reply | 'invoke' | 'info' {
358
+ const route = FRONT_ROUTES[(req.url ?? '/').split('?')[0] ?? ''];
359
+ if (route === undefined) return { status: 404, body: { error: `No route ${req.url ?? '/'}` } };
360
+ if (req.method !== route.method) return { status: 405, body: { error: `Use ${route.method}` } };
361
+ if (route.name === 'healthz' || route.name === 'readyz') return probe(route.name);
362
+ if (!authorized(req)) return { status: 401, body: { error: 'Bad pack token' } };
363
+ return route.name;
364
+ }
365
+
366
+ /** `/healthz`: the front listens. `/readyz`: a child serves. Neither needs the token. */
367
+ function probe(name: 'healthz' | 'readyz'): Reply {
368
+ if (name === 'healthz') return { status: 200, body: { status: 'ok' } };
369
+ return relay.serving()
370
+ ? { status: 200, body: { status: 'ready' } }
371
+ : { status: 503, body: { error: 'no pack service' }, headers: RETRY_AFTER };
372
+ }
373
+
374
+ return {
375
+ token,
376
+ relay,
377
+ async listen() {
378
+ if (front !== undefined) return { url: front.url, port: front.port };
379
+ const host = options.host ?? DEFAULT_HOST;
380
+ const server = createServer((req, res) => {
381
+ void handle(req, res).catch((cause) => {
382
+ if (!res.headersSent) sendJson(res, 500, { error: describe(cause) });
383
+ else res.destroy();
384
+ });
385
+ });
386
+ await new Promise<void>((ready, failed) => {
387
+ server.once('error', failed);
388
+ server.listen(options.port ?? 0, host, () => {
389
+ server.off('error', failed);
390
+ ready();
391
+ });
392
+ });
393
+ const port = (server.address() as AddressInfo).port;
394
+ front = { server, port, url: `http://${host.includes(':') ? `[${host}]` : host}:${port}` };
395
+ return { url: front.url, port };
396
+ },
397
+ start,
398
+ stop,
399
+ async close() {
400
+ await stop();
401
+ const closing = front;
402
+ front = undefined;
403
+ if (closing !== undefined) {
404
+ // No child serves any more; drop idle keep-alives and callers that went away.
405
+ const closed = new Promise<void>((done) => closing.server.close(() => done()));
406
+ closing.server.closeAllConnections();
407
+ await closed;
408
+ }
409
+ },
410
+ };
411
+ }
412
+
413
+ /** Call the serving child on `port` with the session token. */
414
+ async function forwardToChild(
415
+ port: number | undefined,
416
+ token: string,
417
+ call: PackRelayCall,
418
+ ): Promise<PackRelayOutcome> {
419
+ if (port === undefined) return { kind: 'unavailable', reason: 'no pack service' };
420
+ if (call.signal?.aborted) return { kind: 'cancelled' };
421
+ let response: Response;
422
+ try {
423
+ response = await fetch(`http://${DEFAULT_HOST}:${port}/v1/${call.route}`, {
424
+ method: call.route === 'invoke' ? 'POST' : 'GET',
425
+ headers: childHeaders(token, call),
426
+ ...(call.body !== undefined && { body: call.body }),
427
+ ...(call.signal !== undefined && { signal: call.signal }),
428
+ });
429
+ } catch (cause) {
430
+ if (call.signal?.aborted) return { kind: 'cancelled' };
431
+ return refused(cause)
432
+ ? { kind: 'unavailable', reason: 'the pack service refused the connection' }
433
+ : { kind: 'failed', reason: `the pack service did not answer: ${describe(cause)}` };
434
+ }
435
+ return answerFrom(response, call.signal);
436
+ }
437
+
438
+ function childHeaders(token: string, call: PackRelayCall): Record<string, string> {
439
+ return {
440
+ [PACK_HEADERS.token]: token,
441
+ ...(call.body !== undefined && { 'content-type': 'application/json' }),
442
+ ...(call.timeoutMs !== undefined && { [PACK_HEADERS.timeoutMs]: String(call.timeoutMs) }),
443
+ ...(call.runId !== undefined && { [PACK_HEADERS.runId]: call.runId }),
444
+ ...(call.requestId !== undefined && { [PACK_HEADERS.requestId]: call.requestId }),
445
+ ...(call.protocol !== undefined && { [PACK_HEADERS.protocol]: call.protocol }),
446
+ };
447
+ }
448
+
449
+ async function answerFrom(
450
+ response: Response,
451
+ signal: AbortSignal | undefined,
452
+ ): Promise<PackRelayOutcome> {
453
+ let body: string;
454
+ try {
455
+ body = await response.text();
456
+ } catch (cause) {
457
+ if (signal?.aborted) return { kind: 'cancelled' };
458
+ return { kind: 'failed', reason: `the pack service's answer broke off: ${describe(cause)}` };
459
+ }
460
+ const headers: Record<string, string> = {};
461
+ for (const name of ANSWER_HEADERS) {
462
+ const value = response.headers.get(name);
463
+ if (value !== null) headers[name] = value;
464
+ }
465
+ return { kind: 'answer', status: response.status, headers, body };
466
+ }
467
+
468
+ /** The relay call for a front request. */
469
+ function relayCall(
470
+ req: IncomingMessage,
471
+ route: 'invoke' | 'info',
472
+ body: string | undefined,
473
+ signal: AbortSignal,
474
+ ): PackRelayCall {
475
+ const timeoutMs = Number(header(req, PACK_HEADERS.timeoutMs));
476
+ const runId = header(req, PACK_HEADERS.runId);
477
+ const requestId = header(req, PACK_HEADERS.requestId);
478
+ const protocol = header(req, PACK_HEADERS.protocol);
479
+ return {
480
+ route,
481
+ signal,
482
+ ...(body !== undefined && { body }),
483
+ ...(Number.isFinite(timeoutMs) && timeoutMs > 0 && { timeoutMs }),
484
+ ...(runId !== undefined && { runId }),
485
+ ...(requestId !== undefined && { requestId }),
486
+ ...(protocol !== undefined && { protocol }),
487
+ };
488
+ }
489
+
490
+ function writeOutcome(res: ServerResponse, outcome: PackRelayOutcome): void {
491
+ switch (outcome.kind) {
492
+ case 'answer':
493
+ res.writeHead(outcome.status, {
494
+ ...outcome.headers,
495
+ 'content-length': Buffer.byteLength(outcome.body),
496
+ });
497
+ res.end(outcome.body);
498
+ return;
499
+ case 'unavailable':
500
+ sendJson(res, 503, { error: outcome.reason }, RETRY_AFTER);
501
+ return;
502
+ case 'failed':
503
+ sendJson(res, 502, { error: outcome.reason });
504
+ return;
505
+ case 'cancelled':
506
+ res.destroy();
507
+ return;
508
+ }
509
+ }
510
+
511
+ interface Reply {
512
+ readonly status: number;
513
+ readonly body: unknown;
514
+ readonly headers?: Readonly<Record<string, string>>;
515
+ }
516
+
517
+ const RETRY_AFTER: Readonly<Record<string, string>> = { 'retry-after': '1' };
518
+
519
+ interface FrontRoute {
520
+ readonly name: 'invoke' | 'info' | 'healthz' | 'readyz';
521
+ readonly method: 'GET' | 'POST';
522
+ }
523
+
524
+ const FRONT_ROUTES: Readonly<Record<string, FrontRoute>> = {
525
+ '/v1/invoke': { name: 'invoke', method: 'POST' },
526
+ '/v1/info': { name: 'info', method: 'GET' },
527
+ '/healthz': { name: 'healthz', method: 'GET' },
528
+ '/readyz': { name: 'readyz', method: 'GET' },
529
+ };
530
+
531
+ /** A connection that never reached a listener: the call did not run. */
532
+ function refused(cause: unknown): boolean {
533
+ const code = (cause as { cause?: { code?: unknown } } | undefined)?.cause?.code;
534
+ return code === 'ECONNREFUSED';
535
+ }
536
+
537
+ function header(req: IncomingMessage, name: string): string | undefined {
538
+ const value = req.headers[name];
539
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
540
+ }
541
+
542
+ /** The request body, or `undefined` past `limit` bytes. */
543
+ function readBody(req: IncomingMessage, limit: number): Promise<string | undefined> {
544
+ return new Promise((resolve, reject) => {
545
+ const chunks: Buffer[] = [];
546
+ let size = 0;
547
+ let tooLarge = false;
548
+ req.on('data', (chunk: Buffer) => {
549
+ size += chunk.length;
550
+ if (size > limit) tooLarge = true;
551
+ else chunks.push(chunk);
552
+ });
553
+ req.on('end', () => resolve(tooLarge ? undefined : Buffer.concat(chunks).toString('utf8')));
554
+ req.on('error', reject);
555
+ });
556
+ }
557
+
558
+ function sendJson(
559
+ res: ServerResponse,
560
+ status: number,
561
+ body: unknown,
562
+ headers: Readonly<Record<string, string>> = {},
563
+ ): void {
564
+ const text = JSON.stringify(body);
565
+ res.writeHead(status, {
566
+ 'content-type': 'application/json; charset=utf-8',
567
+ 'content-length': Buffer.byteLength(text),
568
+ ...headers,
569
+ });
570
+ res.end(text);
571
+ }
572
+
573
+ function pipeLines(stream: NodeJS.ReadableStream | null, onLine: (line: string) => void): void {
574
+ if (stream !== null) createInterface({ input: stream }).on('line', onLine);
575
+ }
576
+
577
+ function problemsOf(event: Readonly<Record<string, unknown>>): readonly string[] {
578
+ return Array.isArray(event.problems) ? event.problems.map(String) : [];
579
+ }
580
+
581
+ /** The service's own log lines are JSON objects with a `kind`. */
582
+ function serviceEvent(
583
+ line: string,
584
+ ): (Record<string, unknown> & { readonly kind: string }) | undefined {
585
+ if (!line.startsWith('{')) return undefined;
586
+ try {
587
+ const parsed = JSON.parse(line) as unknown;
588
+ return parsed !== null &&
589
+ typeof parsed === 'object' &&
590
+ typeof (parsed as { kind?: unknown }).kind === 'string'
591
+ ? (parsed as Record<string, unknown> & { readonly kind: string })
592
+ : undefined;
593
+ } catch {
594
+ return undefined;
595
+ }
596
+ }
597
+
598
+ function describe(cause: unknown): string {
599
+ return cause instanceof Error ? cause.message : String(cause);
600
+ }