@zhin.js/host-http 1.1.17 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +60 -21
  2. package/lib/access-control.d.ts +127 -0
  3. package/lib/access-control.js +148 -0
  4. package/lib/addresses.d.ts +9 -0
  5. package/lib/addresses.js +9 -0
  6. package/lib/bridge/bridged-host.d.ts +43 -0
  7. package/lib/bridge/bridged-host.js +497 -0
  8. package/lib/bridge/control-channel.d.ts +59 -0
  9. package/lib/bridge/control-channel.js +131 -0
  10. package/lib/bridge/flow.d.ts +58 -0
  11. package/lib/bridge/flow.js +188 -0
  12. package/lib/bridge/index.d.ts +6 -0
  13. package/lib/bridge/index.js +6 -0
  14. package/lib/bridge/instance-transport.d.ts +112 -0
  15. package/lib/bridge/instance-transport.js +532 -0
  16. package/lib/bridge/protocol.d.ts +242 -0
  17. package/lib/bridge/protocol.js +369 -0
  18. package/lib/console-events.d.ts +5 -3
  19. package/lib/console-events.js +53 -27
  20. package/lib/console-plugin-rpc.d.ts +12 -1
  21. package/lib/console-plugin-rpc.js +16 -66
  22. package/lib/console-rest-pages-helpers.d.ts +4 -5
  23. package/lib/console-rest-pages-helpers.js +62 -79
  24. package/lib/console-rest-pages.js +80 -65
  25. package/lib/console-rpc-extended/dispatcher.js +1 -6
  26. package/lib/console-rpc-handlers.d.ts +0 -2
  27. package/lib/console-rpc-handlers.js +2 -40
  28. package/lib/console-rpc.d.ts +6 -18
  29. package/lib/console-rpc.js +6 -6
  30. package/lib/host-installer.d.ts +6 -3
  31. package/lib/host-installer.js +10 -40
  32. package/lib/host-options.d.ts +47 -0
  33. package/lib/host-options.js +1 -0
  34. package/lib/http-host.d.ts +124 -48
  35. package/lib/http-host.js +319 -332
  36. package/lib/index.d.ts +11 -6
  37. package/lib/index.js +10 -6
  38. package/lib/json-body.d.ts +1 -2
  39. package/lib/json-body.js +1 -2
  40. package/lib/marketplace-registry.d.ts +17 -0
  41. package/lib/marketplace-registry.js +52 -0
  42. package/lib/node/index.d.ts +10 -0
  43. package/lib/node/index.js +10 -0
  44. package/lib/{listener-group.d.ts → node/listener-group.d.ts} +2 -3
  45. package/lib/{listener-group.js → node/listener-group.js} +6 -10
  46. package/lib/node/listener.d.ts +26 -0
  47. package/lib/node/listener.js +231 -0
  48. package/lib/node/node-http.d.ts +38 -0
  49. package/lib/node/node-http.js +272 -0
  50. package/lib/node/server.d.ts +13 -0
  51. package/lib/node/server.js +64 -0
  52. package/lib/protocol-auth.d.ts +24 -0
  53. package/lib/protocol-auth.js +48 -0
  54. package/lib/public-api.d.ts +1 -1
  55. package/lib/token-registry.d.ts +54 -36
  56. package/lib/token-registry.js +133 -126
  57. package/lib/trust-proxy.d.ts +22 -0
  58. package/lib/trust-proxy.js +168 -0
  59. package/package.json +14 -6
  60. package/src/access-control.ts +255 -0
  61. package/src/addresses.ts +16 -0
  62. package/src/bridge/bridged-host.ts +548 -0
  63. package/src/bridge/control-channel.ts +173 -0
  64. package/src/bridge/flow.ts +208 -0
  65. package/src/bridge/index.ts +31 -0
  66. package/src/bridge/instance-transport.ts +627 -0
  67. package/src/bridge/protocol.ts +574 -0
  68. package/src/console-events.ts +59 -28
  69. package/src/console-plugin-rpc.ts +17 -77
  70. package/src/console-rest-pages-helpers.ts +60 -82
  71. package/src/console-rest-pages.ts +87 -67
  72. package/src/console-rpc-extended/dispatcher.ts +1 -6
  73. package/src/console-rpc-handlers.ts +2 -31
  74. package/src/console-rpc.ts +10 -24
  75. package/src/host-installer.ts +12 -44
  76. package/src/host-options.ts +47 -0
  77. package/src/http-host.ts +467 -421
  78. package/src/index.ts +56 -14
  79. package/src/json-body.ts +7 -2
  80. package/src/marketplace-registry.ts +62 -0
  81. package/src/node/index.ts +21 -0
  82. package/src/{listener-group.ts → node/listener-group.ts} +13 -24
  83. package/src/node/listener.ts +265 -0
  84. package/src/node/node-http.ts +263 -0
  85. package/src/node/server.ts +82 -0
  86. package/src/protocol-auth.ts +52 -0
  87. package/src/public-api.ts +2 -0
  88. package/src/token-registry.ts +143 -146
  89. package/src/trust-proxy.ts +178 -0
