@zhin.js/host-http 1.1.16 → 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,627 @@
1
+ /**
2
+ * Gateway side of the HTTP bridge (ADR 0054 §3): {@link InstanceTransport} is what the gateway's
3
+ * HTTP server codes against; {@link IpcInstanceTransport} implements it over the instance's IPC
4
+ * control channel. The gateway authenticates, then forwards the Web `Request` (with the
5
+ * principal) and gets a streamed `Response` back; WebSockets are bridged frame by frame.
6
+ *
7
+ * Nothing here imports `node:http` or `ws`; the gateway converts its Node server objects to
8
+ * `Request` / `WsConnection` itself.
9
+ */
10
+ import type { ConsolePluginPermissionInfo } from '@zhin.js/console-protocol';
11
+ import type { ConsoleAuditEntry, ConsolePrincipal } from '../access-control.js';
12
+ import type { WsConnection } from '../http-host.js';
13
+ import type { HttpHostAddress } from '../host-options.js';
14
+ import { sendableCloseCode } from './bridged-host.js';
15
+ import { BridgeStreamClosedError, CreditGate, InboundBody, ownBytes, pumpBody } from './flow.js';
16
+ import {
17
+ DEFAULT_BRIDGE_LIMITS,
18
+ DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS,
19
+ DEFAULT_BRIDGE_WS_OPEN_TIMEOUT_MS,
20
+ EMPTY_ROUTE_TABLE,
21
+ fromBridgeHeaders,
22
+ headSize,
23
+ isBridgeProtocolCompatible,
24
+ parseInstanceMessage,
25
+ toBridgeHeaders,
26
+ wsMessageSize,
27
+ type BridgeLimits,
28
+ type BridgeRouteTable,
29
+ type GatewayToInstanceMessage,
30
+ type HttpResponseHeadPayload,
31
+ type InstanceToGatewayMessage,
32
+ } from './protocol.js';
33
+
34
+ /** Per-request data the gateway resolved before forwarding. */
35
+ export interface InstanceRequestContext {
36
+ /**
37
+ * Principal the gateway authenticated. Required for `{apiBase}` routes (else the instance
38
+ * answers 401); for other routes pass it when the caller presented a valid credential.
39
+ */
40
+ readonly principal?: ConsolePrincipal;
41
+ readonly remoteAddress?: string;
42
+ }
43
+
44
+ /** An upgrade request the gateway wants the instance to accept. */
45
+ export interface InstanceWsOpenInfo {
46
+ readonly url: URL;
47
+ readonly headers: Headers;
48
+ /** Required for `auth: 'host'` sockets (see {@link BridgeRouteTable.ws}). */
49
+ readonly principal?: ConsolePrincipal;
50
+ readonly remoteAddress?: string;
51
+ }
52
+
53
+ /**
54
+ * @public What the gateway's HTTP server talks to (B1.3 codes against this).
55
+ *
56
+ * - `ready`: the instance announced its route table and accepts requests; false before that,
57
+ * after the instance exited, or when it speaks an incompatible protocol.
58
+ * - `routes()` / `onRoutes()`: the current table; listeners run on every new table (hot reload,
59
+ * generation switch) and with {@link EMPTY_ROUTE_TABLE} when the instance goes away.
60
+ * - `fetch()`: forwards one request. The request body is streamed with backpressure; aborting
61
+ * `request.signal` cancels the exchange in the instance. Resolves with the instance's
62
+ * `Response` (body streamed with backpressure; cancelling it cancels the instance side).
63
+ * Rejects with {@link InstanceTransportError}: `instance-offline` (not ready / went away
64
+ * before the head), `timeout` (no head in time), `instance-error` (failed before the head),
65
+ * `overloaded` (too many streams), or with `request.signal.reason` when aborted.
66
+ * - `openWebSocket()`: `peer` is the gateway's accepted client socket. Resolves true once the
67
+ * instance accepted (frames then flow both ways, closes propagate); false when it rejected,
68
+ * timed out or is offline: the caller then closes `peer`. Peer frames sent before acceptance
69
+ * are queued.
70
+ */
71
+ export interface InstanceTransport {
72
+ readonly ready: boolean;
73
+ routes(): BridgeRouteTable;
74
+ onRoutes(listener: (table: BridgeRouteTable) => void): () => void;
75
+ fetch(request: Request, context?: InstanceRequestContext): Promise<Response>;
76
+ openWebSocket(info: InstanceWsOpenInfo, peer: WsConnection): Promise<boolean>;
77
+ }
78
+
79
+ export type InstanceTransportErrorCode = 'instance-offline' | 'timeout' | 'instance-error' | 'overloaded';
80
+
81
+ export class InstanceTransportError extends Error {
82
+ constructor(readonly code: InstanceTransportErrorCode, message: string) {
83
+ super(message);
84
+ this.name = 'InstanceTransportError';
85
+ }
86
+ }
87
+
88
+ /** The gateway's end of the IPC channel to the current instance child. */
89
+ export interface GatewayInstancePort {
90
+ send(message: GatewayToInstanceMessage): void;
91
+ }
92
+
93
+ /** The subset of `ChildProcess` {@link IpcInstanceTransport.attachChildProcess} needs. */
94
+ export interface InstanceChildProcessLike {
95
+ send?(message: unknown, callback?: (error: Error | null) => void): boolean;
96
+ on(event: 'message', listener: (message: unknown) => void): unknown;
97
+ on(event: 'exit' | 'disconnect', listener: () => void): unknown;
98
+ off(event: 'message', listener: (message: unknown) => void): unknown;
99
+ off(event: 'exit' | 'disconnect', listener: () => void): unknown;
100
+ }
101
+
102
+ export interface IpcInstanceTransportOptions {
103
+ readonly limits?: Partial<BridgeLimits>;
104
+ /** Time to wait for a response head. Default {@link DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS}. */
105
+ readonly responseTimeoutMs?: number;
106
+ /** Time to wait for `ws.accepted`. Default {@link DEFAULT_BRIDGE_WS_OPEN_TIMEOUT_MS}. */
107
+ readonly wsOpenTimeoutMs?: number;
108
+ /** Client socket backlog below which instance frames are acknowledged. Default 1 MiB. */
109
+ readonly wsLowWaterBytes?: number;
110
+ }
111
+
112
+ const NULL_BODY_STATUSES = new Set([204, 205, 304]);
113
+ const DRAIN_POLL_MS = 20;
114
+
115
+ interface PendingExchange {
116
+ readonly method: string;
117
+ readonly gate: CreditGate;
118
+ readonly abort: AbortController;
119
+ settleHead(head: HttpResponseHeadPayload | Error): void;
120
+ body?: InboundBody;
121
+ headReceived: boolean;
122
+ }
123
+
124
+ interface BridgedPeer {
125
+ readonly peer: WsConnection;
126
+ /** Sends a peer frame to the instance (after acceptance). */
127
+ forward(data: string | Uint8Array): void;
128
+ accepted: boolean;
129
+ closed: boolean;
130
+ /** Bytes sent to the instance and not acknowledged yet. */
131
+ unacked: number;
132
+ /** Instance bytes delivered to the peer and not acknowledged yet. */
133
+ pendingAck: number;
134
+ drainTimer?: ReturnType<typeof setInterval>;
135
+ readonly queue: (string | Uint8Array)[];
136
+ settle(accepted: boolean): void;
137
+ /** Instance went away or closed: close the peer. */
138
+ closePeer(code: number, reason: string): void;
139
+ cleanup(): void;
140
+ }
141
+
142
+ export class IpcInstanceTransport implements InstanceTransport {
143
+ readonly #limits: BridgeLimits;
144
+ /** Survives restarts: the last catalog the instance reported (plugins rarely change). */
145
+ #catalog: readonly ConsolePluginPermissionInfo[] = Object.freeze([]);
146
+ readonly #catalogListeners = new Set<(catalog: readonly ConsolePluginPermissionInfo[]) => void>();
147
+ readonly #auditListeners = new Set<(entry: ConsoleAuditEntry) => void>();
148
+ readonly #responseTimeoutMs: number;
149
+ readonly #wsOpenTimeoutMs: number;
150
+ readonly #wsLowWater: number;
151
+ #port: GatewayInstancePort | undefined;
152
+ #table: BridgeRouteTable = EMPTY_ROUTE_TABLE;
153
+ #tableReceived = false;
154
+ #incompatible = false;
155
+ #addresses: readonly HttpHostAddress[] | undefined;
156
+ #sequence = 0;
157
+ readonly #exchanges = new Map<string, PendingExchange>();
158
+ readonly #peers = new Map<string, BridgedPeer>();
159
+ readonly #routeListeners = new Set<(table: BridgeRouteTable) => void>();
160
+
161
+ constructor(options: IpcInstanceTransportOptions = {}) {
162
+ this.#limits = Object.freeze({ ...DEFAULT_BRIDGE_LIMITS, ...options.limits });
163
+ this.#responseTimeoutMs = options.responseTimeoutMs ?? DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS;
164
+ this.#wsOpenTimeoutMs = options.wsOpenTimeoutMs ?? DEFAULT_BRIDGE_WS_OPEN_TIMEOUT_MS;
165
+ this.#wsLowWater = options.wsLowWaterBytes ?? 1024 * 1024;
166
+ }
167
+
168
+ /** Plugin permission catalog the instance last reported (kept across restarts). */
169
+ pluginPermissions(): readonly ConsolePluginPermissionInfo[] {
170
+ return this.#catalog;
171
+ }
172
+
173
+ onCatalog(listener: (catalog: readonly ConsolePluginPermissionInfo[]) => void): () => void {
174
+ this.#catalogListeners.add(listener);
175
+ return () => { this.#catalogListeners.delete(listener); };
176
+ }
177
+
178
+ /** Audit entries the instance wrote (the gateway owns the audit log). */
179
+ onAudit(listener: (entry: ConsoleAuditEntry) => void): () => void {
180
+ this.#auditListeners.add(listener);
181
+ return () => { this.#auditListeners.delete(listener); };
182
+ }
183
+
184
+ /** Tells the instance to close the live connections of `principalId` (a user id). */
185
+ revokePrincipal(principalId: string, reason?: string): void {
186
+ if (!principalId) return;
187
+ this.#send({ type: 'principal-revoked', payload: { principalId, ...(reason ? { reason: reason.slice(0, 64) } : {}) } });
188
+ }
189
+
190
+ get ready(): boolean {
191
+ return this.#port !== undefined && this.#tableReceived && !this.#incompatible;
192
+ }
193
+
194
+ get activeStreams(): number {
195
+ return this.#exchanges.size + this.#peers.size;
196
+ }
197
+
198
+ routes(): BridgeRouteTable {
199
+ return this.ready ? this.#table : EMPTY_ROUTE_TABLE;
200
+ }
201
+
202
+ onRoutes(listener: (table: BridgeRouteTable) => void): () => void {
203
+ this.#routeListeners.add(listener);
204
+ return () => { this.#routeListeners.delete(listener); };
205
+ }
206
+
207
+ /** Addresses the gateway listens on; forwarded to the instance (`HttpHost.address`). */
208
+ setAddresses(addresses: readonly HttpHostAddress[]): void {
209
+ this.#addresses = Object.freeze(addresses.map((address) => ({ ...address })));
210
+ this.#send({ type: 'host.addresses', payload: { addresses: this.#addresses } });
211
+ }
212
+
213
+ /** Binds the transport to a new instance child; an earlier one is detached first. */
214
+ attach(port: GatewayInstancePort): void {
215
+ if (this.#port) this.detach('Instance replaced');
216
+ this.#port = port;
217
+ this.#table = EMPTY_ROUTE_TABLE;
218
+ this.#tableReceived = false;
219
+ this.#incompatible = false;
220
+ if (this.#addresses) this.#send({ type: 'host.addresses', payload: { addresses: this.#addresses } });
221
+ }
222
+
223
+ /**
224
+ * Wires a spawned child (`serialization: 'advanced'`): its messages feed {@link receive},
225
+ * `exit` / `disconnect` detach. Returns the unwiring function.
226
+ */
227
+ attachChildProcess(child: InstanceChildProcessLike): () => void {
228
+ const port: GatewayInstancePort = {
229
+ send(message) {
230
+ try {
231
+ child.send?.(message, () => undefined);
232
+ } catch { /* channel closed */ }
233
+ },
234
+ };
235
+ const onMessage = (raw: unknown): void => { if (this.#port === port) this.receive(raw); };
236
+ const onGone = (): void => { if (this.#port === port) this.detach('Instance exited'); };
237
+ this.attach(port);
238
+ child.on('message', onMessage);
239
+ child.on('exit', onGone);
240
+ child.on('disconnect', onGone);
241
+ return () => {
242
+ child.off('message', onMessage);
243
+ child.off('exit', onGone);
244
+ child.off('disconnect', onGone);
245
+ onGone();
246
+ };
247
+ }
248
+
249
+ /** The instance is gone: fail every exchange and close every bridged socket. */
250
+ detach(reason = 'Instance offline'): void {
251
+ if (!this.#port) return;
252
+ const wasReady = this.ready;
253
+ this.#port = undefined;
254
+ this.#tableReceived = false;
255
+ for (const [id, exchange] of [...this.#exchanges]) {
256
+ this.#exchanges.delete(id);
257
+ const error = new InstanceTransportError('instance-offline', reason);
258
+ exchange.gate.close(error);
259
+ exchange.abort.abort(error);
260
+ exchange.settleHead(error);
261
+ exchange.body?.fail(error);
262
+ }
263
+ for (const peer of [...this.#peers.values()]) {
264
+ peer.settle(false);
265
+ peer.closePeer(1012, 'Instance restarting');
266
+ }
267
+ this.#table = EMPTY_ROUTE_TABLE;
268
+ if (wasReady) this.#emitRoutes();
269
+ }
270
+
271
+ /** Feeds one raw message from the instance. Unknown / malformed messages are ignored. */
272
+ receive(raw: unknown): void {
273
+ const message = parseInstanceMessage(raw, this.#limits);
274
+ if (message) this.#dispatch(message);
275
+ }
276
+
277
+ // ---- HTTP
278
+
279
+ async fetch(request: Request, context: InstanceRequestContext = {}): Promise<Response> {
280
+ request.signal.throwIfAborted();
281
+ if (!this.ready) throw new InstanceTransportError('instance-offline', 'Instance is offline');
282
+ if (this.activeStreams >= this.#limits.maxStreams) throw new InstanceTransportError('overloaded', 'Too many concurrent instance streams');
283
+ const headers = toBridgeHeaders(request.headers);
284
+ if (headSize(request.url, headers) > this.#limits.maxHeadBytes) return new Response(null, { status: 431 });
285
+ const id = `h${++this.#sequence}`;
286
+ let settleHead!: (head: HttpResponseHeadPayload | Error) => void;
287
+ const head = new Promise<HttpResponseHeadPayload | Error>((resolve) => { settleHead = resolve; });
288
+ const exchange: PendingExchange = {
289
+ method: request.method.toUpperCase(),
290
+ gate: new CreditGate(this.#limits.initialCreditBytes),
291
+ abort: new AbortController(),
292
+ settleHead: (value) => { settleHead(value); },
293
+ headReceived: false,
294
+ };
295
+ this.#exchanges.set(id, exchange);
296
+ const hasBody = request.body !== null;
297
+ this.#send({
298
+ type: 'http.request',
299
+ id,
300
+ payload: {
301
+ method: request.method,
302
+ url: request.url,
303
+ headers,
304
+ body: hasBody,
305
+ ...(context.principal ? { principal: context.principal } : {}),
306
+ ...(context.remoteAddress ? { remoteAddress: context.remoteAddress } : {}),
307
+ },
308
+ });
309
+ const onClientAbort = (): void => { this.#cancelExchange(id, request.signal.reason, 'Client aborted'); };
310
+ request.signal.addEventListener('abort', onClientAbort, { once: true });
311
+ exchange.abort.signal.addEventListener('abort', () => request.signal.removeEventListener('abort', onClientAbort), { once: true });
312
+ const timer = setTimeout(() => {
313
+ this.#cancelExchange(id, new InstanceTransportError('timeout', `Instance did not answer within ${this.#responseTimeoutMs} ms`), 'Gateway timeout');
314
+ }, this.#responseTimeoutMs);
315
+ timer.unref?.();
316
+ if (hasBody) {
317
+ void pumpBody(request.body!, {
318
+ gate: exchange.gate,
319
+ maxChunkBytes: this.#limits.maxChunkBytes,
320
+ signal: exchange.abort.signal,
321
+ send: (chunk) => this.#send({ type: 'http.request.body', id, payload: { chunk } }),
322
+ }).then(() => {
323
+ if (this.#exchanges.get(id) === exchange) this.#send({ type: 'http.request.end', id, payload: {} });
324
+ }, (error: unknown) => {
325
+ if (error instanceof BridgeStreamClosedError || exchange.abort.signal.aborted) return;
326
+ this.#cancelExchange(id, error, 'Request body failed');
327
+ });
328
+ }
329
+ const result = await head;
330
+ clearTimeout(timer);
331
+ if (result instanceof Error) throw result;
332
+ // The body (created with the head, see #dispatch) may already hold chunks or have ended.
333
+ return new Response(exchange.body?.stream ?? null, {
334
+ status: result.status,
335
+ ...(result.statusText ? { statusText: result.statusText } : {}),
336
+ headers: fromBridgeHeaders(result.headers),
337
+ });
338
+ }
339
+
340
+ #cancelExchange(id: string, reason: unknown, message: string): void {
341
+ const exchange = this.#exchanges.get(id);
342
+ if (!exchange) return;
343
+ this.#exchanges.delete(id);
344
+ const error = reason instanceof Error ? reason : new Error(message);
345
+ exchange.gate.close(new BridgeStreamClosedError(message));
346
+ exchange.abort.abort(error);
347
+ exchange.settleHead(toError(reason, message));
348
+ exchange.body?.fail(error);
349
+ this.#send({ type: 'http.request.abort', id, payload: { message: message.slice(0, 256) } });
350
+ }
351
+
352
+ #finishExchange(id: string): void {
353
+ const exchange = this.#exchanges.get(id);
354
+ if (!exchange) return;
355
+ this.#exchanges.delete(id);
356
+ // Stop a request body the instance no longer reads.
357
+ exchange.gate.close(new BridgeStreamClosedError('Exchange finished'));
358
+ exchange.abort.abort(new BridgeStreamClosedError('Exchange finished'));
359
+ }
360
+
361
+ // ---- WebSocket
362
+
363
+ openWebSocket(info: InstanceWsOpenInfo, peer: WsConnection): Promise<boolean> {
364
+ if (!this.ready || this.activeStreams >= this.#limits.maxStreams) return Promise.resolve(false);
365
+ const headers = toBridgeHeaders(info.headers);
366
+ if (headSize(info.url.href, headers) > this.#limits.maxHeadBytes) return Promise.resolve(false);
367
+ const id = `w${++this.#sequence}`;
368
+ const limits = this.#limits;
369
+ let resolveOpen!: (accepted: boolean) => void;
370
+ const opened = new Promise<boolean>((resolve) => { resolveOpen = resolve; });
371
+ const unsubscribe: Array<() => void> = [];
372
+ let timer: ReturnType<typeof setTimeout> | undefined;
373
+ let queuedBytes = 0;
374
+ const state: BridgedPeer = {
375
+ peer,
376
+ forward: (data) => {
377
+ const size = wsMessageSize(data);
378
+ if (state.unacked + size > limits.maxWsBufferedBytes) {
379
+ this.#send({ type: 'ws.close', id, payload: { code: 1013, reason: 'Instance backpressure' } });
380
+ state.closePeer(1013, 'Instance backpressure');
381
+ return;
382
+ }
383
+ state.unacked += size;
384
+ this.#send({ type: 'ws.message', id, payload: { data: typeof data === 'string' ? data : ownBytes(data) } });
385
+ },
386
+ accepted: false,
387
+ closed: false,
388
+ unacked: 0,
389
+ pendingAck: 0,
390
+ queue: [],
391
+ settle: (accepted) => {
392
+ clearTimeout(timer);
393
+ timer = undefined;
394
+ resolveOpen(accepted);
395
+ },
396
+ closePeer: (code, reason) => {
397
+ if (state.closed) return;
398
+ state.cleanup();
399
+ try { peer.close(sendableCloseCode(code), reason); } catch { /* already closed */ }
400
+ },
401
+ cleanup: () => {
402
+ state.closed = true;
403
+ clearInterval(state.drainTimer);
404
+ clearTimeout(timer);
405
+ if (this.#peers.get(id) === state) this.#peers.delete(id);
406
+ for (const dispose of unsubscribe.splice(0)) dispose();
407
+ },
408
+ };
409
+ this.#peers.set(id, state);
410
+ unsubscribe.push(peer.onMessage((data) => {
411
+ if (state.closed) return;
412
+ if (wsMessageSize(data) > limits.maxWsMessageBytes) {
413
+ this.#send({ type: 'ws.close', id, payload: { code: 1009, reason: 'Message too big' } });
414
+ state.closePeer(1009, 'Message too big');
415
+ return;
416
+ }
417
+ if (state.accepted) {
418
+ state.forward(data);
419
+ return;
420
+ }
421
+ queuedBytes += wsMessageSize(data);
422
+ if (queuedBytes > limits.maxWsBufferedBytes) {
423
+ this.#send({ type: 'ws.close', id, payload: { code: 1013, reason: 'Instance backpressure' } });
424
+ state.settle(false);
425
+ state.closePeer(1013, 'Instance backpressure');
426
+ return;
427
+ }
428
+ state.queue.push(data);
429
+ }));
430
+ unsubscribe.push(peer.onClose((code, reason) => {
431
+ if (state.closed) return;
432
+ state.cleanup();
433
+ state.settle(false);
434
+ this.#send({ type: 'ws.close', id, payload: { code: wireCloseCode(code), reason: (reason ?? '').slice(0, 123) } });
435
+ }));
436
+ timer = setTimeout(() => {
437
+ if (state.accepted || state.closed) return;
438
+ this.#send({ type: 'ws.close', id, payload: { code: 1001, reason: 'Open timed out' } });
439
+ state.cleanup();
440
+ state.settle(false);
441
+ }, this.#wsOpenTimeoutMs);
442
+ timer.unref?.();
443
+ this.#send({
444
+ type: 'ws.open',
445
+ id,
446
+ payload: {
447
+ url: info.url.href,
448
+ headers,
449
+ ...(info.principal ? { principal: info.principal } : {}),
450
+ ...(info.remoteAddress ? { remoteAddress: info.remoteAddress } : {}),
451
+ },
452
+ });
453
+ return opened;
454
+ }
455
+
456
+ #acknowledge(id: string, state: BridgedPeer, peer: WsConnection): void {
457
+ const flush = (): boolean => {
458
+ if (state.closed) return true;
459
+ if (peer.bufferedAmount > this.#wsLowWater) return false;
460
+ if (state.pendingAck > 0) {
461
+ this.#send({ type: 'ws.drain', id, payload: { bytes: state.pendingAck } });
462
+ state.pendingAck = 0;
463
+ }
464
+ return true;
465
+ };
466
+ if (state.drainTimer || flush()) return;
467
+ // The client reads slowly: hold the acknowledgement until its socket drained.
468
+ state.drainTimer = setInterval(() => {
469
+ if (!flush()) return;
470
+ clearInterval(state.drainTimer);
471
+ state.drainTimer = undefined;
472
+ }, DRAIN_POLL_MS);
473
+ state.drainTimer.unref?.();
474
+ }
475
+
476
+ // ---- dispatch
477
+
478
+ #dispatch(message: InstanceToGatewayMessage): void {
479
+ switch (message.type) {
480
+ case 'ready':
481
+ if (!isBridgeProtocolCompatible(message.payload.version)) {
482
+ this.#incompatible = true;
483
+ return;
484
+ }
485
+ if (message.payload.routes) this.#setTable(message.payload.routes);
486
+ return;
487
+ case 'routes':
488
+ this.#setTable(message.payload.table);
489
+ return;
490
+ case 'restart-request':
491
+ return;
492
+ case 'catalog': {
493
+ this.#catalog = deepFreeze(message.payload.pluginPermissions.map((entry) => ({ ...entry, owners: [...entry.owners] })));
494
+ for (const listener of [...this.#catalogListeners]) {
495
+ try { listener(this.#catalog); } catch { /* observers never break the bridge */ }
496
+ }
497
+ return;
498
+ }
499
+ case 'audit':
500
+ for (const listener of [...this.#auditListeners]) {
501
+ try { listener(message.payload.entry); } catch { /* audit never breaks the bridge */ }
502
+ }
503
+ return;
504
+ case 'http.response.head': {
505
+ const exchange = this.#exchanges.get(message.id);
506
+ if (!exchange || exchange.headReceived) return;
507
+ exchange.headReceived = true;
508
+ const id = message.id;
509
+ const wanted = message.payload.body && !NULL_BODY_STATUSES.has(message.payload.status) && exchange.method !== 'HEAD';
510
+ // Created synchronously: body chunks of the same IPC batch are dispatched right after.
511
+ if (wanted) {
512
+ exchange.body = new InboundBody({
513
+ window: this.#limits.initialCreditBytes,
514
+ grant: (bytes) => { if (this.#exchanges.get(id) === exchange) this.#send({ type: 'http.response.credit', id, payload: { bytes } }); },
515
+ cancel: (reason) => { this.#cancelExchange(id, reason, 'Response cancelled'); },
516
+ });
517
+ }
518
+ exchange.settleHead(message.payload);
519
+ if (!message.payload.body) this.#finishExchange(id);
520
+ // Body not wanted (HEAD / null-body status): stop the instance side.
521
+ else if (!wanted) this.#cancelExchange(id, new Error('Response body not used'), 'Response body not used');
522
+ return;
523
+ }
524
+ case 'http.response.body': {
525
+ const exchange = this.#exchanges.get(message.id);
526
+ if (!exchange?.body) return;
527
+ if (!exchange.body.push(message.payload.chunk)) {
528
+ this.#cancelExchange(message.id, new InstanceTransportError('instance-error', 'Response body exceeded the granted credit'), 'Credit exceeded');
529
+ }
530
+ return;
531
+ }
532
+ case 'http.response.end': {
533
+ const exchange = this.#exchanges.get(message.id);
534
+ if (!exchange) return;
535
+ exchange.body?.end();
536
+ this.#finishExchange(message.id);
537
+ return;
538
+ }
539
+ case 'http.response.error': {
540
+ const exchange = this.#exchanges.get(message.id);
541
+ if (!exchange) return;
542
+ const error = new InstanceTransportError('instance-error', message.payload.message);
543
+ exchange.settleHead(error);
544
+ exchange.body?.fail(error);
545
+ this.#finishExchange(message.id);
546
+ return;
547
+ }
548
+ case 'http.request.credit':
549
+ this.#exchanges.get(message.id)?.gate.add(message.payload.bytes);
550
+ return;
551
+ case 'ws.accepted': {
552
+ const state = this.#peers.get(message.id);
553
+ if (!state || state.accepted || state.closed) return;
554
+ state.accepted = true;
555
+ for (const data of state.queue.splice(0)) state.forward(data);
556
+ state.settle(true);
557
+ return;
558
+ }
559
+ case 'ws.rejected': {
560
+ const state = this.#peers.get(message.id);
561
+ if (!state || state.accepted) return;
562
+ state.cleanup();
563
+ state.settle(false);
564
+ return;
565
+ }
566
+ case 'ws.message': {
567
+ const state = this.#peers.get(message.id);
568
+ if (!state || state.closed) return;
569
+ try {
570
+ state.peer.send(message.payload.data);
571
+ } catch { /* peer closing */ }
572
+ state.pendingAck += Math.max(1, wsMessageSize(message.payload.data));
573
+ this.#acknowledge(message.id, state, state.peer);
574
+ return;
575
+ }
576
+ case 'ws.close': {
577
+ const state = this.#peers.get(message.id);
578
+ if (!state) return;
579
+ state.settle(false);
580
+ state.closePeer(message.payload.code, message.payload.reason);
581
+ return;
582
+ }
583
+ case 'ws.drain': {
584
+ const state = this.#peers.get(message.id);
585
+ if (state) state.unacked = Math.max(0, state.unacked - message.payload.bytes);
586
+ return;
587
+ }
588
+ default:
589
+ return;
590
+ }
591
+ }
592
+
593
+ #setTable(table: BridgeRouteTable): void {
594
+ if (this.#tableReceived && table.revision <= this.#table.revision) return;
595
+ this.#table = deepFreeze(table);
596
+ this.#tableReceived = true;
597
+ if (!this.#incompatible) this.#emitRoutes();
598
+ }
599
+
600
+ #emitRoutes(): void {
601
+ const table = this.routes();
602
+ for (const listener of [...this.#routeListeners]) {
603
+ try { listener(table); } catch { /* observers never break the bridge */ }
604
+ }
605
+ }
606
+
607
+ #send(message: GatewayToInstanceMessage): void {
608
+ try { this.#port?.send(message); } catch { /* instance gone */ }
609
+ }
610
+ }
611
+
612
+ /** Codes a peer reported locally (1005 / 1006) travel as is; anything else invalid becomes 1000. */
613
+ function wireCloseCode(code: number): number {
614
+ return Number.isInteger(code) && code >= 1000 && code <= 4999 ? code : 1000;
615
+ }
616
+
617
+ function toError(reason: unknown, fallback: string): Error {
618
+ return reason instanceof Error ? reason : new Error(typeof reason === 'string' ? reason : fallback);
619
+ }
620
+
621
+ function deepFreeze<T>(value: T): T {
622
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
623
+ for (const child of Object.values(value)) deepFreeze(child);
624
+ Object.freeze(value);
625
+ }
626
+ return value;
627
+ }