@zhin.js/host-http 1.1.17 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +5 -1
  21. package/lib/console-plugin-rpc.js +5 -40
  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 -14
  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 +10 -5
  37. package/lib/index.js +9 -5
  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 +5 -51
  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 -19
  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 +55 -13
  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,548 @@
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 {
19
+ generationAdmissionBinder,
20
+ type GenerationAdmissionBindable,
21
+ } from '@zhin.js/plugin-runtime';
22
+ import {
23
+ createHttpRouter,
24
+ type HttpHost,
25
+ type HttpRouter,
26
+ type HttpRouterOptions,
27
+ type WsTransport,
28
+ } from '../http-host.js';
29
+ import type { ConsoleAccessProvider, ConsoleAuditEntry, ConsolePrincipal } from '../access-control.js';
30
+ import { TokenRegistry } from '../token-registry.js';
31
+ import type { HttpHostAddress } from '../host-options.js';
32
+ import { httpHostAddressProvider, type AddressAwareHttpHost } from '../addresses.js';
33
+ import type { RouteMeta } from '../openapi.js';
34
+ import { CreditGate, InboundBody, ownBytes, pumpBody } from './flow.js';
35
+ import {
36
+ DEFAULT_BRIDGE_LIMITS,
37
+ fromBridgeHeaders,
38
+ parseBridgePrincipal,
39
+ toBridgeHeaders,
40
+ wsMessageSize,
41
+ type BridgeLimits,
42
+ type BridgeRouteTable,
43
+ type GatewayToInstanceMessage,
44
+ type HttpRequestMessage,
45
+ type InstanceToGatewayMessage,
46
+ type WsOpenMessage,
47
+ } from './protocol.js';
48
+
49
+ const logger = getLogger('http');
50
+ const BODYLESS_METHODS = new Set(['GET', 'HEAD']);
51
+ /** Close code / reason used when the gateway revoked the socket's principal. */
52
+ export const PRINCIPAL_REVOKED_CLOSE_CODE = 4401;
53
+
54
+ /** What the bridged Host needs from the control channel (see `openInstanceControlChannel`). */
55
+ export interface BridgeChannel {
56
+ send(message: InstanceToGatewayMessage): void;
57
+ onMessage(listener: (message: GatewayToInstanceMessage) => void): () => void;
58
+ onDisconnect(listener: () => void): () => void;
59
+ /** Keeps the process alive while the Host listens (see `InstanceControlChannel.hold`). */
60
+ hold?(): () => void;
61
+ }
62
+
63
+ export interface BridgedHttpHostOptions extends Omit<HttpRouterOptions, 'address' | 'authentication' | 'secure' | 'token' | 'tokenRegistry'> {
64
+ readonly channel: BridgeChannel;
65
+ readonly limits?: Partial<BridgeLimits>;
66
+ }
67
+
68
+ /** @public Instance HTTP Host carried by the gateway bridge. */
69
+ export interface BridgedHttpHost extends HttpHost, GenerationAdmissionBindable<HttpHost>, AddressAwareHttpHost {
70
+ /**
71
+ * Opens the bridge: announces the route table and starts serving forwarded requests.
72
+ * Resolves with the gateway's primary address once the gateway announced it (else undefined).
73
+ */
74
+ listen(): Promise<HttpHostAddress | undefined>;
75
+ /** Ends every exchange and socket, withdraws the route table and drops all routes. */
76
+ close(): Promise<void>;
77
+ /** Route table as the gateway sees it. */
78
+ routeTable(): BridgeRouteTable;
79
+ /** Announces the table again, tagged with `generation` (call on generation commit). */
80
+ publishRoutes(generation?: number): void;
81
+ /** Open HTTP exchanges plus WebSocket connections. */
82
+ readonly activeStreams: number;
83
+ /**
84
+ * Called when the gateway revoked a principal (after its exchanges and sockets were closed),
85
+ * for state the bridge does not see (e.g. sessions keyed by user). Returns an unsubscriber.
86
+ */
87
+ onPrincipalRevoked(listener: (principalId: string) => void): () => void;
88
+ }
89
+
90
+ interface Exchange {
91
+ readonly abort: AbortController;
92
+ readonly gate: CreditGate;
93
+ body?: InboundBody;
94
+ /** User id of the forwarded principal (closed on `principal-revoked`). */
95
+ readonly principalId?: string;
96
+ }
97
+
98
+ interface BridgedSocket {
99
+ principalId?: string;
100
+ readonly messageListeners: Set<(data: string | Uint8Array) => void>;
101
+ readonly closeListeners: Set<(code: number, reason: string) => void>;
102
+ buffered: number;
103
+ closed: boolean;
104
+ /** Ends the socket locally and fires close listeners; `notify` also tells the gateway. */
105
+ finish(code: number, reason: string, notify: boolean): void;
106
+ }
107
+
108
+ export function createBridgedHttpHost(options: BridgedHttpHostOptions): BridgedHttpHost {
109
+ const limits: BridgeLimits = Object.freeze({ ...DEFAULT_BRIDGE_LIMITS, ...options.limits });
110
+ const { channel } = options;
111
+ let addresses: readonly HttpHostAddress[] = Object.freeze([]);
112
+ const tokenRegistry = new TokenRegistry();
113
+ const router: HttpRouter = createHttpRouter({
114
+ ...options,
115
+ tokenRegistry,
116
+ authentication: 'forwarded',
117
+ address: () => addresses[0],
118
+ secure: false,
119
+ });
120
+ const exchanges = new Map<string, Exchange>();
121
+ const sockets = new Map<string, BridgedSocket>();
122
+ let listening = false;
123
+ let closed = false;
124
+ let revision = 0;
125
+ let generation: number | undefined;
126
+ let lastSent: string | undefined;
127
+ let publishScheduled = false;
128
+ let closeResult: Promise<void> | undefined;
129
+ const disposers: Array<() => void> = [];
130
+ let lastCatalog: string | undefined;
131
+ const revokedListeners = new Set<(principalId: string) => void>();
132
+
133
+ const send = (message: InstanceToGatewayMessage): void => {
134
+ try { channel.send(message); } catch { /* channel gone */ }
135
+ };
136
+
137
+ // Closed registry: initialized (no open Host, no bootstrap token), never resolves a bearer;
138
+ // audit entries are written by the gateway.
139
+ const forwardedAccess: ConsoleAccessProvider = Object.freeze({
140
+ initialized: () => true,
141
+ authenticate: async () => null,
142
+ audit: (entry: ConsoleAuditEntry) => {
143
+ if (listening && !closed) send({ type: 'audit', payload: { entry: cloneableAudit(entry) } });
144
+ },
145
+ });
146
+ tokenRegistry.useAccessProvider(forwardedAccess);
147
+
148
+ /** Reports the plugin permission catalog when it changed (or always with `force`). */
149
+ function sendCatalog(force: boolean): void {
150
+ if (!listening || closed) return;
151
+ let pluginPermissions;
152
+ try {
153
+ pluginPermissions = JSON.parse(JSON.stringify(tokenRegistry.pluginPermissions())) as ReturnType<TokenRegistry['pluginPermissions']>;
154
+ } catch {
155
+ return;
156
+ }
157
+ const fingerprint = JSON.stringify(pluginPermissions);
158
+ if (!force && fingerprint === lastCatalog) return;
159
+ lastCatalog = fingerprint;
160
+ send({ type: 'catalog', payload: { pluginPermissions } });
161
+ }
162
+
163
+ function revokePrincipal(principalId: string): void {
164
+ for (const [id, exchange] of [...exchanges]) {
165
+ if (exchange.principalId === principalId) abortExchange(id, 'Credentials revoked', true);
166
+ }
167
+ for (const socket of [...sockets.values()]) {
168
+ if (socket.principalId === principalId) socket.finish(PRINCIPAL_REVOKED_CLOSE_CODE, 'Credentials revoked', true);
169
+ }
170
+ for (const listener of [...revokedListeners]) {
171
+ try { listener(principalId); } catch { /* listener failure stays local */ }
172
+ }
173
+ }
174
+
175
+ function routeTable(): BridgeRouteTable {
176
+ if (closed) {
177
+ return Object.freeze({ revision, ...(generation !== undefined ? { generation } : {}), apiBase: router.apiBase, http: Object.freeze([]), ws: Object.freeze([]) });
178
+ }
179
+ return Object.freeze({
180
+ revision,
181
+ ...(generation !== undefined ? { generation } : {}),
182
+ apiBase: router.apiBase,
183
+ http: Object.freeze(router.listRoutes().map((route) => {
184
+ const meta = cloneableMeta(route.meta);
185
+ return Object.freeze({ method: route.method, pattern: route.pattern, ...(meta ? { meta } : {}) });
186
+ })),
187
+ ws: router.listWsRoutes(),
188
+ });
189
+ }
190
+
191
+ function sendRoutes(force: boolean): void {
192
+ if (!listening) return;
193
+ const table = routeTable();
194
+ const fingerprint = JSON.stringify([table.generation, table.http, table.ws]);
195
+ if (!force && fingerprint === lastSent) return;
196
+ lastSent = fingerprint;
197
+ revision += 1;
198
+ // The catalog follows the generation: report it before the table that serves it.
199
+ sendCatalog(false);
200
+ send({ type: 'routes', payload: { table: { ...table, revision } } });
201
+ }
202
+
203
+ // Registration bursts (a generation registering dozens of routes) coalesce into one table.
204
+ const schedulePublish = (): void => {
205
+ if (!listening || publishScheduled) return;
206
+ publishScheduled = true;
207
+ queueMicrotask(() => {
208
+ publishScheduled = false;
209
+ sendRoutes(false);
210
+ });
211
+ };
212
+
213
+ // ---- HTTP
214
+
215
+ async function handleRequest(message: HttpRequestMessage): Promise<void> {
216
+ const { id, payload } = message;
217
+ if (exchanges.has(id) || sockets.has(id)) return;
218
+ if (!listening || closed || exchanges.size + sockets.size >= limits.maxStreams) {
219
+ send({ type: 'http.response.head', id, payload: { status: 503, headers: [], body: false } });
220
+ send({ type: 'http.response.end', id, payload: {} });
221
+ return;
222
+ }
223
+ const principal = payload.principal === undefined ? undefined : parseBridgePrincipal(payload.principal);
224
+ const principalId = userIdOf(principal);
225
+ const exchange: Exchange = {
226
+ abort: new AbortController(),
227
+ gate: new CreditGate(limits.initialCreditBytes),
228
+ ...(principalId ? { principalId } : {}),
229
+ };
230
+ exchanges.set(id, exchange);
231
+ const method = payload.method.toUpperCase();
232
+ if (payload.body && !BODYLESS_METHODS.has(method)) {
233
+ exchange.body = new InboundBody({
234
+ window: limits.initialCreditBytes,
235
+ grant: (bytes) => { if (exchanges.get(id) === exchange) send({ type: 'http.request.credit', id, payload: { bytes } }); },
236
+ // The gateway stops sending once the response ends; nothing to tell it now.
237
+ cancel: () => undefined,
238
+ });
239
+ }
240
+ try {
241
+ let request: Request;
242
+ try {
243
+ request = new Request(payload.url, {
244
+ method,
245
+ headers: fromBridgeHeaders(payload.headers),
246
+ signal: exchange.abort.signal,
247
+ ...(exchange.body ? { body: exchange.body.stream, duplex: 'half' } : {}),
248
+ } as RequestInit);
249
+ } catch {
250
+ sendHead(id, new Response(null, { status: 400 }));
251
+ send({ type: 'http.response.end', id, payload: {} });
252
+ return;
253
+ }
254
+ const response = await router.handle(request, {
255
+ ...(payload.remoteAddress ? { remoteAddress: payload.remoteAddress } : {}),
256
+ ...(principal ? { principal } : {}),
257
+ });
258
+ if (exchange.abort.signal.aborted) {
259
+ await response.body?.cancel(exchange.abort.signal.reason).catch(() => undefined);
260
+ return;
261
+ }
262
+ sendHead(id, response);
263
+ if (response.body) {
264
+ await pumpBody(response.body, {
265
+ gate: exchange.gate,
266
+ maxChunkBytes: limits.maxChunkBytes,
267
+ signal: exchange.abort.signal,
268
+ send: (chunk) => send({ type: 'http.response.body', id, payload: { chunk } }),
269
+ });
270
+ }
271
+ if (!exchange.abort.signal.aborted) send({ type: 'http.response.end', id, payload: {} });
272
+ } catch (error) {
273
+ if (!exchange.abort.signal.aborted) {
274
+ logger.debug(formatCompact({ op: 'bridge_response_failed', error: errorText(error) }));
275
+ send({ type: 'http.response.error', id, payload: { message: errorText(error).slice(0, 512) } });
276
+ }
277
+ } finally {
278
+ if (exchanges.get(id) === exchange) exchanges.delete(id);
279
+ exchange.gate.close();
280
+ exchange.body?.fail(new Error('Exchange finished'));
281
+ }
282
+ }
283
+
284
+ function sendHead(id: string, response: Response): void {
285
+ send({
286
+ type: 'http.response.head',
287
+ id,
288
+ payload: {
289
+ status: response.status,
290
+ ...(response.statusText ? { statusText: response.statusText.slice(0, 256) } : {}),
291
+ headers: toBridgeHeaders(response.headers),
292
+ body: response.body !== null,
293
+ },
294
+ });
295
+ }
296
+
297
+ function abortExchange(id: string, reason: string, notify: boolean): void {
298
+ const exchange = exchanges.get(id);
299
+ if (!exchange) return;
300
+ exchanges.delete(id);
301
+ const error = new Error(reason);
302
+ exchange.abort.abort(error);
303
+ exchange.gate.close(error);
304
+ exchange.body?.fail(error);
305
+ if (notify) send({ type: 'http.response.error', id, payload: { message: reason } });
306
+ }
307
+
308
+ // ---- WebSocket
309
+
310
+ async function handleWsOpen(message: WsOpenMessage): Promise<void> {
311
+ const { id, payload } = message;
312
+ if (exchanges.has(id) || sockets.has(id)) return;
313
+ if (!listening || closed || exchanges.size + sockets.size >= limits.maxStreams) {
314
+ send({ type: 'ws.rejected', id, payload: { status: 503 } });
315
+ return;
316
+ }
317
+ let url: URL;
318
+ try {
319
+ url = new URL(payload.url);
320
+ } catch {
321
+ send({ type: 'ws.rejected', id, payload: { status: 404 } });
322
+ return;
323
+ }
324
+ const principal = payload.principal === undefined ? undefined : parseBridgePrincipal(payload.principal);
325
+ // Reserve the id while the upgrade is decided, so a racing ws.close is not lost.
326
+ const socket = createSocket(id);
327
+ const principalId = userIdOf(principal);
328
+ if (principalId) socket.principalId = principalId;
329
+ sockets.set(id, socket);
330
+ const decision = await router.upgrade({
331
+ url,
332
+ headers: fromBridgeHeaders(payload.headers),
333
+ ...(payload.remoteAddress ? { remoteAddress: payload.remoteAddress } : {}),
334
+ ...(principal ? { principal } : {}),
335
+ }).catch(() => ({ accepted: false as const, status: 404 as const }));
336
+ if (!decision.accepted) {
337
+ sockets.delete(id);
338
+ if (!socket.closed) send({ type: 'ws.rejected', id, payload: { status: decision.status } });
339
+ return;
340
+ }
341
+ if (socket.closed || closed) {
342
+ sockets.delete(id);
343
+ decision.reject();
344
+ if (!socket.closed) send({ type: 'ws.rejected', id, payload: { status: 503 } });
345
+ return;
346
+ }
347
+ send({ type: 'ws.accepted', id, payload: {} });
348
+ const transport: WsTransport = {
349
+ send(data) {
350
+ if (socket.closed) return;
351
+ const size = wsMessageSize(data);
352
+ if (size > limits.maxWsMessageBytes) {
353
+ socket.finish(1009, 'Message too big', true);
354
+ return;
355
+ }
356
+ if (socket.buffered + size > limits.maxWsBufferedBytes) {
357
+ socket.finish(1013, 'Bridge backpressure', true);
358
+ return;
359
+ }
360
+ socket.buffered += size;
361
+ send({ type: 'ws.message', id, payload: { data: typeof data === 'string' ? data : ownBytes(data) } });
362
+ },
363
+ close(code = 1000, reason = '') {
364
+ socket.finish(code, reason, true);
365
+ },
366
+ onMessage(listener) {
367
+ socket.messageListeners.add(listener);
368
+ return () => { socket.messageListeners.delete(listener); };
369
+ },
370
+ onClose(listener) {
371
+ socket.closeListeners.add(listener);
372
+ return () => { socket.closeListeners.delete(listener); };
373
+ },
374
+ get bufferedAmount() { return socket.buffered; },
375
+ };
376
+ decision.accept(transport);
377
+ }
378
+
379
+ function createSocket(id: string): BridgedSocket {
380
+ const socket: BridgedSocket = {
381
+ messageListeners: new Set(),
382
+ closeListeners: new Set(),
383
+ buffered: 0,
384
+ closed: false,
385
+ finish(code, reason, notify) {
386
+ if (socket.closed) return;
387
+ socket.closed = true;
388
+ if (sockets.get(id) === socket) sockets.delete(id);
389
+ if (notify) send({ type: 'ws.close', id, payload: { code: sendableCloseCode(code), reason: reason.slice(0, 123) } });
390
+ for (const listener of [...socket.closeListeners]) {
391
+ try { listener(code, reason); } catch { /* listener failure stays local */ }
392
+ }
393
+ socket.closeListeners.clear();
394
+ socket.messageListeners.clear();
395
+ },
396
+ };
397
+ return socket;
398
+ }
399
+
400
+ function deliverWsMessage(id: string, data: string | Uint8Array): void {
401
+ const socket = sockets.get(id);
402
+ if (!socket || socket.closed) return;
403
+ for (const listener of [...socket.messageListeners]) {
404
+ try {
405
+ listener(data);
406
+ } catch (error) {
407
+ logger.warn(formatCompact({ op: 'ws_message_listener_failed', error: errorText(error) }));
408
+ }
409
+ }
410
+ send({ type: 'ws.drain', id, payload: { bytes: Math.max(1, wsMessageSize(data)) } });
411
+ }
412
+
413
+ // ---- channel
414
+
415
+ const onMessage = (message: GatewayToInstanceMessage): void => {
416
+ switch (message.type) {
417
+ case 'host.addresses':
418
+ addresses = Object.freeze(message.payload.addresses.map((address) => Object.freeze({ ...address })));
419
+ return;
420
+ case 'http.request':
421
+ void handleRequest(message);
422
+ return;
423
+ case 'principal-revoked':
424
+ revokePrincipal(message.payload.principalId);
425
+ return;
426
+ case 'http.request.body': {
427
+ const exchange = exchanges.get(message.id);
428
+ if (exchange?.body && !exchange.body.push(message.payload.chunk)) {
429
+ abortExchange(message.id, 'Request body exceeded the granted credit', true);
430
+ }
431
+ return;
432
+ }
433
+ case 'http.request.end':
434
+ exchanges.get(message.id)?.body?.end();
435
+ return;
436
+ case 'http.request.abort':
437
+ abortExchange(message.id, message.payload.message, false);
438
+ return;
439
+ case 'http.response.credit':
440
+ exchanges.get(message.id)?.gate.add(message.payload.bytes);
441
+ return;
442
+ case 'ws.open':
443
+ void handleWsOpen(message);
444
+ return;
445
+ case 'ws.message':
446
+ deliverWsMessage(message.id, message.payload.data);
447
+ return;
448
+ case 'ws.close':
449
+ sockets.get(message.id)?.finish(message.payload.code, message.payload.reason, false);
450
+ return;
451
+ case 'ws.drain': {
452
+ const socket = sockets.get(message.id);
453
+ if (socket) socket.buffered = Math.max(0, socket.buffered - message.payload.bytes);
454
+ return;
455
+ }
456
+ default:
457
+ return;
458
+ }
459
+ };
460
+
461
+ const onDisconnect = (): void => {
462
+ for (const id of [...exchanges.keys()]) abortExchange(id, 'Gateway disconnected', false);
463
+ for (const socket of [...sockets.values()]) socket.finish(1006, 'Gateway disconnected', false);
464
+ };
465
+
466
+ const host: BridgedHttpHost = {
467
+ [generationAdmissionBinder]: (admission) => router[generationAdmissionBinder](admission),
468
+ [httpHostAddressProvider]: () => addresses,
469
+ ws: (pathname, wsOptions) => router.ws(pathname, wsOptions),
470
+ route: (method, path, handler, meta) => router.route(method, path, handler, meta),
471
+ listRoutes: () => router.listRoutes(),
472
+ get address() { return addresses[0]; },
473
+ get tokenRegistry() { return router.tokenRegistry; },
474
+ get activeStreams() { return exchanges.size + sockets.size; },
475
+ routeTable,
476
+ publishRoutes(next) {
477
+ if (next !== undefined) generation = next;
478
+ sendRoutes(true);
479
+ },
480
+ onPrincipalRevoked(listener) {
481
+ revokedListeners.add(listener);
482
+ return () => { revokedListeners.delete(listener); };
483
+ },
484
+ async listen() {
485
+ if (closed) throw new Error('HTTP Host is closed');
486
+ if (!listening) {
487
+ listening = true;
488
+ disposers.push(channel.onMessage(onMessage), channel.onDisconnect(onDisconnect), router.onRoutesChanged(schedulePublish));
489
+ // Like a listening server, a listening bridge keeps the instance alive until close().
490
+ if (channel.hold) disposers.push(channel.hold());
491
+ sendCatalog(true);
492
+ sendRoutes(true);
493
+ logger.debug(formatCompact({ op: 'bridge_listening', routes: router.routeCount, ws: router.wsPaths.length }));
494
+ }
495
+ return addresses[0];
496
+ },
497
+ close() {
498
+ closeResult ??= (async () => {
499
+ const wasListening = listening;
500
+ closed = true;
501
+ for (const id of [...exchanges.keys()]) abortExchange(id, 'Instance stopping', wasListening);
502
+ for (const socket of [...sockets.values()]) socket.finish(1001, 'Instance stopping', wasListening);
503
+ if (wasListening) sendRoutes(true);
504
+ listening = false;
505
+ router.close();
506
+ for (const dispose of disposers.splice(0)) dispose();
507
+ })();
508
+ return closeResult;
509
+ },
510
+ };
511
+ return host;
512
+ }
513
+
514
+ /** Route meta that survives structured clone (JSON data only); otherwise dropped. */
515
+ function cloneableMeta(meta: RouteMeta | undefined): RouteMeta | undefined {
516
+ if (!meta) return undefined;
517
+ try {
518
+ return JSON.parse(JSON.stringify(meta)) as RouteMeta;
519
+ } catch {
520
+ return undefined;
521
+ }
522
+ }
523
+
524
+ /** User id of a forwarded user / API-token principal (what `principal-revoked` names). */
525
+ function userIdOf(principal: ConsolePrincipal | undefined): string | undefined {
526
+ return principal && principal.kind !== 'bootstrap' ? principal.userId : undefined;
527
+ }
528
+
529
+ /** An audit entry that survives structured clone (JSON detail only). */
530
+ function cloneableAudit(entry: ConsoleAuditEntry): ConsoleAuditEntry {
531
+ try {
532
+ return JSON.parse(JSON.stringify(entry)) as ConsoleAuditEntry;
533
+ } catch {
534
+ const { detail: _detail, ...rest } = entry;
535
+ return rest;
536
+ }
537
+ }
538
+
539
+ /** Close codes a peer may put on the wire; reserved ones (1005, 1006, 1015) become 1000. */
540
+ export function sendableCloseCode(code: number): number {
541
+ if (!Number.isInteger(code)) return 1000;
542
+ if ((code >= 1000 && code <= 1003) || (code >= 1007 && code <= 1014) || (code >= 3000 && code <= 4999)) return code;
543
+ return 1000;
544
+ }
545
+
546
+ function errorText(error: unknown): string {
547
+ return error instanceof Error ? error.message : String(error);
548
+ }