@@ -0,0 +1,497 @@
1
+ /**
2
+ * Instance side of the HTTP bridge (ADR 0054 §2 / §3): `BridgedHttpHost` implements the same
3
+ * {@link HttpHost} surface as the in-process Node server, but serves requests that the gateway
4
+ * forwards over the control channel. Routing, CORS, generation admission and Console
5
+ * authorization are the shared router's (`createHttpRouter`), running in `forwarded`
6
+ * authentication mode: the gateway authenticated the caller and the principal travels with the
7
+ * request; the router still authorizes it against the Console permission map.
8
+ *
9
+ * Console accounts live in the gateway (ADR 0054 §7): the instance never resolves a bearer. Its
10
+ * token registry is closed (no bootstrap token, no open Host): every grant arrives as the
11
+ * forwarded principal. Audit entries the instance writes go to the gateway (`audit`), the plugin
12
+ * permission catalog of the current generation is reported (`catalog`), and `principal-revoked`
13
+ * closes that user's live exchanges and sockets.
14
+ *
15
+ * Nothing here imports `node:http` or `ws`.
16
+ */
17
+ import { formatCompact, getLogger } from '@zhin.js/logger';
18
+ import { generationAdmissionBinder, } from '@zhin.js/plugin-runtime';
19
+ import { createHttpRouter, } from '../http-host.js';
20
+ import { TokenRegistry } from '../token-registry.js';
21
+ import { httpHostAddressProvider } from '../addresses.js';
22
+ import { CreditGate, InboundBody, ownBytes, pumpBody } from './flow.js';
23
+ import { DEFAULT_BRIDGE_LIMITS, fromBridgeHeaders, parseBridgePrincipal, toBridgeHeaders, wsMessageSize, } from './protocol.js';
24
+ const logger = getLogger('http');
25
+ const BODYLESS_METHODS = new Set(['GET', 'HEAD']);
26
+ /** Close code / reason used when the gateway revoked the socket's principal. */
27
+ export const PRINCIPAL_REVOKED_CLOSE_CODE = 4401;
28
+ export function createBridgedHttpHost(options) {
29
+ const limits = Object.freeze({ ...DEFAULT_BRIDGE_LIMITS, ...options.limits });
30
+ const { channel } = options;
31
+ let addresses = Object.freeze([]);
32
+ const tokenRegistry = new TokenRegistry();
33
+ const router = createHttpRouter({
34
+ ...options,
35
+ tokenRegistry,
36
+ authentication: 'forwarded',
37
+ address: () => addresses[0],
38
+ secure: false,
39
+ });
40
+ const exchanges = new Map();
41
+ const sockets = new Map();
42
+ let listening = false;
43
+ let closed = false;
44
+ let revision = 0;
45
+ let generation;
46
+ let lastSent;
47
+ let publishScheduled = false;
48
+ let closeResult;
49
+ const disposers = [];
50
+ let lastCatalog;
51
+ const revokedListeners = new Set();
52
+ const send = (message) => {
53
+ try {
54
+ channel.send(message);
55
+ }
56
+ catch { /* channel gone */ }
57
+ };
58
+ // Closed registry: initialized (no open Host, no bootstrap token), never resolves a bearer;
59
+ // audit entries are written by the gateway.
60
+ const forwardedAccess = Object.freeze({
61
+ initialized: () => true,
62
+ authenticate: async () => null,
63
+ audit: (entry) => {
64
+ if (listening && !closed)
65
+ send({ type: 'audit', payload: { entry: cloneableAudit(entry) } });
66
+ },
67
+ });
68
+ tokenRegistry.useAccessProvider(forwardedAccess);
69
+ /** Reports the plugin permission catalog when it changed (or always with `force`). */
70
+ function sendCatalog(force) {
71
+ if (!listening || closed)
72
+ return;
73
+ let pluginPermissions;
74
+ try {
75
+ pluginPermissions = JSON.parse(JSON.stringify(tokenRegistry.pluginPermissions()));
76
+ }
77
+ catch {
78
+ return;
79
+ }
80
+ const fingerprint = JSON.stringify(pluginPermissions);
81
+ if (!force && fingerprint === lastCatalog)
82
+ return;
83
+ lastCatalog = fingerprint;
84
+ send({ type: 'catalog', payload: { pluginPermissions } });
85
+ }
86
+ function revokePrincipal(principalId) {
87
+ for (const [id, exchange] of [...exchanges]) {
88
+ if (exchange.principalId === principalId)
89
+ abortExchange(id, 'Credentials revoked', true);
90
+ }
91
+ for (const socket of [...sockets.values()]) {
92
+ if (socket.principalId === principalId)
93
+ socket.finish(PRINCIPAL_REVOKED_CLOSE_CODE, 'Credentials revoked', true);
94
+ }
95
+ for (const listener of [...revokedListeners]) {
96
+ try {
97
+ listener(principalId);
98
+ }
99
+ catch { /* listener failure stays local */ }
100
+ }
101
+ }
102
+ function routeTable() {
103
+ if (closed) {
104
+ return Object.freeze({ revision, ...(generation !== undefined ? { generation } : {}), apiBase: router.apiBase, http: Object.freeze([]), ws: Object.freeze([]) });
105
+ }
106
+ return Object.freeze({
107
+ revision,
108
+ ...(generation !== undefined ? { generation } : {}),
109
+ apiBase: router.apiBase,
110
+ http: Object.freeze(router.listRoutes().map((route) => {
111
+ const meta = cloneableMeta(route.meta);
112
+ return Object.freeze({ method: route.method, pattern: route.pattern, ...(meta ? { meta } : {}) });
113
+ })),
114
+ ws: router.listWsRoutes(),
115
+ });
116
+ }
117
+ function sendRoutes(force) {
118
+ if (!listening)
119
+ return;
120
+ const table = routeTable();
121
+ const fingerprint = JSON.stringify([table.generation, table.http, table.ws]);
122
+ if (!force && fingerprint === lastSent)
123
+ return;
124
+ lastSent = fingerprint;
125
+ revision += 1;
126
+ // The catalog follows the generation: report it before the table that serves it.
127
+ sendCatalog(false);
128
+ send({ type: 'routes', payload: { table: { ...table, revision } } });
129
+ }
130
+ // Registration bursts (a generation registering dozens of routes) coalesce into one table.
131
+ const schedulePublish = () => {
132
+ if (!listening || publishScheduled)
133
+ return;
134
+ publishScheduled = true;
135
+ queueMicrotask(() => {
136
+ publishScheduled = false;
137
+ sendRoutes(false);
138
+ });
139
+ };
140
+ // ---- HTTP
141
+ async function handleRequest(message) {
142
+ const { id, payload } = message;
143
+ if (exchanges.has(id) || sockets.has(id))
144
+ return;
145
+ if (!listening || closed || exchanges.size + sockets.size >= limits.maxStreams) {
146
+ send({ type: 'http.response.head', id, payload: { status: 503, headers: [], body: false } });
147
+ send({ type: 'http.response.end', id, payload: {} });
148
+ return;
149
+ }
150
+ const principal = payload.principal === undefined ? undefined : parseBridgePrincipal(payload.principal);
151
+ const principalId = userIdOf(principal);
152
+ const exchange = {
153
+ abort: new AbortController(),
154
+ gate: new CreditGate(limits.initialCreditBytes),
155
+ ...(principalId ? { principalId } : {}),
156
+ };
157
+ exchanges.set(id, exchange);
158
+ const method = payload.method.toUpperCase();
159
+ if (payload.body && !BODYLESS_METHODS.has(method)) {
160
+ exchange.body = new InboundBody({
161
+ window: limits.initialCreditBytes,
162
+ grant: (bytes) => { if (exchanges.get(id) === exchange)
163
+ send({ type: 'http.request.credit', id, payload: { bytes } }); },
164
+ // The gateway stops sending once the response ends; nothing to tell it now.
165
+ cancel: () => undefined,
166
+ });
167
+ }
168
+ try {
169
+ let request;
170
+ try {
171
+ request = new Request(payload.url, {
172
+ method,
173
+ headers: fromBridgeHeaders(payload.headers),
174
+ signal: exchange.abort.signal,
175
+ ...(exchange.body ? { body: exchange.body.stream, duplex: 'half' } : {}),
176
+ });
177
+ }
178
+ catch {
179
+ sendHead(id, new Response(null, { status: 400 }));
180
+ send({ type: 'http.response.end', id, payload: {} });
181
+ return;
182
+ }
183
+ const response = await router.handle(request, {
184
+ ...(payload.remoteAddress ? { remoteAddress: payload.remoteAddress } : {}),
185
+ ...(principal ? { principal } : {}),
186
+ });
187
+ if (exchange.abort.signal.aborted) {
188
+ await response.body?.cancel(exchange.abort.signal.reason).catch(() => undefined);
189
+ return;
190
+ }
191
+ sendHead(id, response);
192
+ if (response.body) {
193
+ await pumpBody(response.body, {
194
+ gate: exchange.gate,
195
+ maxChunkBytes: limits.maxChunkBytes,
196
+ signal: exchange.abort.signal,
197
+ send: (chunk) => send({ type: 'http.response.body', id, payload: { chunk } }),
198
+ });
199
+ }
200
+ if (!exchange.abort.signal.aborted)
201
+ send({ type: 'http.response.end', id, payload: {} });
202
+ }
203
+ catch (error) {
204
+ if (!exchange.abort.signal.aborted) {
205
+ logger.debug(formatCompact({ op: 'bridge_response_failed', error: errorText(error) }));
206
+ send({ type: 'http.response.error', id, payload: { message: errorText(error).slice(0, 512) } });
207
+ }
208
+ }
209
+ finally {
210
+ if (exchanges.get(id) === exchange)
211
+ exchanges.delete(id);
212
+ exchange.gate.close();
213
+ exchange.body?.fail(new Error('Exchange finished'));
214
+ }
215
+ }
216
+ function sendHead(id, response) {
217
+ send({
218
+ type: 'http.response.head',
219
+ id,
220
+ payload: {
221
+ status: response.status,
222
+ ...(response.statusText ? { statusText: response.statusText.slice(0, 256) } : {}),
223
+ headers: toBridgeHeaders(response.headers),
224
+ body: response.body !== null,
225
+ },
226
+ });
227
+ }
228
+ function abortExchange(id, reason, notify) {
229
+ const exchange = exchanges.get(id);
230
+ if (!exchange)
231
+ return;
232
+ exchanges.delete(id);
233
+ const error = new Error(reason);
234
+ exchange.abort.abort(error);
235
+ exchange.gate.close(error);
236
+ exchange.body?.fail(error);
237
+ if (notify)
238
+ send({ type: 'http.response.error', id, payload: { message: reason } });
239
+ }
240
+ // ---- WebSocket
241
+ async function handleWsOpen(message) {
242
+ const { id, payload } = message;
243
+ if (exchanges.has(id) || sockets.has(id))
244
+ return;
245
+ if (!listening || closed || exchanges.size + sockets.size >= limits.maxStreams) {
246
+ send({ type: 'ws.rejected', id, payload: { status: 503 } });
247
+ return;
248
+ }
249
+ let url;
250
+ try {
251
+ url = new URL(payload.url);
252
+ }
253
+ catch {
254
+ send({ type: 'ws.rejected', id, payload: { status: 404 } });
255
+ return;
256
+ }
257
+ const principal = payload.principal === undefined ? undefined : parseBridgePrincipal(payload.principal);
258
+ // Reserve the id while the upgrade is decided, so a racing ws.close is not lost.
259
+ const socket = createSocket(id);
260
+ const principalId = userIdOf(principal);
261
+ if (principalId)
262
+ socket.principalId = principalId;
263
+ sockets.set(id, socket);
264
+ const decision = await router.upgrade({
265
+ url,
266
+ headers: fromBridgeHeaders(payload.headers),
267
+ ...(payload.remoteAddress ? { remoteAddress: payload.remoteAddress } : {}),
268
+ ...(principal ? { principal } : {}),
269
+ }).catch(() => ({ accepted: false, status: 404 }));
270
+ if (!decision.accepted) {
271
+ sockets.delete(id);
272
+ if (!socket.closed)
273
+ send({ type: 'ws.rejected', id, payload: { status: decision.status } });
274
+ return;
275
+ }
276
+ if (socket.closed || closed) {
277
+ sockets.delete(id);
278
+ decision.reject();
279
+ if (!socket.closed)
280
+ send({ type: 'ws.rejected', id, payload: { status: 503 } });
281
+ return;
282
+ }
283
+ send({ type: 'ws.accepted', id, payload: {} });
284
+ const transport = {
285
+ send(data) {
286
+ if (socket.closed)
287
+ return;
288
+ const size = wsMessageSize(data);
289
+ if (size > limits.maxWsMessageBytes) {
290
+ socket.finish(1009, 'Message too big', true);
291
+ return;
292
+ }
293
+ if (socket.buffered + size > limits.maxWsBufferedBytes) {
294
+ socket.finish(1013, 'Bridge backpressure', true);
295
+ return;
296
+ }
297
+ socket.buffered += size;
298
+ send({ type: 'ws.message', id, payload: { data: typeof data === 'string' ? data : ownBytes(data) } });
299
+ },
300
+ close(code = 1000, reason = '') {
301
+ socket.finish(code, reason, true);
302
+ },
303
+ onMessage(listener) {
304
+ socket.messageListeners.add(listener);
305
+ return () => { socket.messageListeners.delete(listener); };
306
+ },
307
+ onClose(listener) {
308
+ socket.closeListeners.add(listener);
309
+ return () => { socket.closeListeners.delete(listener); };
310
+ },
311
+ get bufferedAmount() { return socket.buffered; },
312
+ };
313
+ decision.accept(transport);
314
+ }
315
+ function createSocket(id) {
316
+ const socket = {
317
+ messageListeners: new Set(),
318
+ closeListeners: new Set(),
319
+ buffered: 0,
320
+ closed: false,
321
+ finish(code, reason, notify) {
322
+ if (socket.closed)
323
+ return;
324
+ socket.closed = true;
325
+ if (sockets.get(id) === socket)
326
+ sockets.delete(id);
327
+ if (notify)
328
+ send({ type: 'ws.close', id, payload: { code: sendableCloseCode(code), reason: reason.slice(0, 123) } });
329
+ for (const listener of [...socket.closeListeners]) {
330
+ try {
331
+ listener(code, reason);
332
+ }
333
+ catch { /* listener failure stays local */ }
334
+ }
335
+ socket.closeListeners.clear();
336
+ socket.messageListeners.clear();
337
+ },
338
+ };
339
+ return socket;
340
+ }
341
+ function deliverWsMessage(id, data) {
342
+ const socket = sockets.get(id);
343
+ if (!socket || socket.closed)
344
+ return;
345
+ for (const listener of [...socket.messageListeners]) {
346
+ try {
347
+ listener(data);
348
+ }
349
+ catch (error) {
350
+ logger.warn(formatCompact({ op: 'ws_message_listener_failed', error: errorText(error) }));
351
+ }
352
+ }
353
+ send({ type: 'ws.drain', id, payload: { bytes: Math.max(1, wsMessageSize(data)) } });
354
+ }
355
+ // ---- channel
356
+ const onMessage = (message) => {
357
+ switch (message.type) {
358
+ case 'host.addresses':
359
+ addresses = Object.freeze(message.payload.addresses.map((address) => Object.freeze({ ...address })));
360
+ return;
361
+ case 'http.request':
362
+ void handleRequest(message);
363
+ return;
364
+ case 'principal-revoked':
365
+ revokePrincipal(message.payload.principalId);
366
+ return;
367
+ case 'http.request.body': {
368
+ const exchange = exchanges.get(message.id);
369
+ if (exchange?.body && !exchange.body.push(message.payload.chunk)) {
370
+ abortExchange(message.id, 'Request body exceeded the granted credit', true);
371
+ }
372
+ return;
373
+ }
374
+ case 'http.request.end':
375
+ exchanges.get(message.id)?.body?.end();
376
+ return;
377
+ case 'http.request.abort':
378
+ abortExchange(message.id, message.payload.message, false);
379
+ return;
380
+ case 'http.response.credit':
381
+ exchanges.get(message.id)?.gate.add(message.payload.bytes);
382
+ return;
383
+ case 'ws.open':
384
+ void handleWsOpen(message);
385
+ return;
386
+ case 'ws.message':
387
+ deliverWsMessage(message.id, message.payload.data);
388
+ return;
389
+ case 'ws.close':
390
+ sockets.get(message.id)?.finish(message.payload.code, message.payload.reason, false);
391
+ return;
392
+ case 'ws.drain': {
393
+ const socket = sockets.get(message.id);
394
+ if (socket)
395
+ socket.buffered = Math.max(0, socket.buffered - message.payload.bytes);
396
+ return;
397
+ }
398
+ default:
399
+ return;
400
+ }
401
+ };
402
+ const onDisconnect = () => {
403
+ for (const id of [...exchanges.keys()])
404
+ abortExchange(id, 'Gateway disconnected', false);
405
+ for (const socket of [...sockets.values()])
406
+ socket.finish(1006, 'Gateway disconnected', false);
407
+ };
408
+ const host = {
409
+ [generationAdmissionBinder]: (admission) => router[generationAdmissionBinder](admission),
410
+ [httpHostAddressProvider]: () => addresses,
411
+ ws: (pathname, wsOptions) => router.ws(pathname, wsOptions),
412
+ route: (method, path, handler, meta) => router.route(method, path, handler, meta),
413
+ listRoutes: () => router.listRoutes(),
414
+ get address() { return addresses[0]; },
415
+ get tokenRegistry() { return router.tokenRegistry; },
416
+ get activeStreams() { return exchanges.size + sockets.size; },
417
+ routeTable,
418
+ publishRoutes(next) {
419
+ if (next !== undefined)
420
+ generation = next;
421
+ sendRoutes(true);
422
+ },
423
+ onPrincipalRevoked(listener) {
424
+ revokedListeners.add(listener);
425
+ return () => { revokedListeners.delete(listener); };
426
+ },
427
+ async listen() {
428
+ if (closed)
429
+ throw new Error('HTTP Host is closed');
430
+ if (!listening) {
431
+ listening = true;
432
+ disposers.push(channel.onMessage(onMessage), channel.onDisconnect(onDisconnect), router.onRoutesChanged(schedulePublish));
433
+ // Like a listening server, a listening bridge keeps the instance alive until close().
434
+ if (channel.hold)
435
+ disposers.push(channel.hold());
436
+ sendCatalog(true);
437
+ sendRoutes(true);
438
+ logger.debug(formatCompact({ op: 'bridge_listening', routes: router.routeCount, ws: router.wsPaths.length }));
439
+ }
440
+ return addresses[0];
441
+ },
442
+ close() {
443
+ closeResult ??= (async () => {
444
+ const wasListening = listening;
445
+ closed = true;
446
+ for (const id of [...exchanges.keys()])
447
+ abortExchange(id, 'Instance stopping', wasListening);
448
+ for (const socket of [...sockets.values()])
449
+ socket.finish(1001, 'Instance stopping', wasListening);
450
+ if (wasListening)
451
+ sendRoutes(true);
452
+ listening = false;
453
+ router.close();
454
+ for (const dispose of disposers.splice(0))
455
+ dispose();
456
+ })();
457
+ return closeResult;
458
+ },
459
+ };
460
+ return host;
461
+ }
462
+ /** Route meta that survives structured clone (JSON data only); otherwise dropped. */
463
+ function cloneableMeta(meta) {
464
+ if (!meta)
465
+ return undefined;
466
+ try {
467
+ return JSON.parse(JSON.stringify(meta));
468
+ }
469
+ catch {
470
+ return undefined;
471
+ }
472
+ }
473
+ /** User id of a forwarded user / API-token principal (what `principal-revoked` names). */
474
+ function userIdOf(principal) {
475
+ return principal && principal.kind !== 'bootstrap' ? principal.userId : undefined;
476
+ }
477
+ /** An audit entry that survives structured clone (JSON detail only). */
478
+ function cloneableAudit(entry) {
479
+ try {
480
+ return JSON.parse(JSON.stringify(entry));
481
+ }
482
+ catch {
483
+ const { detail: _detail, ...rest } = entry;
484
+ return rest;
485
+ }
486
+ }
487
+ /** Close codes a peer may put on the wire; reserved ones (1005, 1006, 1015) become 1000. */
488
+ export function sendableCloseCode(code) {
489
+ if (!Number.isInteger(code))
490
+ return 1000;
491
+ if ((code >= 1000 && code <= 1003) || (code >= 1007 && code <= 1014) || (code >= 3000 && code <= 4999))
492
+ return code;
493
+ return 1000;
494
+ }
495
+ function errorText(error) {
496
+ return error instanceof Error ? error.message : String(error);
497
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Instance side of the gateway control channel (ADR 0054 §3).
3
+ *
4
+ * Wraps the child-process IPC port (`process.send` / `process.on('message')`). The channel is
5
+ * unref'd so an idle IPC pipe never keeps a stopped instance alive.
6
+ */
7
+ import { type BridgeRouteTable, type GatewayShutdownPayload, type GatewayToInstanceMessage, type InstanceToGatewayMessage } from './protocol.js';
8
+ /** The subset of `process` the control channel needs; tests pass a fake. */
9
+ export interface InstanceIpcPort {
10
+ send?(message: unknown, callback?: (error: Error | null) => void): boolean;
11
+ on(event: 'message', listener: (message: unknown) => void): unknown;
12
+ on(event: 'disconnect', listener: () => void): unknown;
13
+ off(event: 'message', listener: (message: unknown) => void): unknown;
14
+ off(event: 'disconnect', listener: () => void): unknown;
15
+ readonly connected?: boolean;
16
+ readonly channel?: {
17
+ unref?(): void;
18
+ ref?(): void;
19
+ } | null;
20
+ }
21
+ export interface InstanceReadyDetails {
22
+ readonly generation?: number;
23
+ /** Route table at ready time (ADR 0054 §3 `ready { routes }`). */
24
+ readonly routes?: BridgeRouteTable;
25
+ }
26
+ export interface InstanceControlChannel {
27
+ /** Announces that startup completed (carries the protocol version). */
28
+ ready(details?: InstanceReadyDetails): void;
29
+ /** Asks the gateway for a whole-process restart; replaces the former exit code 75. */
30
+ requestRestart(reason: string): void;
31
+ onShutdown(listener: (payload: GatewayShutdownPayload) => void): () => void;
32
+ /** Every validated gateway message (bridge streams, addresses, shutdown). */
33
+ onMessage(listener: (message: GatewayToInstanceMessage) => void): () => void;
34
+ /** Sends one bridge message; dropped once the channel is gone. */
35
+ send(message: InstanceToGatewayMessage): void;
36
+ /**
37
+ * Keeps the process alive through the channel (as a listening server would) until the
38
+ * returned release runs. Holds are counted; the channel is unref'd again at zero.
39
+ */
40
+ hold(): () => void;
41
+ /** Protocol version the gateway announced through {@link INSTANCE_CHILD_ENV}, when numeric. */
42
+ readonly gatewayVersion?: number;
43
+ /** The gateway went away (the IPC channel closed). */
44
+ onDisconnect(listener: () => void): () => void;
45
+ readonly connected: boolean;
46
+ close(): void;
47
+ }
48
+ /**
49
+ * Opens the control channel when the gateway started this process: the IPC channel must be
50
+ * present and {@link INSTANCE_CHILD_ENV} set. Returns `undefined` otherwise (not supervised).
51
+ *
52
+ * The marker matters: other parents also open an IPC channel (test runners' fork workers,
53
+ * process managers). Treating their channel as the gateway would send control messages to
54
+ * them and exit the process on a restart request.
55
+ *
56
+ * Throws {@link BridgeProtocolMismatchError} when the marker names a protocol version this build
57
+ * cannot speak: the instance must not start against an incompatible gateway.
58
+ */
59
+ export declare function openInstanceControlChannel(port?: InstanceIpcPort, pid?: number, environment?: Readonly<Record<string, string | undefined>>): InstanceControlChannel | undefined;
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Instance side of the gateway control channel (ADR 0054 §3).
3
+ *
4
+ * Wraps the child-process IPC port (`process.send` / `process.on('message')`). The channel is
5
+ * unref'd so an idle IPC pipe never keeps a stopped instance alive.
6
+ */
7
+ import { BridgeProtocolMismatchError, INSTANCE_BRIDGE_PROTOCOL_VERSION, INSTANCE_CHILD_ENV, isBridgeProtocolCompatible, parseGatewayMessage, } from './protocol.js';
8
+ /**
9
+ * Opens the control channel when the gateway started this process: the IPC channel must be
10
+ * present and {@link INSTANCE_CHILD_ENV} set. Returns `undefined` otherwise (not supervised).
11
+ *
12
+ * The marker matters: other parents also open an IPC channel (test runners' fork workers,
13
+ * process managers). Treating their channel as the gateway would send control messages to
14
+ * them and exit the process on a restart request.
15
+ *
16
+ * Throws {@link BridgeProtocolMismatchError} when the marker names a protocol version this build
17
+ * cannot speak: the instance must not start against an incompatible gateway.
18
+ */
19
+ export function openInstanceControlChannel(port = process, pid = process.pid, environment = process.env) {
20
+ const marker = environment[INSTANCE_CHILD_ENV];
21
+ if (!marker)
22
+ return undefined;
23
+ if (typeof port.send !== 'function' || port.connected === false)
24
+ return undefined;
25
+ const gatewayVersion = /^\d+$/u.test(marker) ? Number(marker) : undefined;
26
+ if (gatewayVersion !== undefined && !isBridgeProtocolCompatible(gatewayVersion)) {
27
+ throw new BridgeProtocolMismatchError(gatewayVersion, 'gateway');
28
+ }
29
+ port.channel?.unref?.();
30
+ const shutdownListeners = new Set();
31
+ const messageListeners = new Set();
32
+ const disconnectListeners = new Set();
33
+ let connected = true;
34
+ let closed = false;
35
+ let holds = 0;
36
+ const releaseHold = () => {
37
+ holds -= 1;
38
+ if (holds === 0)
39
+ port.channel?.unref?.();
40
+ };
41
+ const onMessage = (raw) => {
42
+ const message = parseGatewayMessage(raw);
43
+ if (!message)
44
+ return;
45
+ for (const listener of [...messageListeners]) {
46
+ try {
47
+ listener(message);
48
+ }
49
+ catch { /* one consumer never breaks the channel */ }
50
+ }
51
+ if (message.type !== 'shutdown')
52
+ return;
53
+ for (const listener of [...shutdownListeners])
54
+ listener(message.payload);
55
+ };
56
+ const onDisconnect = () => {
57
+ connected = false;
58
+ for (const listener of [...disconnectListeners])
59
+ listener();
60
+ };
61
+ port.on('message', onMessage);
62
+ port.on('disconnect', onDisconnect);
63
+ const send = (message) => {
64
+ if (!connected || closed)
65
+ return;
66
+ try {
67
+ // The callback swallows EPIPE when the gateway is already gone.
68
+ port.send?.(message, () => undefined);
69
+ }
70
+ catch { /* channel closed */ }
71
+ };
72
+ return {
73
+ ready(details = {}) {
74
+ send({
75
+ type: 'ready',
76
+ payload: {
77
+ version: INSTANCE_BRIDGE_PROTOCOL_VERSION,
78
+ pid,
79
+ ...(details.generation !== undefined ? { generation: details.generation } : {}),
80
+ ...(details.routes ? { routes: details.routes } : {}),
81
+ },
82
+ });
83
+ },
84
+ send,
85
+ hold() {
86
+ if (closed)
87
+ return () => undefined;
88
+ holds += 1;
89
+ if (holds === 1)
90
+ port.channel?.ref?.();
91
+ let released = false;
92
+ return () => {
93
+ if (released)
94
+ return;
95
+ released = true;
96
+ releaseHold();
97
+ };
98
+ },
99
+ onMessage(listener) {
100
+ messageListeners.add(listener);
101
+ return () => { messageListeners.delete(listener); };
102
+ },
103
+ ...(gatewayVersion !== undefined ? { gatewayVersion } : {}),
104
+ requestRestart(reason) {
105
+ send({ type: 'restart-request', payload: { reason } });
106
+ },
107
+ onShutdown(listener) {
108
+ shutdownListeners.add(listener);
109
+ return () => { shutdownListeners.delete(listener); };
110
+ },
111
+ onDisconnect(listener) {
112
+ disconnectListeners.add(listener);
113
+ return () => { disconnectListeners.delete(listener); };
114
+ },
115
+ get connected() { return connected && !closed; },
116
+ close() {
117
+ if (closed)
118
+ return;
119
+ closed = true;
120
+ if (holds > 0) {
121
+ holds = 0;
122
+ port.channel?.unref?.();
123
+ }
124
+ port.off('message', onMessage);
125
+ port.off('disconnect', onDisconnect);
126
+ shutdownListeners.clear();
127
+ messageListeners.clear();
128
+ disconnectListeners.clear();
129
+ },
130
+ };
131
+ }