@statewalker/webrun-rpc 0.5.1 → 0.8.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.
- package/README.md +237 -12
- package/dist/connect-serve.d.ts +16 -1
- package/dist/connect-serve.d.ts.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +592 -64
- package/dist/listen-port.d.ts.map +1 -1
- package/dist/message-target.d.ts +14 -1
- package/dist/message-target.d.ts.map +1 -1
- package/dist/multiplex-port.d.ts +13 -2
- package/dist/multiplex-port.d.ts.map +1 -1
- package/dist/port-credit.d.ts +27 -0
- package/dist/port-credit.d.ts.map +1 -0
- package/dist/port-types.d.ts +153 -6
- package/dist/port-types.d.ts.map +1 -1
- package/dist/structured-codec.d.ts.map +1 -1
- package/dist/transfer-port-mux.d.ts.map +1 -1
- package/dist/virtual-port.d.ts +8 -2
- package/dist/virtual-port.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/connect-serve.ts +31 -10
- package/src/duplex-over-port.ts +8 -0
- package/src/index.ts +15 -2
- package/src/listen-port.ts +9 -2
- package/src/message-target.ts +14 -1
- package/src/multiplex-port.ts +682 -33
- package/src/port-credit.ts +61 -0
- package/src/port-types.ts +137 -8
- package/src/structured-codec.ts +9 -1
- package/src/transfer-port-mux.ts +32 -8
- package/src/virtual-port.ts +41 -11
package/src/multiplex-port.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
+
import { getPortCloseSignal } from "./close-signal.js";
|
|
1
2
|
import type { MessageTarget } from "./message-target.js";
|
|
2
|
-
import
|
|
3
|
+
import { nextTask, setPortSendState } from "./port-credit.js";
|
|
4
|
+
import type {
|
|
5
|
+
OpenPortOptions,
|
|
6
|
+
PortEnvelope,
|
|
7
|
+
PortInfo,
|
|
8
|
+
PortMux,
|
|
9
|
+
PortMuxOptions,
|
|
10
|
+
} from "./port-types.js";
|
|
3
11
|
import { newVirtualPort, type VirtualPortHandle } from "./virtual-port.js";
|
|
4
12
|
|
|
5
13
|
/**
|
|
@@ -8,30 +16,199 @@ import { newVirtualPort, type VirtualPortHandle } from "./virtual-port.js";
|
|
|
8
16
|
*/
|
|
9
17
|
export const MUX_CLOSED_MESSAGE = "webrun-rpc: the multiplexer was closed";
|
|
10
18
|
|
|
19
|
+
/**
|
|
20
|
+
* The id of keepalive's `ping` / `pong`: one no port ever takes. An older mux
|
|
21
|
+
* whose codec passes unknown envelopes through reads them as a close of that
|
|
22
|
+
* id, which must therefore not be a real port (0 is the initiator's first).
|
|
23
|
+
*/
|
|
24
|
+
const KEEPALIVE_ID = Number.MAX_SAFE_INTEGER;
|
|
25
|
+
|
|
26
|
+
/** The longest delay a timer holds; past it `setInterval` fires at once. */
|
|
27
|
+
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
28
|
+
|
|
11
29
|
/** Ceiling on concurrently open virtual ports. Bounds the id table only. */
|
|
12
30
|
export const DEFAULT_MAX_PORTS = 1024;
|
|
13
31
|
|
|
32
|
+
/** Default ceiling on one message reassembled from frames (`maxReassembledBytes`). */
|
|
33
|
+
export const DEFAULT_MAX_REASSEMBLED_BYTES = 64 * 1024 * 1024;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* What a message costs a port's window when the codec cannot say
|
|
37
|
+
* (`PortCodec.encode`): its bytes and string lengths, found in its values up
|
|
38
|
+
* to 4 levels deep (at most 256 values), and at least 64. Both ends compute it
|
|
39
|
+
* from the same structured value, so they agree.
|
|
40
|
+
*/
|
|
41
|
+
export function estimateMessageSize(payload: unknown): number {
|
|
42
|
+
let budget = 256;
|
|
43
|
+
const walk = (v: unknown, depth: number): number => {
|
|
44
|
+
if (budget-- <= 0) return 0;
|
|
45
|
+
if (typeof v === "string") return v.length;
|
|
46
|
+
if (v instanceof ArrayBuffer) return v.byteLength;
|
|
47
|
+
if (ArrayBuffer.isView(v)) return v.byteLength;
|
|
48
|
+
if (v === null || typeof v !== "object" || depth >= 4) return 8;
|
|
49
|
+
let n = 16;
|
|
50
|
+
if (Array.isArray(v)) for (const x of v) n += walk(x, depth + 1);
|
|
51
|
+
else for (const k in v) n += k.length + walk((v as Record<string, unknown>)[k], depth + 1);
|
|
52
|
+
return n;
|
|
53
|
+
};
|
|
54
|
+
return Math.max(64, walk(payload, 0));
|
|
55
|
+
}
|
|
56
|
+
|
|
14
57
|
/**
|
|
15
58
|
* The default `PortMux`: emulates multiplexing over a single port.
|
|
16
59
|
*
|
|
17
60
|
* A transport that already multiplexes natively supplies its own `PortMux`
|
|
18
61
|
* instead — this implementation is for transports that offer one pipe.
|
|
19
62
|
*/
|
|
20
|
-
export function multiplexPort(
|
|
63
|
+
export function multiplexPort(
|
|
64
|
+
port: MessageTarget,
|
|
65
|
+
options: PortMuxOptions,
|
|
66
|
+
): PortMux & { ports(): PortInfo[] } {
|
|
21
67
|
const {
|
|
22
68
|
codec,
|
|
23
69
|
onPort,
|
|
24
70
|
side = "initiator",
|
|
25
71
|
maxPorts = DEFAULT_MAX_PORTS,
|
|
26
72
|
maxMessageSize,
|
|
73
|
+
signal,
|
|
74
|
+
window,
|
|
75
|
+
frameSize,
|
|
76
|
+
keepalive,
|
|
77
|
+
onLeak,
|
|
27
78
|
} = options;
|
|
79
|
+
const maxReassembledBytes = options.maxReassembledBytes ?? DEFAULT_MAX_REASSEMBLED_BYTES;
|
|
80
|
+
const misses = keepalive?.misses ?? 3;
|
|
81
|
+
if (
|
|
82
|
+
keepalive &&
|
|
83
|
+
!(
|
|
84
|
+
Number.isFinite(keepalive.intervalMs) &&
|
|
85
|
+
keepalive.intervalMs > 0 &&
|
|
86
|
+
keepalive.intervalMs <= MAX_TIMER_MS &&
|
|
87
|
+
Number.isSafeInteger(misses) &&
|
|
88
|
+
misses > 0
|
|
89
|
+
)
|
|
90
|
+
) {
|
|
91
|
+
throw new RangeError(
|
|
92
|
+
`webrun-rpc: keepalive needs an intervalMs in (0, ${MAX_TIMER_MS}] and a positive integer misses`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
// A frame of no bytes would never advance the split: refuse it here.
|
|
96
|
+
if (frameSize != null && !(Number.isSafeInteger(frameSize) && frameSize > 0)) {
|
|
97
|
+
throw new RangeError(`webrun-rpc: frameSize must be a positive integer, got ${frameSize}`);
|
|
98
|
+
}
|
|
99
|
+
if (!(Number.isSafeInteger(maxReassembledBytes) && maxReassembledBytes > 0)) {
|
|
100
|
+
throw new RangeError(
|
|
101
|
+
`webrun-rpc: maxReassembledBytes must be a positive integer, got ${maxReassembledBytes}`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
28
104
|
|
|
29
|
-
|
|
105
|
+
/**
|
|
106
|
+
* Flow control of one port (`PortMuxOptions.window`): the send side's queue
|
|
107
|
+
* and the bytes delivered but not yet consumed, and the receive side's
|
|
108
|
+
* consumed bytes not yet granted back.
|
|
109
|
+
*/
|
|
110
|
+
interface Credit {
|
|
111
|
+
window: number;
|
|
112
|
+
/**
|
|
113
|
+
* The peer applies the window. Until it says so (the responder's
|
|
114
|
+
* zero-byte `credit`), bytes are counted but nothing waits: an older peer
|
|
115
|
+
* never sends credit, and its ports must run as they did before.
|
|
116
|
+
*/
|
|
117
|
+
enforced: boolean;
|
|
118
|
+
/** This side opened the port; the other answers its window with an ack. */
|
|
119
|
+
opener: boolean;
|
|
120
|
+
/**
|
|
121
|
+
* Both ends count encoded bytes (`PortCodec.encode` / `decode`), and so
|
|
122
|
+
* may split a large message into frames (`frameSize`). If either cannot,
|
|
123
|
+
* both count the shared estimate and send messages whole: two ends
|
|
124
|
+
* counting different sizes would leak window on every message until the
|
|
125
|
+
* port wedged.
|
|
126
|
+
*/
|
|
127
|
+
exact: boolean;
|
|
128
|
+
outstanding: number;
|
|
129
|
+
queue: { send: (whole?: boolean) => void; size: number }[];
|
|
130
|
+
queued: number;
|
|
131
|
+
unconsumed: number;
|
|
132
|
+
waiters: { below: number; resolve: () => void }[];
|
|
133
|
+
toGrant: number;
|
|
134
|
+
grantScheduled: boolean;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* What the mux keeps of a port: never the consumer's `port` object itself,
|
|
138
|
+
* so a port its consumer dropped can be collected and reported (`onLeak`).
|
|
139
|
+
*/
|
|
140
|
+
type PortRecord = Pick<
|
|
141
|
+
VirtualPortHandle,
|
|
142
|
+
"deliver" | "markClosed" | "isClosed" | "hasListeners"
|
|
143
|
+
> & {
|
|
144
|
+
/** Close the port as its consumer's `close()` would, without its object. */
|
|
145
|
+
close(reason: unknown): void;
|
|
146
|
+
};
|
|
147
|
+
interface Entry extends PortInfo {
|
|
148
|
+
handle: PortRecord;
|
|
149
|
+
credit?: Credit;
|
|
150
|
+
/**
|
|
151
|
+
* The frames of a message still arriving. `room`: what the window left
|
|
152
|
+
* when the first came, if anything was still owed back then. `charged`:
|
|
153
|
+
* its bytes past the window, counted in `reassembling`.
|
|
154
|
+
*/
|
|
155
|
+
partial?: {
|
|
156
|
+
chunks: Uint8Array[];
|
|
157
|
+
bytes: number;
|
|
158
|
+
room?: number;
|
|
159
|
+
owed: number;
|
|
160
|
+
charged: number;
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const open = new Map<number, Entry>();
|
|
164
|
+
// Bytes of partial messages held past their port's window, across every
|
|
165
|
+
// port (`maxReassembledBytes`). What fits a window is bounded by it already.
|
|
166
|
+
let reassembling = 0;
|
|
167
|
+
const freePartial = (entry: Entry | undefined): void => {
|
|
168
|
+
if (!entry?.partial) return;
|
|
169
|
+
reassembling -= entry.partial.charged;
|
|
170
|
+
entry.partial = undefined;
|
|
171
|
+
};
|
|
172
|
+
// A port collected while still open: report it, then free its slot on both
|
|
173
|
+
// ends as its close would. Only with `onLeak` — a registry costs per port.
|
|
174
|
+
const leaks = onLeak
|
|
175
|
+
? new FinalizationRegistry<{
|
|
176
|
+
id: number;
|
|
177
|
+
record: PortRecord;
|
|
178
|
+
release: (reason: unknown) => void;
|
|
179
|
+
}>(({ id, record, release }) => {
|
|
180
|
+
const entry = open.get(id);
|
|
181
|
+
if (entry?.handle !== record) return;
|
|
182
|
+
const { handle: _, credit: __, partial: ___, ...info } = entry;
|
|
183
|
+
// A port whose consumer still listens is still receiving (a
|
|
184
|
+
// subscription that keeps only its listener): report it, but leave it
|
|
185
|
+
// open. One with no listener can neither send nor receive any more.
|
|
186
|
+
if (!record.hasListeners()) {
|
|
187
|
+
release("leaked");
|
|
188
|
+
record.markClosed(new Error("webrun-rpc: the port was dropped without close()"));
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
onLeak?.(info);
|
|
192
|
+
} catch {
|
|
193
|
+
// A reporter's fault must not escape into the finalizer.
|
|
194
|
+
}
|
|
195
|
+
})
|
|
196
|
+
: undefined;
|
|
197
|
+
const touch = (id: number): void => {
|
|
198
|
+
const entry = open.get(id);
|
|
199
|
+
if (entry) entry.lastActivity = Date.now();
|
|
200
|
+
};
|
|
30
201
|
let nextId = side === "initiator" ? 0 : 1;
|
|
31
202
|
let muxClosed = false;
|
|
203
|
+
/** This end counts encoded bytes and reassembles frames. */
|
|
204
|
+
const exactCodec = codec.encode != null && codec.decode != null;
|
|
32
205
|
|
|
33
206
|
const post = (envelope: PortEnvelope, transfer?: Transferable[]): void => {
|
|
34
207
|
if (muxClosed) return;
|
|
208
|
+
codecPost(envelope, transfer);
|
|
209
|
+
};
|
|
210
|
+
/** Like `post`, also while closing: the mux's own close notices. */
|
|
211
|
+
const codecPost = (envelope: PortEnvelope, transfer?: Transferable[]): void => {
|
|
35
212
|
try {
|
|
36
213
|
codec.post(port, envelope, transfer);
|
|
37
214
|
} catch {
|
|
@@ -40,22 +217,263 @@ export function multiplexPort(port: MessageTarget, options: PortMuxOptions): Por
|
|
|
40
217
|
}
|
|
41
218
|
};
|
|
42
219
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
220
|
+
// FAIR FRAMING (`frameSize`): what each port has to put on the pipe, taken
|
|
221
|
+
// one item per port in turn whenever the pipe has room (`whenDrained`). A
|
|
222
|
+
// large message is split into frames first, so a small one on another port
|
|
223
|
+
// waits for one frame, not for the whole message. Without `frameSize`
|
|
224
|
+
// everything is posted at once, as before.
|
|
225
|
+
const outgoing = new Map<number, Array<() => void>>();
|
|
226
|
+
const ring: number[] = [];
|
|
227
|
+
let pumping = false;
|
|
228
|
+
const pump = async (): Promise<void> => {
|
|
229
|
+
pumping = true;
|
|
230
|
+
try {
|
|
231
|
+
while (ring.length && !muxClosed) {
|
|
232
|
+
// A pipe without the method has no way to say it is full; the loop
|
|
233
|
+
// then runs to the end synchronously.
|
|
234
|
+
if (port.whenDrained) await port.whenDrained();
|
|
235
|
+
if (muxClosed) break;
|
|
236
|
+
// biome-ignore lint/style/noNonNullAssertion: the loop condition checks the ring is not empty.
|
|
237
|
+
const id = ring.shift()!;
|
|
238
|
+
const queue = outgoing.get(id);
|
|
239
|
+
const item = queue?.shift();
|
|
240
|
+
if (queue?.length) ring.push(id);
|
|
241
|
+
else outgoing.delete(id);
|
|
242
|
+
item?.();
|
|
243
|
+
}
|
|
244
|
+
} catch (err) {
|
|
245
|
+
// The pipe failed while this mux waited for room: nothing queued can
|
|
246
|
+
// go out now, and the ports must hear so rather than wait forever.
|
|
247
|
+
close(err).catch(() => {});
|
|
248
|
+
} finally {
|
|
249
|
+
pumping = false;
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
const sendOnPort = (id: number, item: () => void): void => {
|
|
253
|
+
if (frameSize == null) {
|
|
254
|
+
item();
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
let queue = outgoing.get(id);
|
|
258
|
+
if (!queue) {
|
|
259
|
+
queue = [];
|
|
260
|
+
outgoing.set(id, queue);
|
|
261
|
+
ring.push(id);
|
|
262
|
+
}
|
|
263
|
+
queue.push(item);
|
|
264
|
+
if (!pumping) void pump();
|
|
265
|
+
};
|
|
266
|
+
const dropOutgoing = (id: number): void => {
|
|
267
|
+
if (!outgoing.delete(id)) return;
|
|
268
|
+
const at = ring.indexOf(id);
|
|
269
|
+
if (at >= 0) ring.splice(at, 1);
|
|
270
|
+
};
|
|
271
|
+
const postBytes = (bytes: Uint8Array): void => {
|
|
272
|
+
if (muxClosed) return;
|
|
273
|
+
try {
|
|
274
|
+
port.postMessage(bytes);
|
|
275
|
+
} catch {
|
|
276
|
+
// The underlying port is gone, as in `post`.
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const releaseWaiters = (c: Credit | undefined): void => {
|
|
281
|
+
for (const w of c?.waiters.splice(0) ?? []) w.resolve();
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const flush = (c: Credit): void => {
|
|
285
|
+
while (c.queue.length) {
|
|
286
|
+
// biome-ignore lint/style/noNonNullAssertion: the loop condition checks the queue is not empty.
|
|
287
|
+
const next = c.queue[0]!;
|
|
288
|
+
// A message larger than the window goes out once nothing else is outstanding.
|
|
289
|
+
if (c.enforced && c.outstanding > 0 && c.outstanding + next.size > c.window) break;
|
|
290
|
+
c.queue.shift();
|
|
291
|
+
c.queued -= next.size;
|
|
292
|
+
c.outstanding += next.size;
|
|
293
|
+
next.send();
|
|
294
|
+
}
|
|
295
|
+
for (const w of [...c.waiters]) {
|
|
296
|
+
if (c.queued <= w.below) {
|
|
297
|
+
c.waiters.splice(c.waiters.indexOf(w), 1);
|
|
298
|
+
w.resolve();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
// Credit goes back in batches: at once from a quarter of the window, else on
|
|
304
|
+
// the next task (so a burst of small messages costs one credit envelope, and
|
|
305
|
+
// a quiet port still gets its last bytes back).
|
|
306
|
+
const grant = (id: number, c: Credit, bytes: number): void => {
|
|
307
|
+
c.unconsumed -= bytes;
|
|
308
|
+
c.toGrant += bytes;
|
|
309
|
+
const send = () => {
|
|
310
|
+
c.grantScheduled = false;
|
|
311
|
+
if (!c.toGrant || !open.has(id)) return;
|
|
312
|
+
post({ type: "credit", id, bytes: c.toGrant });
|
|
313
|
+
c.toGrant = 0;
|
|
314
|
+
};
|
|
315
|
+
if (c.toGrant >= c.window / 4) {
|
|
316
|
+
send();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (c.grantScheduled) return;
|
|
320
|
+
c.grantScheduled = true;
|
|
321
|
+
setTimeout(send, 0);
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const attach = (
|
|
325
|
+
id: number,
|
|
326
|
+
meta: unknown,
|
|
327
|
+
portWindow: unknown,
|
|
328
|
+
opener: boolean,
|
|
329
|
+
exact: boolean,
|
|
330
|
+
): VirtualPortHandle => {
|
|
331
|
+
// A peer's window must be a positive integer; anything else means none.
|
|
332
|
+
const c: Credit | undefined =
|
|
333
|
+
typeof portWindow === "number" && Number.isSafeInteger(portWindow) && portWindow > 0
|
|
334
|
+
? {
|
|
335
|
+
window: portWindow,
|
|
336
|
+
// Nobody holds back until the other end has shown it reads credit:
|
|
337
|
+
// the responder's ack proves that to the opener, and the opener's
|
|
338
|
+
// answer to the ack proves it to the responder.
|
|
339
|
+
enforced: false,
|
|
340
|
+
opener,
|
|
341
|
+
exact,
|
|
342
|
+
outstanding: 0,
|
|
343
|
+
queue: [],
|
|
344
|
+
queued: 0,
|
|
345
|
+
unconsumed: 0,
|
|
346
|
+
waiters: [],
|
|
347
|
+
toGrant: 0,
|
|
348
|
+
grantScheduled: false,
|
|
349
|
+
}
|
|
350
|
+
: undefined;
|
|
351
|
+
const self = newVirtualPort(
|
|
352
|
+
(payload, transfer) => {
|
|
353
|
+
touch(id);
|
|
354
|
+
const envelope: PortEnvelope = { type: "message", id, payload };
|
|
355
|
+
// Without a window nothing frames or waits: post at once, so the
|
|
356
|
+
// payload is read now, while the caller still expects it to be.
|
|
357
|
+
if (!c) {
|
|
358
|
+
post(envelope, transfer);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
// Encoded once, here, when the codec can: the exact wire size is then
|
|
362
|
+
// what the window counts, on both ends, and the bytes are what frames split.
|
|
363
|
+
const bytes = c.exact ? codec.encode?.(envelope) : undefined;
|
|
364
|
+
const size = bytes?.byteLength ?? estimateMessageSize(payload);
|
|
365
|
+
// A message that will wait (for credit, or for the pump when framing)
|
|
366
|
+
// is copied now, as `postMessage` would copy it at once: the caller may
|
|
367
|
+
// reuse its buffer as soon as it returns. Encoded bytes are a copy already.
|
|
368
|
+
const waits =
|
|
369
|
+
frameSize != null ||
|
|
370
|
+
c.queue.length > 0 ||
|
|
371
|
+
(c.enforced && c.outstanding > 0 && c.outstanding + size > c.window);
|
|
372
|
+
const held =
|
|
373
|
+
!bytes && waits
|
|
374
|
+
? { ...envelope, payload: structuredClone(payload, { transfer }) }
|
|
375
|
+
: undefined;
|
|
376
|
+
// `whole`: at close, what is still queued leaves past the window, and
|
|
377
|
+
// a frame past the window is what the peer refuses (`maxReassembledBytes`).
|
|
378
|
+
const send = (whole = false): void => {
|
|
379
|
+
if (!bytes) {
|
|
380
|
+
sendOnPort(id, held ? () => post(held) : () => post(envelope, transfer));
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
// Frames only once the peer has acknowledged exact sizing: before
|
|
384
|
+
// that, it may be a peer that cannot reassemble them.
|
|
385
|
+
if (
|
|
386
|
+
!whole &&
|
|
387
|
+
frameSize != null &&
|
|
388
|
+
c.enforced &&
|
|
389
|
+
c.exact &&
|
|
390
|
+
bytes.byteLength > frameSize
|
|
391
|
+
) {
|
|
392
|
+
for (let at = 0; at < bytes.byteLength; at += frameSize) {
|
|
393
|
+
const data = bytes.subarray(at, at + frameSize);
|
|
394
|
+
const frame: PortEnvelope =
|
|
395
|
+
at + frameSize >= bytes.byteLength
|
|
396
|
+
? { type: "frame", id, data, last: true }
|
|
397
|
+
: { type: "frame", id, data };
|
|
398
|
+
sendOnPort(id, () => post(frame));
|
|
399
|
+
}
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
sendOnPort(id, () => postBytes(bytes));
|
|
403
|
+
};
|
|
404
|
+
c.queue.push({ send, size });
|
|
405
|
+
c.queued += size;
|
|
406
|
+
flush(c);
|
|
407
|
+
},
|
|
47
408
|
(reason) => {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
self.markClosed();
|
|
409
|
+
release(reason);
|
|
410
|
+
record.markClosed(reason);
|
|
51
411
|
},
|
|
52
412
|
);
|
|
53
|
-
|
|
413
|
+
function release(reason: unknown): void {
|
|
414
|
+
freePartial(open.get(id));
|
|
415
|
+
open.delete(id);
|
|
416
|
+
leaks?.unregister(record);
|
|
417
|
+
// What is queued goes out now, before the close and regardless of the
|
|
418
|
+
// window: a peer that stopped returning credit (a listener that never
|
|
419
|
+
// settles) must still learn that the port is closed, and nothing is
|
|
420
|
+
// kept for a port nobody holds. Without a window it would all have
|
|
421
|
+
// gone out at once anyway.
|
|
422
|
+
if (c?.queue.length) {
|
|
423
|
+
for (const item of c.queue.splice(0)) item.send(true);
|
|
424
|
+
c.queued = 0;
|
|
425
|
+
}
|
|
426
|
+
// Behind this port's frames, so the peer gets the whole message first.
|
|
427
|
+
sendOnPort(id, () => post({ type: "close", id, reason }));
|
|
428
|
+
releaseWaiters(c);
|
|
429
|
+
}
|
|
430
|
+
// No closure below may refer to `self`: closures in one scope share it, and
|
|
431
|
+
// the ones the mux keeps would then hold the consumer's port (`onLeak`).
|
|
432
|
+
const { markClosed, isClosed, hasListeners } = self;
|
|
433
|
+
if (c) {
|
|
434
|
+
const deliver = self.deliver;
|
|
435
|
+
self.deliver = (payload, size = estimateMessageSize(payload)) => {
|
|
436
|
+
c.unconsumed += size;
|
|
437
|
+
const done = deliver(payload);
|
|
438
|
+
if (done) void done.then(() => grant(id, c, size));
|
|
439
|
+
else grant(id, c, size);
|
|
440
|
+
return done;
|
|
441
|
+
};
|
|
442
|
+
setPortSendState(self.port, {
|
|
443
|
+
bufferedAmount: () => c.queued,
|
|
444
|
+
ready: (below = 0) =>
|
|
445
|
+
new Promise<void>((resolve) => {
|
|
446
|
+
if (c.queued <= below || isClosed()) nextTask(resolve);
|
|
447
|
+
else c.waiters.push({ below, resolve: () => nextTask(resolve) });
|
|
448
|
+
}),
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
const record: PortRecord = {
|
|
452
|
+
deliver: self.deliver,
|
|
453
|
+
markClosed,
|
|
454
|
+
isClosed,
|
|
455
|
+
hasListeners,
|
|
456
|
+
close: (reason) => {
|
|
457
|
+
if (isClosed()) return;
|
|
458
|
+
release(reason);
|
|
459
|
+
markClosed(reason);
|
|
460
|
+
},
|
|
461
|
+
};
|
|
462
|
+
leaks?.register(self.port, { id, record, release }, record);
|
|
463
|
+
const now = Date.now();
|
|
464
|
+
open.set(id, { id, meta, openedAt: now, lastActivity: now, handle: record, credit: c });
|
|
54
465
|
return self;
|
|
55
466
|
};
|
|
56
467
|
|
|
57
|
-
const handleEnvelope = (envelope: PortEnvelope): void => {
|
|
468
|
+
const handleEnvelope = (envelope: PortEnvelope, event: MessageEvent): void => {
|
|
58
469
|
if (muxClosed) return;
|
|
470
|
+
// Anything from the peer shows it is alive.
|
|
471
|
+
heardSincePing = true;
|
|
472
|
+
if (envelope.type === "ping" || envelope.type === "pong") {
|
|
473
|
+
peerKeepsAlive = true;
|
|
474
|
+
if (envelope.type === "ping") post({ type: "pong", id: KEEPALIVE_ID });
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
59
477
|
|
|
60
478
|
if (envelope.type === "open") {
|
|
61
479
|
// A duplicate id is a peer bug; ignoring it is safer than replacing a
|
|
@@ -65,7 +483,25 @@ export function multiplexPort(port: MessageTarget, options: PortMuxOptions): Por
|
|
|
65
483
|
post({ type: "close", id: envelope.id, reason: "max-ports" });
|
|
66
484
|
return;
|
|
67
485
|
}
|
|
68
|
-
|
|
486
|
+
// The opener asked for a window and is known to speak it.
|
|
487
|
+
const handle = attach(
|
|
488
|
+
envelope.id,
|
|
489
|
+
envelope.meta,
|
|
490
|
+
envelope.window,
|
|
491
|
+
false,
|
|
492
|
+
envelope.exact === true && exactCodec,
|
|
493
|
+
);
|
|
494
|
+
// Acknowledge the window before `onPort` runs: what `onPort` posts then
|
|
495
|
+
// reaches the opener after the ack, so both ends count it the same way
|
|
496
|
+
// (exact bytes or the estimate). A rejection's close follows the ack.
|
|
497
|
+
const credit = open.get(envelope.id)?.credit;
|
|
498
|
+
if (credit) {
|
|
499
|
+
post(
|
|
500
|
+
credit.exact
|
|
501
|
+
? { type: "credit", id: envelope.id, bytes: 0, exact: true }
|
|
502
|
+
: { type: "credit", id: envelope.id, bytes: 0 },
|
|
503
|
+
);
|
|
504
|
+
}
|
|
69
505
|
let accepted = false;
|
|
70
506
|
if (onPort) {
|
|
71
507
|
try {
|
|
@@ -78,41 +514,205 @@ export function multiplexPort(port: MessageTarget, options: PortMuxOptions): Por
|
|
|
78
514
|
open.delete(envelope.id);
|
|
79
515
|
handle.markClosed(new Error("webrun-rpc: the port was rejected"));
|
|
80
516
|
post({ type: "close", id: envelope.id, reason: "rejected" });
|
|
517
|
+
return;
|
|
81
518
|
}
|
|
519
|
+
|
|
82
520
|
return;
|
|
83
521
|
}
|
|
84
522
|
|
|
85
|
-
const
|
|
523
|
+
const entry = open.get(envelope.id);
|
|
524
|
+
|
|
525
|
+
if (envelope.type === "credit") {
|
|
526
|
+
const c = entry?.credit;
|
|
527
|
+
if (!c || !(envelope.bytes >= 0)) return;
|
|
528
|
+
if (!c.enforced && c.opener) {
|
|
529
|
+
// The ack. It also settles the sizing: if the responder cannot count
|
|
530
|
+
// exact bytes, both ends fall back to the estimate, and what was
|
|
531
|
+
// counted exactly before the ack no longer matches what comes back.
|
|
532
|
+
if (c.exact && envelope.exact !== true) {
|
|
533
|
+
c.exact = false;
|
|
534
|
+
c.outstanding = 0;
|
|
535
|
+
}
|
|
536
|
+
// Show the responder its codec reads credit, so it may hold back too.
|
|
537
|
+
post({ type: "credit", id: envelope.id, bytes: 0 });
|
|
538
|
+
}
|
|
539
|
+
c.enforced = true;
|
|
540
|
+
c.outstanding = Math.max(0, c.outstanding - envelope.bytes);
|
|
541
|
+
flush(c);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
86
544
|
|
|
87
545
|
if (envelope.type === "message") {
|
|
88
546
|
// Drop, never queue. An id that was rejected, never opened, or already
|
|
89
547
|
// closed has no consumer, and holding its traffic would be exactly the
|
|
90
548
|
// buffering layer 1 refuses to do.
|
|
91
|
-
|
|
92
|
-
|
|
549
|
+
const sizeOf = (c: Credit) =>
|
|
550
|
+
(c.exact ? byteLengthOf(event.data) : undefined) ?? estimateMessageSize(envelope.payload);
|
|
551
|
+
if (!entry) return;
|
|
552
|
+
touch(envelope.id);
|
|
553
|
+
entry.handle.deliver(envelope.payload, entry.credit ? sizeOf(entry.credit) : undefined);
|
|
93
554
|
return;
|
|
94
555
|
}
|
|
95
556
|
|
|
96
|
-
if (
|
|
557
|
+
if (envelope.type === "frame") {
|
|
558
|
+
// Only where this end advertised exact sizing — the one place a peer may
|
|
559
|
+
// frame. What a partial message may hold is bounded below, so frames
|
|
560
|
+
// that never end cannot make this side buffer without limit.
|
|
561
|
+
if (!entry?.credit?.exact || !codec.decode || !(envelope.data instanceof Uint8Array)) return;
|
|
562
|
+
touch(envelope.id);
|
|
563
|
+
const c = entry.credit;
|
|
564
|
+
// What this side still owes back (delivered and unconsumed, or consumed
|
|
565
|
+
// and not yet granted) is never more than what the sender counted as
|
|
566
|
+
// outstanding. So an honest sender starts a message only when owed plus
|
|
567
|
+
// the message fits the window, or when nothing is owed at all: a message
|
|
568
|
+
// larger than the window, bounded by `maxReassembledBytes` alone.
|
|
569
|
+
const owed = c.unconsumed + c.toGrant;
|
|
570
|
+
const partial = entry.partial ?? {
|
|
571
|
+
chunks: [],
|
|
572
|
+
bytes: 0,
|
|
573
|
+
owed,
|
|
574
|
+
charged: 0,
|
|
575
|
+
...(owed > 0 ? { room: c.window - owed } : {}),
|
|
576
|
+
};
|
|
577
|
+
entry.partial = partial;
|
|
578
|
+
const size = envelope.data.byteLength;
|
|
579
|
+
// Only a message begun with nothing owed may pass the window; what it
|
|
580
|
+
// holds past it is what the cap, shared by all ports, bounds.
|
|
581
|
+
const charged = partial.room == null ? Math.max(0, partial.bytes + size - c.window) : 0;
|
|
582
|
+
const overrun =
|
|
583
|
+
partial.room != null && partial.bytes + size > partial.room
|
|
584
|
+
? `what the window left (${partial.room} of ${c.window} bytes, ${partial.owed} still owed)`
|
|
585
|
+
: reassembling - partial.charged + charged > maxReassembledBytes
|
|
586
|
+
? `maxReassembledBytes (${maxReassembledBytes} bytes past the window, across all ports)`
|
|
587
|
+
: undefined;
|
|
588
|
+
if (overrun) {
|
|
589
|
+
// A peer that frames past what it may send: hold none of it.
|
|
590
|
+
freePartial(entry);
|
|
591
|
+
dropOutgoing(envelope.id);
|
|
592
|
+
open.delete(envelope.id);
|
|
593
|
+
releaseWaiters(c);
|
|
594
|
+
entry.handle.markClosed(new RangeError(`webrun-rpc: a framed message passed ${overrun}`));
|
|
595
|
+
post({ type: "close", id: envelope.id, reason: "max-reassembled-bytes" });
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
partial.bytes += size;
|
|
599
|
+
reassembling += charged - partial.charged;
|
|
600
|
+
partial.charged = charged;
|
|
601
|
+
partial.chunks.push(envelope.data);
|
|
602
|
+
if (!envelope.last) return;
|
|
603
|
+
freePartial(entry);
|
|
604
|
+
const whole = new Uint8Array(partial.bytes);
|
|
605
|
+
let at = 0;
|
|
606
|
+
for (const chunk of partial.chunks) {
|
|
607
|
+
whole.set(chunk, at);
|
|
608
|
+
at += chunk.byteLength;
|
|
609
|
+
}
|
|
610
|
+
const inner = codec.decode(whole);
|
|
611
|
+
if (inner?.type !== "message" || inner.id !== envelope.id) return;
|
|
612
|
+
entry.handle.deliver(inner.payload, whole.byteLength);
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// The peer closed the port: whatever it still had to receive is moot.
|
|
617
|
+
dropOutgoing(envelope.id);
|
|
618
|
+
if (!entry) return;
|
|
619
|
+
freePartial(entry);
|
|
97
620
|
open.delete(envelope.id);
|
|
98
|
-
|
|
621
|
+
releaseWaiters(entry.credit);
|
|
622
|
+
entry.handle.markClosed(new Error("webrun-rpc: the peer closed the port"));
|
|
99
623
|
};
|
|
100
624
|
|
|
101
625
|
const listener = (event: MessageEvent): void => {
|
|
102
626
|
const envelope = codec.read(event);
|
|
103
|
-
if (envelope) handleEnvelope(envelope);
|
|
627
|
+
if (envelope) handleEnvelope(envelope, event);
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
// KEEPALIVE: a MessagePort reports neither death nor a frozen peer, so the
|
|
631
|
+
// mux probes. It counts pings that went unanswered — nothing at all from the
|
|
632
|
+
// peer between one ping and the next tick — rather than time since the last
|
|
633
|
+
// message, so timer jitter cannot turn a prompt answer into a miss. It holds
|
|
634
|
+
// a peer to it only once that peer has answered or pinged: an older one,
|
|
635
|
+
// which never does, is never closed for it.
|
|
636
|
+
let peerKeepsAlive = false;
|
|
637
|
+
let heardSincePing = true;
|
|
638
|
+
let unanswered = 0;
|
|
639
|
+
let pinger: ReturnType<typeof setInterval> | undefined;
|
|
640
|
+
const tick = (): void => {
|
|
641
|
+
if (!keepalive) return;
|
|
642
|
+
// A ping still in this end's own transport buffer has not reached the
|
|
643
|
+
// peer: a backlog of ours is not the peer's silence.
|
|
644
|
+
const backlogged = (port.bufferedAmount ?? 0) > 0;
|
|
645
|
+
if (heardSincePing) unanswered = 0;
|
|
646
|
+
else if (peerKeepsAlive && !backlogged) unanswered++;
|
|
647
|
+
if (unanswered >= misses) {
|
|
648
|
+
close(
|
|
649
|
+
new Error(
|
|
650
|
+
`webrun-rpc: the peer did not answer ${misses} keepalive pings, ${keepalive.intervalMs} ms apart`,
|
|
651
|
+
),
|
|
652
|
+
).catch(() => {});
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
heardSincePing = false;
|
|
656
|
+
post({ type: "ping", id: KEEPALIVE_ID });
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
const close = async (reason?: unknown): Promise<void> => {
|
|
660
|
+
if (muxClosed) return;
|
|
661
|
+
clearInterval(pinger);
|
|
662
|
+
// Closed BEFORE the ports: `markClosed` runs their close-signal listeners
|
|
663
|
+
// synchronously, and one that reopens (reconnect logic) must be refused
|
|
664
|
+
// rather than add a port after the snapshot below, which nothing would
|
|
665
|
+
// ever close.
|
|
666
|
+
muxClosed = true;
|
|
667
|
+
signal?.removeEventListener("abort", onTransportAbort);
|
|
668
|
+
port.removeEventListener("message", listener);
|
|
669
|
+
const closedNow = new Set<number>();
|
|
670
|
+
for (const [id, { handle, credit }] of [...open]) {
|
|
671
|
+
// A port that a close-signal listener closed meanwhile is still sent its close here: its own
|
|
672
|
+
// close() posts nothing once the mux is closing.
|
|
673
|
+
closedNow.add(id);
|
|
674
|
+
codecPost({ type: "close", id });
|
|
675
|
+
open.delete(id);
|
|
676
|
+
releaseWaiters(credit);
|
|
677
|
+
handle.markClosed(reason ?? new Error(MUX_CLOSED_MESSAGE));
|
|
678
|
+
}
|
|
679
|
+
// Ports closed here whose close still waits behind their frames: it goes
|
|
680
|
+
// now, or the peer would hold them open forever.
|
|
681
|
+
for (const id of outgoing.keys()) if (!closedNow.has(id)) codecPost({ type: "close", id });
|
|
682
|
+
outgoing.clear();
|
|
683
|
+
ring.length = 0;
|
|
684
|
+
await port.close?.();
|
|
685
|
+
};
|
|
686
|
+
// Nobody awaits the transport's close on this path, and a dead transport's
|
|
687
|
+
// `close()` often rejects (a stream already reset): never let that become
|
|
688
|
+
// an unhandled rejection.
|
|
689
|
+
const onTransportAbort = (): void => {
|
|
690
|
+
close(signal?.reason).catch(() => {});
|
|
104
691
|
};
|
|
105
692
|
|
|
106
693
|
port.addEventListener("message", listener);
|
|
107
694
|
void port.start?.();
|
|
695
|
+
// Started only once the pipe is wired, so a constructor that throws above
|
|
696
|
+
// leaves no timer behind.
|
|
697
|
+
if (keepalive) {
|
|
698
|
+
pinger = setInterval(tick, keepalive.intervalMs);
|
|
699
|
+
// A probe must not keep a Node process alive on its own.
|
|
700
|
+
(pinger as { unref?: () => void }).unref?.();
|
|
701
|
+
}
|
|
702
|
+
if (signal?.aborted) close(signal.reason).catch(() => {});
|
|
703
|
+
else signal?.addEventListener("abort", onTransportAbort, { once: true });
|
|
108
704
|
|
|
109
705
|
return {
|
|
110
706
|
maxMessageSize,
|
|
111
707
|
|
|
112
|
-
async openPort(meta?: unknown
|
|
708
|
+
async openPort(meta?: unknown, { signal: portSignal }: OpenPortOptions = {}) {
|
|
113
709
|
if (muxClosed) throw new Error("webrun-rpc: the multiplexer is closed");
|
|
710
|
+
// As `fetch` does: a signal already aborted opens nothing.
|
|
711
|
+
if (portSignal?.aborted) throw portSignal.reason;
|
|
114
712
|
if (open.size >= maxPorts) {
|
|
115
|
-
throw new RangeError(
|
|
713
|
+
throw new RangeError(
|
|
714
|
+
`webrun-rpc: maxPorts (${maxPorts}) reached; open ports by kind: ${describeKinds(open.values())}`,
|
|
715
|
+
);
|
|
116
716
|
}
|
|
117
717
|
// A hostile or misconfigured peer may have opened using our own
|
|
118
718
|
// parity. Skipping a claimed id costs one line and keeps a local open
|
|
@@ -120,21 +720,70 @@ export function multiplexPort(port: MessageTarget, options: PortMuxOptions): Por
|
|
|
120
720
|
while (open.has(nextId)) nextId += 2;
|
|
121
721
|
const id = nextId;
|
|
122
722
|
nextId += 2;
|
|
123
|
-
const handle = attach(id);
|
|
124
|
-
post(
|
|
723
|
+
const handle = attach(id, meta, window, true, exactCodec);
|
|
724
|
+
post(
|
|
725
|
+
window
|
|
726
|
+
? exactCodec
|
|
727
|
+
? { type: "open", id, meta, window, exact: true }
|
|
728
|
+
: { type: "open", id, meta, window }
|
|
729
|
+
: { type: "open", id, meta },
|
|
730
|
+
);
|
|
731
|
+
if (portSignal) {
|
|
732
|
+
// Through the mux's record, not the port: a long-lived signal must not
|
|
733
|
+
// keep a dropped port reachable, and must still free it once dropped.
|
|
734
|
+
const record = open.get(id)?.handle;
|
|
735
|
+
const onAbort = () => {
|
|
736
|
+
if (record && open.get(id)?.handle === record) record.close(portSignal.reason);
|
|
737
|
+
};
|
|
738
|
+
portSignal.addEventListener("abort", onAbort, { once: true });
|
|
739
|
+
getPortCloseSignal(handle.port)?.addEventListener(
|
|
740
|
+
"abort",
|
|
741
|
+
() => portSignal.removeEventListener("abort", onAbort),
|
|
742
|
+
{ once: true },
|
|
743
|
+
);
|
|
744
|
+
}
|
|
125
745
|
return handle.port;
|
|
126
746
|
},
|
|
127
747
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
748
|
+
close,
|
|
749
|
+
|
|
750
|
+
ports(): PortInfo[] {
|
|
751
|
+
return [...open.values()].map(({ handle: _, credit, ...info }) =>
|
|
752
|
+
credit
|
|
753
|
+
? {
|
|
754
|
+
...info,
|
|
755
|
+
...(credit.enforced ? { window: credit.window } : {}),
|
|
756
|
+
queued: credit.queued,
|
|
757
|
+
outstanding: credit.outstanding,
|
|
758
|
+
unconsumed: credit.unconsumed,
|
|
759
|
+
}
|
|
760
|
+
: info,
|
|
761
|
+
);
|
|
138
762
|
},
|
|
139
763
|
};
|
|
140
764
|
}
|
|
765
|
+
|
|
766
|
+
/** The byte length of a received message's data, or `undefined` if it is not bytes. */
|
|
767
|
+
function byteLengthOf(data: unknown): number | undefined {
|
|
768
|
+
if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) return data.byteLength;
|
|
769
|
+
return undefined;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* `"http: 1000, ws: 24"`: the three commonest `meta.kind`s among `entries`, so
|
|
774
|
+
* a full table names who filled it. A port without a kind counts as `"?"`.
|
|
775
|
+
*/
|
|
776
|
+
function describeKinds(entries: Iterable<PortInfo>): string {
|
|
777
|
+
const counts = new Map<string, number>();
|
|
778
|
+
for (const { meta } of entries) {
|
|
779
|
+
const kind = (meta as { kind?: unknown } | undefined)?.kind;
|
|
780
|
+
const name = typeof kind === "string" ? kind : "?";
|
|
781
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
782
|
+
}
|
|
783
|
+
if (!counts.size) return "none";
|
|
784
|
+
return [...counts]
|
|
785
|
+
.sort((x, y) => y[1] - x[1])
|
|
786
|
+
.slice(0, 3)
|
|
787
|
+
.map(([name, n]) => `${name}: ${n}`)
|
|
788
|
+
.join(", ");
|
|
789
|
+
}
|