@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.
- package/README.md +60 -21
- package/lib/access-control.d.ts +127 -0
- package/lib/access-control.js +148 -0
- package/lib/addresses.d.ts +9 -0
- package/lib/addresses.js +9 -0
- package/lib/bridge/bridged-host.d.ts +43 -0
- package/lib/bridge/bridged-host.js +497 -0
- package/lib/bridge/control-channel.d.ts +59 -0
- package/lib/bridge/control-channel.js +131 -0
- package/lib/bridge/flow.d.ts +58 -0
- package/lib/bridge/flow.js +188 -0
- package/lib/bridge/index.d.ts +6 -0
- package/lib/bridge/index.js +6 -0
- package/lib/bridge/instance-transport.d.ts +112 -0
- package/lib/bridge/instance-transport.js +532 -0
- package/lib/bridge/protocol.d.ts +242 -0
- package/lib/bridge/protocol.js +369 -0
- package/lib/console-events.d.ts +5 -3
- package/lib/console-events.js +53 -27
- package/lib/console-plugin-rpc.d.ts +12 -1
- package/lib/console-plugin-rpc.js +16 -66
- package/lib/console-rest-pages-helpers.d.ts +4 -5
- package/lib/console-rest-pages-helpers.js +62 -79
- package/lib/console-rest-pages.js +80 -65
- package/lib/console-rpc-extended/dispatcher.js +1 -6
- package/lib/console-rpc-handlers.d.ts +0 -2
- package/lib/console-rpc-handlers.js +2 -40
- package/lib/console-rpc.d.ts +6 -18
- package/lib/console-rpc.js +6 -6
- package/lib/host-installer.d.ts +6 -3
- package/lib/host-installer.js +10 -40
- package/lib/host-options.d.ts +47 -0
- package/lib/host-options.js +1 -0
- package/lib/http-host.d.ts +124 -48
- package/lib/http-host.js +319 -332
- package/lib/index.d.ts +11 -6
- package/lib/index.js +10 -6
- package/lib/json-body.d.ts +1 -2
- package/lib/json-body.js +1 -2
- package/lib/marketplace-registry.d.ts +17 -0
- package/lib/marketplace-registry.js +52 -0
- package/lib/node/index.d.ts +10 -0
- package/lib/node/index.js +10 -0
- package/lib/{listener-group.d.ts → node/listener-group.d.ts} +2 -3
- package/lib/{listener-group.js → node/listener-group.js} +6 -10
- package/lib/node/listener.d.ts +26 -0
- package/lib/node/listener.js +231 -0
- package/lib/node/node-http.d.ts +38 -0
- package/lib/node/node-http.js +272 -0
- package/lib/node/server.d.ts +13 -0
- package/lib/node/server.js +64 -0
- package/lib/protocol-auth.d.ts +24 -0
- package/lib/protocol-auth.js +48 -0
- package/lib/public-api.d.ts +1 -1
- package/lib/token-registry.d.ts +54 -36
- package/lib/token-registry.js +133 -126
- package/lib/trust-proxy.d.ts +22 -0
- package/lib/trust-proxy.js +168 -0
- package/package.json +14 -6
- package/src/access-control.ts +255 -0
- package/src/addresses.ts +16 -0
- package/src/bridge/bridged-host.ts +548 -0
- package/src/bridge/control-channel.ts +173 -0
- package/src/bridge/flow.ts +208 -0
- package/src/bridge/index.ts +31 -0
- package/src/bridge/instance-transport.ts +627 -0
- package/src/bridge/protocol.ts +574 -0
- package/src/console-events.ts +59 -28
- package/src/console-plugin-rpc.ts +17 -77
- package/src/console-rest-pages-helpers.ts +60 -82
- package/src/console-rest-pages.ts +87 -67
- package/src/console-rpc-extended/dispatcher.ts +1 -6
- package/src/console-rpc-handlers.ts +2 -31
- package/src/console-rpc.ts +10 -24
- package/src/host-installer.ts +12 -44
- package/src/host-options.ts +47 -0
- package/src/http-host.ts +467 -421
- package/src/index.ts +56 -14
- package/src/json-body.ts +7 -2
- package/src/marketplace-registry.ts +62 -0
- package/src/node/index.ts +21 -0
- package/src/{listener-group.ts → node/listener-group.ts} +13 -24
- package/src/node/listener.ts +265 -0
- package/src/node/node-http.ts +263 -0
- package/src/node/server.ts +82 -0
- package/src/protocol-auth.ts +52 -0
- package/src/public-api.ts +2 -0
- package/src/token-registry.ts +143 -146
- package/src/trust-proxy.ts +178 -0
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
import { sendableCloseCode } from './bridged-host.js';
|
|
2
|
+
import { BridgeStreamClosedError, CreditGate, InboundBody, ownBytes, pumpBody } from './flow.js';
|
|
3
|
+
import { DEFAULT_BRIDGE_LIMITS, DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS, DEFAULT_BRIDGE_WS_OPEN_TIMEOUT_MS, EMPTY_ROUTE_TABLE, fromBridgeHeaders, headSize, isBridgeProtocolCompatible, parseInstanceMessage, toBridgeHeaders, wsMessageSize, } from './protocol.js';
|
|
4
|
+
export class InstanceTransportError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
constructor(code, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.name = 'InstanceTransportError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const NULL_BODY_STATUSES = new Set([204, 205, 304]);
|
|
13
|
+
const DRAIN_POLL_MS = 20;
|
|
14
|
+
export class IpcInstanceTransport {
|
|
15
|
+
#limits;
|
|
16
|
+
/** Survives restarts: the last catalog the instance reported (plugins rarely change). */
|
|
17
|
+
#catalog = Object.freeze([]);
|
|
18
|
+
#catalogListeners = new Set();
|
|
19
|
+
#auditListeners = new Set();
|
|
20
|
+
#responseTimeoutMs;
|
|
21
|
+
#wsOpenTimeoutMs;
|
|
22
|
+
#wsLowWater;
|
|
23
|
+
#port;
|
|
24
|
+
#table = EMPTY_ROUTE_TABLE;
|
|
25
|
+
#tableReceived = false;
|
|
26
|
+
#incompatible = false;
|
|
27
|
+
#addresses;
|
|
28
|
+
#sequence = 0;
|
|
29
|
+
#exchanges = new Map();
|
|
30
|
+
#peers = new Map();
|
|
31
|
+
#routeListeners = new Set();
|
|
32
|
+
constructor(options = {}) {
|
|
33
|
+
this.#limits = Object.freeze({ ...DEFAULT_BRIDGE_LIMITS, ...options.limits });
|
|
34
|
+
this.#responseTimeoutMs = options.responseTimeoutMs ?? DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS;
|
|
35
|
+
this.#wsOpenTimeoutMs = options.wsOpenTimeoutMs ?? DEFAULT_BRIDGE_WS_OPEN_TIMEOUT_MS;
|
|
36
|
+
this.#wsLowWater = options.wsLowWaterBytes ?? 1024 * 1024;
|
|
37
|
+
}
|
|
38
|
+
/** Plugin permission catalog the instance last reported (kept across restarts). */
|
|
39
|
+
pluginPermissions() {
|
|
40
|
+
return this.#catalog;
|
|
41
|
+
}
|
|
42
|
+
onCatalog(listener) {
|
|
43
|
+
this.#catalogListeners.add(listener);
|
|
44
|
+
return () => { this.#catalogListeners.delete(listener); };
|
|
45
|
+
}
|
|
46
|
+
/** Audit entries the instance wrote (the gateway owns the audit log). */
|
|
47
|
+
onAudit(listener) {
|
|
48
|
+
this.#auditListeners.add(listener);
|
|
49
|
+
return () => { this.#auditListeners.delete(listener); };
|
|
50
|
+
}
|
|
51
|
+
/** Tells the instance to close the live connections of `principalId` (a user id). */
|
|
52
|
+
revokePrincipal(principalId, reason) {
|
|
53
|
+
if (!principalId)
|
|
54
|
+
return;
|
|
55
|
+
this.#send({ type: 'principal-revoked', payload: { principalId, ...(reason ? { reason: reason.slice(0, 64) } : {}) } });
|
|
56
|
+
}
|
|
57
|
+
get ready() {
|
|
58
|
+
return this.#port !== undefined && this.#tableReceived && !this.#incompatible;
|
|
59
|
+
}
|
|
60
|
+
get activeStreams() {
|
|
61
|
+
return this.#exchanges.size + this.#peers.size;
|
|
62
|
+
}
|
|
63
|
+
routes() {
|
|
64
|
+
return this.ready ? this.#table : EMPTY_ROUTE_TABLE;
|
|
65
|
+
}
|
|
66
|
+
onRoutes(listener) {
|
|
67
|
+
this.#routeListeners.add(listener);
|
|
68
|
+
return () => { this.#routeListeners.delete(listener); };
|
|
69
|
+
}
|
|
70
|
+
/** Addresses the gateway listens on; forwarded to the instance (`HttpHost.address`). */
|
|
71
|
+
setAddresses(addresses) {
|
|
72
|
+
this.#addresses = Object.freeze(addresses.map((address) => ({ ...address })));
|
|
73
|
+
this.#send({ type: 'host.addresses', payload: { addresses: this.#addresses } });
|
|
74
|
+
}
|
|
75
|
+
/** Binds the transport to a new instance child; an earlier one is detached first. */
|
|
76
|
+
attach(port) {
|
|
77
|
+
if (this.#port)
|
|
78
|
+
this.detach('Instance replaced');
|
|
79
|
+
this.#port = port;
|
|
80
|
+
this.#table = EMPTY_ROUTE_TABLE;
|
|
81
|
+
this.#tableReceived = false;
|
|
82
|
+
this.#incompatible = false;
|
|
83
|
+
if (this.#addresses)
|
|
84
|
+
this.#send({ type: 'host.addresses', payload: { addresses: this.#addresses } });
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Wires a spawned child (`serialization: 'advanced'`): its messages feed {@link receive},
|
|
88
|
+
* `exit` / `disconnect` detach. Returns the unwiring function.
|
|
89
|
+
*/
|
|
90
|
+
attachChildProcess(child) {
|
|
91
|
+
const port = {
|
|
92
|
+
send(message) {
|
|
93
|
+
try {
|
|
94
|
+
child.send?.(message, () => undefined);
|
|
95
|
+
}
|
|
96
|
+
catch { /* channel closed */ }
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
const onMessage = (raw) => { if (this.#port === port)
|
|
100
|
+
this.receive(raw); };
|
|
101
|
+
const onGone = () => { if (this.#port === port)
|
|
102
|
+
this.detach('Instance exited'); };
|
|
103
|
+
this.attach(port);
|
|
104
|
+
child.on('message', onMessage);
|
|
105
|
+
child.on('exit', onGone);
|
|
106
|
+
child.on('disconnect', onGone);
|
|
107
|
+
return () => {
|
|
108
|
+
child.off('message', onMessage);
|
|
109
|
+
child.off('exit', onGone);
|
|
110
|
+
child.off('disconnect', onGone);
|
|
111
|
+
onGone();
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/** The instance is gone: fail every exchange and close every bridged socket. */
|
|
115
|
+
detach(reason = 'Instance offline') {
|
|
116
|
+
if (!this.#port)
|
|
117
|
+
return;
|
|
118
|
+
const wasReady = this.ready;
|
|
119
|
+
this.#port = undefined;
|
|
120
|
+
this.#tableReceived = false;
|
|
121
|
+
for (const [id, exchange] of [...this.#exchanges]) {
|
|
122
|
+
this.#exchanges.delete(id);
|
|
123
|
+
const error = new InstanceTransportError('instance-offline', reason);
|
|
124
|
+
exchange.gate.close(error);
|
|
125
|
+
exchange.abort.abort(error);
|
|
126
|
+
exchange.settleHead(error);
|
|
127
|
+
exchange.body?.fail(error);
|
|
128
|
+
}
|
|
129
|
+
for (const peer of [...this.#peers.values()]) {
|
|
130
|
+
peer.settle(false);
|
|
131
|
+
peer.closePeer(1012, 'Instance restarting');
|
|
132
|
+
}
|
|
133
|
+
this.#table = EMPTY_ROUTE_TABLE;
|
|
134
|
+
if (wasReady)
|
|
135
|
+
this.#emitRoutes();
|
|
136
|
+
}
|
|
137
|
+
/** Feeds one raw message from the instance. Unknown / malformed messages are ignored. */
|
|
138
|
+
receive(raw) {
|
|
139
|
+
const message = parseInstanceMessage(raw, this.#limits);
|
|
140
|
+
if (message)
|
|
141
|
+
this.#dispatch(message);
|
|
142
|
+
}
|
|
143
|
+
// ---- HTTP
|
|
144
|
+
async fetch(request, context = {}) {
|
|
145
|
+
request.signal.throwIfAborted();
|
|
146
|
+
if (!this.ready)
|
|
147
|
+
throw new InstanceTransportError('instance-offline', 'Instance is offline');
|
|
148
|
+
if (this.activeStreams >= this.#limits.maxStreams)
|
|
149
|
+
throw new InstanceTransportError('overloaded', 'Too many concurrent instance streams');
|
|
150
|
+
const headers = toBridgeHeaders(request.headers);
|
|
151
|
+
if (headSize(request.url, headers) > this.#limits.maxHeadBytes)
|
|
152
|
+
return new Response(null, { status: 431 });
|
|
153
|
+
const id = `h${++this.#sequence}`;
|
|
154
|
+
let settleHead;
|
|
155
|
+
const head = new Promise((resolve) => { settleHead = resolve; });
|
|
156
|
+
const exchange = {
|
|
157
|
+
method: request.method.toUpperCase(),
|
|
158
|
+
gate: new CreditGate(this.#limits.initialCreditBytes),
|
|
159
|
+
abort: new AbortController(),
|
|
160
|
+
settleHead: (value) => { settleHead(value); },
|
|
161
|
+
headReceived: false,
|
|
162
|
+
};
|
|
163
|
+
this.#exchanges.set(id, exchange);
|
|
164
|
+
const hasBody = request.body !== null;
|
|
165
|
+
this.#send({
|
|
166
|
+
type: 'http.request',
|
|
167
|
+
id,
|
|
168
|
+
payload: {
|
|
169
|
+
method: request.method,
|
|
170
|
+
url: request.url,
|
|
171
|
+
headers,
|
|
172
|
+
body: hasBody,
|
|
173
|
+
...(context.principal ? { principal: context.principal } : {}),
|
|
174
|
+
...(context.remoteAddress ? { remoteAddress: context.remoteAddress } : {}),
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
const onClientAbort = () => { this.#cancelExchange(id, request.signal.reason, 'Client aborted'); };
|
|
178
|
+
request.signal.addEventListener('abort', onClientAbort, { once: true });
|
|
179
|
+
exchange.abort.signal.addEventListener('abort', () => request.signal.removeEventListener('abort', onClientAbort), { once: true });
|
|
180
|
+
const timer = setTimeout(() => {
|
|
181
|
+
this.#cancelExchange(id, new InstanceTransportError('timeout', `Instance did not answer within ${this.#responseTimeoutMs} ms`), 'Gateway timeout');
|
|
182
|
+
}, this.#responseTimeoutMs);
|
|
183
|
+
timer.unref?.();
|
|
184
|
+
if (hasBody) {
|
|
185
|
+
void pumpBody(request.body, {
|
|
186
|
+
gate: exchange.gate,
|
|
187
|
+
maxChunkBytes: this.#limits.maxChunkBytes,
|
|
188
|
+
signal: exchange.abort.signal,
|
|
189
|
+
send: (chunk) => this.#send({ type: 'http.request.body', id, payload: { chunk } }),
|
|
190
|
+
}).then(() => {
|
|
191
|
+
if (this.#exchanges.get(id) === exchange)
|
|
192
|
+
this.#send({ type: 'http.request.end', id, payload: {} });
|
|
193
|
+
}, (error) => {
|
|
194
|
+
if (error instanceof BridgeStreamClosedError || exchange.abort.signal.aborted)
|
|
195
|
+
return;
|
|
196
|
+
this.#cancelExchange(id, error, 'Request body failed');
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
const result = await head;
|
|
200
|
+
clearTimeout(timer);
|
|
201
|
+
if (result instanceof Error)
|
|
202
|
+
throw result;
|
|
203
|
+
// The body (created with the head, see #dispatch) may already hold chunks or have ended.
|
|
204
|
+
return new Response(exchange.body?.stream ?? null, {
|
|
205
|
+
status: result.status,
|
|
206
|
+
...(result.statusText ? { statusText: result.statusText } : {}),
|
|
207
|
+
headers: fromBridgeHeaders(result.headers),
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
#cancelExchange(id, reason, message) {
|
|
211
|
+
const exchange = this.#exchanges.get(id);
|
|
212
|
+
if (!exchange)
|
|
213
|
+
return;
|
|
214
|
+
this.#exchanges.delete(id);
|
|
215
|
+
const error = reason instanceof Error ? reason : new Error(message);
|
|
216
|
+
exchange.gate.close(new BridgeStreamClosedError(message));
|
|
217
|
+
exchange.abort.abort(error);
|
|
218
|
+
exchange.settleHead(toError(reason, message));
|
|
219
|
+
exchange.body?.fail(error);
|
|
220
|
+
this.#send({ type: 'http.request.abort', id, payload: { message: message.slice(0, 256) } });
|
|
221
|
+
}
|
|
222
|
+
#finishExchange(id) {
|
|
223
|
+
const exchange = this.#exchanges.get(id);
|
|
224
|
+
if (!exchange)
|
|
225
|
+
return;
|
|
226
|
+
this.#exchanges.delete(id);
|
|
227
|
+
// Stop a request body the instance no longer reads.
|
|
228
|
+
exchange.gate.close(new BridgeStreamClosedError('Exchange finished'));
|
|
229
|
+
exchange.abort.abort(new BridgeStreamClosedError('Exchange finished'));
|
|
230
|
+
}
|
|
231
|
+
// ---- WebSocket
|
|
232
|
+
openWebSocket(info, peer) {
|
|
233
|
+
if (!this.ready || this.activeStreams >= this.#limits.maxStreams)
|
|
234
|
+
return Promise.resolve(false);
|
|
235
|
+
const headers = toBridgeHeaders(info.headers);
|
|
236
|
+
if (headSize(info.url.href, headers) > this.#limits.maxHeadBytes)
|
|
237
|
+
return Promise.resolve(false);
|
|
238
|
+
const id = `w${++this.#sequence}`;
|
|
239
|
+
const limits = this.#limits;
|
|
240
|
+
let resolveOpen;
|
|
241
|
+
const opened = new Promise((resolve) => { resolveOpen = resolve; });
|
|
242
|
+
const unsubscribe = [];
|
|
243
|
+
let timer;
|
|
244
|
+
let queuedBytes = 0;
|
|
245
|
+
const state = {
|
|
246
|
+
peer,
|
|
247
|
+
forward: (data) => {
|
|
248
|
+
const size = wsMessageSize(data);
|
|
249
|
+
if (state.unacked + size > limits.maxWsBufferedBytes) {
|
|
250
|
+
this.#send({ type: 'ws.close', id, payload: { code: 1013, reason: 'Instance backpressure' } });
|
|
251
|
+
state.closePeer(1013, 'Instance backpressure');
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
state.unacked += size;
|
|
255
|
+
this.#send({ type: 'ws.message', id, payload: { data: typeof data === 'string' ? data : ownBytes(data) } });
|
|
256
|
+
},
|
|
257
|
+
accepted: false,
|
|
258
|
+
closed: false,
|
|
259
|
+
unacked: 0,
|
|
260
|
+
pendingAck: 0,
|
|
261
|
+
queue: [],
|
|
262
|
+
settle: (accepted) => {
|
|
263
|
+
clearTimeout(timer);
|
|
264
|
+
timer = undefined;
|
|
265
|
+
resolveOpen(accepted);
|
|
266
|
+
},
|
|
267
|
+
closePeer: (code, reason) => {
|
|
268
|
+
if (state.closed)
|
|
269
|
+
return;
|
|
270
|
+
state.cleanup();
|
|
271
|
+
try {
|
|
272
|
+
peer.close(sendableCloseCode(code), reason);
|
|
273
|
+
}
|
|
274
|
+
catch { /* already closed */ }
|
|
275
|
+
},
|
|
276
|
+
cleanup: () => {
|
|
277
|
+
state.closed = true;
|
|
278
|
+
clearInterval(state.drainTimer);
|
|
279
|
+
clearTimeout(timer);
|
|
280
|
+
if (this.#peers.get(id) === state)
|
|
281
|
+
this.#peers.delete(id);
|
|
282
|
+
for (const dispose of unsubscribe.splice(0))
|
|
283
|
+
dispose();
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
this.#peers.set(id, state);
|
|
287
|
+
unsubscribe.push(peer.onMessage((data) => {
|
|
288
|
+
if (state.closed)
|
|
289
|
+
return;
|
|
290
|
+
if (wsMessageSize(data) > limits.maxWsMessageBytes) {
|
|
291
|
+
this.#send({ type: 'ws.close', id, payload: { code: 1009, reason: 'Message too big' } });
|
|
292
|
+
state.closePeer(1009, 'Message too big');
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (state.accepted) {
|
|
296
|
+
state.forward(data);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
queuedBytes += wsMessageSize(data);
|
|
300
|
+
if (queuedBytes > limits.maxWsBufferedBytes) {
|
|
301
|
+
this.#send({ type: 'ws.close', id, payload: { code: 1013, reason: 'Instance backpressure' } });
|
|
302
|
+
state.settle(false);
|
|
303
|
+
state.closePeer(1013, 'Instance backpressure');
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
state.queue.push(data);
|
|
307
|
+
}));
|
|
308
|
+
unsubscribe.push(peer.onClose((code, reason) => {
|
|
309
|
+
if (state.closed)
|
|
310
|
+
return;
|
|
311
|
+
state.cleanup();
|
|
312
|
+
state.settle(false);
|
|
313
|
+
this.#send({ type: 'ws.close', id, payload: { code: wireCloseCode(code), reason: (reason ?? '').slice(0, 123) } });
|
|
314
|
+
}));
|
|
315
|
+
timer = setTimeout(() => {
|
|
316
|
+
if (state.accepted || state.closed)
|
|
317
|
+
return;
|
|
318
|
+
this.#send({ type: 'ws.close', id, payload: { code: 1001, reason: 'Open timed out' } });
|
|
319
|
+
state.cleanup();
|
|
320
|
+
state.settle(false);
|
|
321
|
+
}, this.#wsOpenTimeoutMs);
|
|
322
|
+
timer.unref?.();
|
|
323
|
+
this.#send({
|
|
324
|
+
type: 'ws.open',
|
|
325
|
+
id,
|
|
326
|
+
payload: {
|
|
327
|
+
url: info.url.href,
|
|
328
|
+
headers,
|
|
329
|
+
...(info.principal ? { principal: info.principal } : {}),
|
|
330
|
+
...(info.remoteAddress ? { remoteAddress: info.remoteAddress } : {}),
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
return opened;
|
|
334
|
+
}
|
|
335
|
+
#acknowledge(id, state, peer) {
|
|
336
|
+
const flush = () => {
|
|
337
|
+
if (state.closed)
|
|
338
|
+
return true;
|
|
339
|
+
if (peer.bufferedAmount > this.#wsLowWater)
|
|
340
|
+
return false;
|
|
341
|
+
if (state.pendingAck > 0) {
|
|
342
|
+
this.#send({ type: 'ws.drain', id, payload: { bytes: state.pendingAck } });
|
|
343
|
+
state.pendingAck = 0;
|
|
344
|
+
}
|
|
345
|
+
return true;
|
|
346
|
+
};
|
|
347
|
+
if (state.drainTimer || flush())
|
|
348
|
+
return;
|
|
349
|
+
// The client reads slowly: hold the acknowledgement until its socket drained.
|
|
350
|
+
state.drainTimer = setInterval(() => {
|
|
351
|
+
if (!flush())
|
|
352
|
+
return;
|
|
353
|
+
clearInterval(state.drainTimer);
|
|
354
|
+
state.drainTimer = undefined;
|
|
355
|
+
}, DRAIN_POLL_MS);
|
|
356
|
+
state.drainTimer.unref?.();
|
|
357
|
+
}
|
|
358
|
+
// ---- dispatch
|
|
359
|
+
#dispatch(message) {
|
|
360
|
+
switch (message.type) {
|
|
361
|
+
case 'ready':
|
|
362
|
+
if (!isBridgeProtocolCompatible(message.payload.version)) {
|
|
363
|
+
this.#incompatible = true;
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (message.payload.routes)
|
|
367
|
+
this.#setTable(message.payload.routes);
|
|
368
|
+
return;
|
|
369
|
+
case 'routes':
|
|
370
|
+
this.#setTable(message.payload.table);
|
|
371
|
+
return;
|
|
372
|
+
case 'restart-request':
|
|
373
|
+
return;
|
|
374
|
+
case 'catalog': {
|
|
375
|
+
this.#catalog = deepFreeze(message.payload.pluginPermissions.map((entry) => ({ ...entry, owners: [...entry.owners] })));
|
|
376
|
+
for (const listener of [...this.#catalogListeners]) {
|
|
377
|
+
try {
|
|
378
|
+
listener(this.#catalog);
|
|
379
|
+
}
|
|
380
|
+
catch { /* observers never break the bridge */ }
|
|
381
|
+
}
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
case 'audit':
|
|
385
|
+
for (const listener of [...this.#auditListeners]) {
|
|
386
|
+
try {
|
|
387
|
+
listener(message.payload.entry);
|
|
388
|
+
}
|
|
389
|
+
catch { /* audit never breaks the bridge */ }
|
|
390
|
+
}
|
|
391
|
+
return;
|
|
392
|
+
case 'http.response.head': {
|
|
393
|
+
const exchange = this.#exchanges.get(message.id);
|
|
394
|
+
if (!exchange || exchange.headReceived)
|
|
395
|
+
return;
|
|
396
|
+
exchange.headReceived = true;
|
|
397
|
+
const id = message.id;
|
|
398
|
+
const wanted = message.payload.body && !NULL_BODY_STATUSES.has(message.payload.status) && exchange.method !== 'HEAD';
|
|
399
|
+
// Created synchronously: body chunks of the same IPC batch are dispatched right after.
|
|
400
|
+
if (wanted) {
|
|
401
|
+
exchange.body = new InboundBody({
|
|
402
|
+
window: this.#limits.initialCreditBytes,
|
|
403
|
+
grant: (bytes) => { if (this.#exchanges.get(id) === exchange)
|
|
404
|
+
this.#send({ type: 'http.response.credit', id, payload: { bytes } }); },
|
|
405
|
+
cancel: (reason) => { this.#cancelExchange(id, reason, 'Response cancelled'); },
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
exchange.settleHead(message.payload);
|
|
409
|
+
if (!message.payload.body)
|
|
410
|
+
this.#finishExchange(id);
|
|
411
|
+
// Body not wanted (HEAD / null-body status): stop the instance side.
|
|
412
|
+
else if (!wanted)
|
|
413
|
+
this.#cancelExchange(id, new Error('Response body not used'), 'Response body not used');
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
case 'http.response.body': {
|
|
417
|
+
const exchange = this.#exchanges.get(message.id);
|
|
418
|
+
if (!exchange?.body)
|
|
419
|
+
return;
|
|
420
|
+
if (!exchange.body.push(message.payload.chunk)) {
|
|
421
|
+
this.#cancelExchange(message.id, new InstanceTransportError('instance-error', 'Response body exceeded the granted credit'), 'Credit exceeded');
|
|
422
|
+
}
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
case 'http.response.end': {
|
|
426
|
+
const exchange = this.#exchanges.get(message.id);
|
|
427
|
+
if (!exchange)
|
|
428
|
+
return;
|
|
429
|
+
exchange.body?.end();
|
|
430
|
+
this.#finishExchange(message.id);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
case 'http.response.error': {
|
|
434
|
+
const exchange = this.#exchanges.get(message.id);
|
|
435
|
+
if (!exchange)
|
|
436
|
+
return;
|
|
437
|
+
const error = new InstanceTransportError('instance-error', message.payload.message);
|
|
438
|
+
exchange.settleHead(error);
|
|
439
|
+
exchange.body?.fail(error);
|
|
440
|
+
this.#finishExchange(message.id);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
case 'http.request.credit':
|
|
444
|
+
this.#exchanges.get(message.id)?.gate.add(message.payload.bytes);
|
|
445
|
+
return;
|
|
446
|
+
case 'ws.accepted': {
|
|
447
|
+
const state = this.#peers.get(message.id);
|
|
448
|
+
if (!state || state.accepted || state.closed)
|
|
449
|
+
return;
|
|
450
|
+
state.accepted = true;
|
|
451
|
+
for (const data of state.queue.splice(0))
|
|
452
|
+
state.forward(data);
|
|
453
|
+
state.settle(true);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
case 'ws.rejected': {
|
|
457
|
+
const state = this.#peers.get(message.id);
|
|
458
|
+
if (!state || state.accepted)
|
|
459
|
+
return;
|
|
460
|
+
state.cleanup();
|
|
461
|
+
state.settle(false);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
case 'ws.message': {
|
|
465
|
+
const state = this.#peers.get(message.id);
|
|
466
|
+
if (!state || state.closed)
|
|
467
|
+
return;
|
|
468
|
+
try {
|
|
469
|
+
state.peer.send(message.payload.data);
|
|
470
|
+
}
|
|
471
|
+
catch { /* peer closing */ }
|
|
472
|
+
state.pendingAck += Math.max(1, wsMessageSize(message.payload.data));
|
|
473
|
+
this.#acknowledge(message.id, state, state.peer);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
case 'ws.close': {
|
|
477
|
+
const state = this.#peers.get(message.id);
|
|
478
|
+
if (!state)
|
|
479
|
+
return;
|
|
480
|
+
state.settle(false);
|
|
481
|
+
state.closePeer(message.payload.code, message.payload.reason);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
case 'ws.drain': {
|
|
485
|
+
const state = this.#peers.get(message.id);
|
|
486
|
+
if (state)
|
|
487
|
+
state.unacked = Math.max(0, state.unacked - message.payload.bytes);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
default:
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
#setTable(table) {
|
|
495
|
+
if (this.#tableReceived && table.revision <= this.#table.revision)
|
|
496
|
+
return;
|
|
497
|
+
this.#table = deepFreeze(table);
|
|
498
|
+
this.#tableReceived = true;
|
|
499
|
+
if (!this.#incompatible)
|
|
500
|
+
this.#emitRoutes();
|
|
501
|
+
}
|
|
502
|
+
#emitRoutes() {
|
|
503
|
+
const table = this.routes();
|
|
504
|
+
for (const listener of [...this.#routeListeners]) {
|
|
505
|
+
try {
|
|
506
|
+
listener(table);
|
|
507
|
+
}
|
|
508
|
+
catch { /* observers never break the bridge */ }
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
#send(message) {
|
|
512
|
+
try {
|
|
513
|
+
this.#port?.send(message);
|
|
514
|
+
}
|
|
515
|
+
catch { /* instance gone */ }
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
/** Codes a peer reported locally (1005 / 1006) travel as is; anything else invalid becomes 1000. */
|
|
519
|
+
function wireCloseCode(code) {
|
|
520
|
+
return Number.isInteger(code) && code >= 1000 && code <= 4999 ? code : 1000;
|
|
521
|
+
}
|
|
522
|
+
function toError(reason, fallback) {
|
|
523
|
+
return reason instanceof Error ? reason : new Error(typeof reason === 'string' ? reason : fallback);
|
|
524
|
+
}
|
|
525
|
+
function deepFreeze(value) {
|
|
526
|
+
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
527
|
+
for (const child of Object.values(value))
|
|
528
|
+
deepFreeze(child);
|
|
529
|
+
Object.freeze(value);
|
|
530
|
+
}
|
|
531
|
+
return value;
|
|
532
|
+
}
|