@statewalker/webrun-rpc 0.7.0 → 0.10.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 +189 -12
- package/dist/connect-serve.d.ts +2 -0
- package/dist/connect-serve.d.ts.map +1 -1
- package/dist/duplex-over-port.d.ts +3 -1
- package/dist/duplex-over-port.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +496 -101
- package/dist/multiplex-port.d.ts +2 -0
- package/dist/multiplex-port.d.ts.map +1 -1
- package/dist/port-credit.d.ts +51 -0
- package/dist/port-credit.d.ts.map +1 -1
- package/dist/port-types.d.ts +37 -2
- package/dist/port-types.d.ts.map +1 -1
- package/dist/structured-codec.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/connect-serve.ts +20 -9
- package/src/duplex-over-port.ts +345 -40
- package/src/index.ts +6 -1
- package/src/multiplex-port.ts +238 -21
- package/src/port-credit.ts +86 -0
- package/src/port-types.ts +32 -3
- package/src/structured-codec.ts +5 -1
package/src/multiplex-port.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { getPortCloseSignal } from "./close-signal.js";
|
|
2
2
|
import type { MessageTarget } from "./message-target.js";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
nextTask,
|
|
5
|
+
type PortCap,
|
|
6
|
+
setPortAgreement,
|
|
7
|
+
setPortEndState,
|
|
8
|
+
setPortSendState,
|
|
9
|
+
} from "./port-credit.js";
|
|
4
10
|
import type {
|
|
5
11
|
OpenPortOptions,
|
|
12
|
+
PortCodec,
|
|
6
13
|
PortEnvelope,
|
|
7
14
|
PortInfo,
|
|
8
15
|
PortMux,
|
|
@@ -26,9 +33,29 @@ const KEEPALIVE_ID = Number.MAX_SAFE_INTEGER;
|
|
|
26
33
|
/** The longest delay a timer holds; past it `setInterval` fires at once. */
|
|
27
34
|
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
28
35
|
|
|
36
|
+
/**
|
|
37
|
+
* What a windowed port offers in its `open`: every capability this end
|
|
38
|
+
* speaks. `end` only over a codec that reads it (`PortCodec.halfClose`).
|
|
39
|
+
*/
|
|
40
|
+
function ownCaps(codec: PortCodec): readonly PortCap[] {
|
|
41
|
+
return codec.halfClose ? ["pipeline", "end"] : ["pipeline"];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The capabilities in a peer's `caps` that this end speaks too, or
|
|
46
|
+
* `undefined` when the peer listed none (it is then treated as an older peer).
|
|
47
|
+
*/
|
|
48
|
+
function sharedCaps(own: readonly PortCap[], listed: unknown): Set<PortCap> | undefined {
|
|
49
|
+
if (!Array.isArray(listed)) return undefined;
|
|
50
|
+
return new Set(own.filter((cap) => listed.includes(cap)));
|
|
51
|
+
}
|
|
52
|
+
|
|
29
53
|
/** Ceiling on concurrently open virtual ports. Bounds the id table only. */
|
|
30
54
|
export const DEFAULT_MAX_PORTS = 1024;
|
|
31
55
|
|
|
56
|
+
/** Default ceiling on one message reassembled from frames (`maxReassembledBytes`). */
|
|
57
|
+
export const DEFAULT_MAX_REASSEMBLED_BYTES = 64 * 1024 * 1024;
|
|
58
|
+
|
|
32
59
|
/**
|
|
33
60
|
* What a message costs a port's window when the codec cannot say
|
|
34
61
|
* (`PortCodec.encode`): its bytes and string lengths, found in its values up
|
|
@@ -73,6 +100,7 @@ export function multiplexPort(
|
|
|
73
100
|
keepalive,
|
|
74
101
|
onLeak,
|
|
75
102
|
} = options;
|
|
103
|
+
const maxReassembledBytes = options.maxReassembledBytes ?? DEFAULT_MAX_REASSEMBLED_BYTES;
|
|
76
104
|
const misses = keepalive?.misses ?? 3;
|
|
77
105
|
if (
|
|
78
106
|
keepalive &&
|
|
@@ -92,6 +120,11 @@ export function multiplexPort(
|
|
|
92
120
|
if (frameSize != null && !(Number.isSafeInteger(frameSize) && frameSize > 0)) {
|
|
93
121
|
throw new RangeError(`webrun-rpc: frameSize must be a positive integer, got ${frameSize}`);
|
|
94
122
|
}
|
|
123
|
+
if (!(Number.isSafeInteger(maxReassembledBytes) && maxReassembledBytes > 0)) {
|
|
124
|
+
throw new RangeError(
|
|
125
|
+
`webrun-rpc: maxReassembledBytes must be a positive integer, got ${maxReassembledBytes}`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
95
128
|
|
|
96
129
|
/**
|
|
97
130
|
* Flow control of one port (`PortMuxOptions.window`): the send side's queue
|
|
@@ -116,13 +149,27 @@ export function multiplexPort(
|
|
|
116
149
|
* port wedged.
|
|
117
150
|
*/
|
|
118
151
|
exact: boolean;
|
|
152
|
+
/** What both ends speak on this port; empty until the handshake says otherwise. */
|
|
153
|
+
caps: Set<PortCap>;
|
|
119
154
|
outstanding: number;
|
|
120
|
-
queue: { send: () => void; size: number }[];
|
|
155
|
+
queue: { send: (whole?: boolean) => void; size: number }[];
|
|
121
156
|
queued: number;
|
|
122
157
|
unconsumed: number;
|
|
123
158
|
waiters: { below: number; resolve: () => void }[];
|
|
124
159
|
toGrant: number;
|
|
125
160
|
grantScheduled: boolean;
|
|
161
|
+
/** This side's `end` is queued: anything posted afterwards is dropped. */
|
|
162
|
+
ending: boolean;
|
|
163
|
+
/** This side's `end` has left, behind the port's frames. */
|
|
164
|
+
endSent: boolean;
|
|
165
|
+
/** The peer's `end` has arrived. */
|
|
166
|
+
peerEnded: boolean;
|
|
167
|
+
/**
|
|
168
|
+
* Resolves `whenPortEnded`, once someone waits for it. Held weakly: the
|
|
169
|
+
* waiter's continuations would otherwise keep a dropped port reachable
|
|
170
|
+
* from the mux, and `onLeak` would never see it.
|
|
171
|
+
*/
|
|
172
|
+
onPeerEnd?: WeakRef<() => void>;
|
|
126
173
|
}
|
|
127
174
|
/**
|
|
128
175
|
* What the mux keeps of a port: never the consumer's `port` object itself,
|
|
@@ -138,10 +185,28 @@ export function multiplexPort(
|
|
|
138
185
|
interface Entry extends PortInfo {
|
|
139
186
|
handle: PortRecord;
|
|
140
187
|
credit?: Credit;
|
|
141
|
-
/**
|
|
142
|
-
|
|
188
|
+
/**
|
|
189
|
+
* The frames of a message still arriving. `room`: what the window left
|
|
190
|
+
* when the first came, if anything was still owed back then. `charged`:
|
|
191
|
+
* its bytes past the window, counted in `reassembling`.
|
|
192
|
+
*/
|
|
193
|
+
partial?: {
|
|
194
|
+
chunks: Uint8Array[];
|
|
195
|
+
bytes: number;
|
|
196
|
+
room?: number;
|
|
197
|
+
owed: number;
|
|
198
|
+
charged: number;
|
|
199
|
+
};
|
|
143
200
|
}
|
|
144
201
|
const open = new Map<number, Entry>();
|
|
202
|
+
// Bytes of partial messages held past their port's window, across every
|
|
203
|
+
// port (`maxReassembledBytes`). What fits a window is bounded by it already.
|
|
204
|
+
let reassembling = 0;
|
|
205
|
+
const freePartial = (entry: Entry | undefined): void => {
|
|
206
|
+
if (!entry?.partial) return;
|
|
207
|
+
reassembling -= entry.partial.charged;
|
|
208
|
+
entry.partial = undefined;
|
|
209
|
+
};
|
|
145
210
|
// A port collected while still open: report it, then free its slot on both
|
|
146
211
|
// ends as its close would. Only with `onLeak` — a registry costs per port.
|
|
147
212
|
const leaks = onLeak
|
|
@@ -175,6 +240,8 @@ export function multiplexPort(
|
|
|
175
240
|
let muxClosed = false;
|
|
176
241
|
/** This end counts encoded bytes and reassembles frames. */
|
|
177
242
|
const exactCodec = codec.encode != null && codec.decode != null;
|
|
243
|
+
/** What this end offers on a windowed port. */
|
|
244
|
+
const caps = ownCaps(codec);
|
|
178
245
|
|
|
179
246
|
const post = (envelope: PortEnvelope, transfer?: Transferable[]): void => {
|
|
180
247
|
if (muxClosed) return;
|
|
@@ -250,6 +317,29 @@ export function multiplexPort(
|
|
|
250
317
|
}
|
|
251
318
|
};
|
|
252
319
|
|
|
320
|
+
/**
|
|
321
|
+
* Both directions have ended: free the id as a close would, with no `close`
|
|
322
|
+
* on the wire (each end frees it on its own once it has seen both ends).
|
|
323
|
+
* Aborting the close signal is how the port's consumer learns it is gone.
|
|
324
|
+
*/
|
|
325
|
+
const freeIfEnded = (id: number, c: Credit): void => {
|
|
326
|
+
const entry = open.get(id);
|
|
327
|
+
if (!c.endSent || !c.peerEnded || entry?.credit !== c) return;
|
|
328
|
+
freePartial(entry);
|
|
329
|
+
open.delete(id);
|
|
330
|
+
leaks?.unregister(entry.handle);
|
|
331
|
+
entry.handle.markClosed(new Error("webrun-rpc: the port ended in both directions"));
|
|
332
|
+
};
|
|
333
|
+
/** A peer that broke the port's protocol: hold none of its traffic, and close. */
|
|
334
|
+
const failPort = (id: number, entry: Entry, error: Error, reason: string): void => {
|
|
335
|
+
freePartial(entry);
|
|
336
|
+
dropOutgoing(id);
|
|
337
|
+
open.delete(id);
|
|
338
|
+
releaseWaiters(entry.credit);
|
|
339
|
+
entry.handle.markClosed(error);
|
|
340
|
+
post({ type: "close", id, reason });
|
|
341
|
+
};
|
|
342
|
+
|
|
253
343
|
const releaseWaiters = (c: Credit | undefined): void => {
|
|
254
344
|
for (const w of c?.waiters.splice(0) ?? []) w.resolve();
|
|
255
345
|
};
|
|
@@ -258,8 +348,10 @@ export function multiplexPort(
|
|
|
258
348
|
while (c.queue.length) {
|
|
259
349
|
// biome-ignore lint/style/noNonNullAssertion: the loop condition checks the queue is not empty.
|
|
260
350
|
const next = c.queue[0]!;
|
|
261
|
-
// A message larger than the window goes out once nothing else is
|
|
262
|
-
|
|
351
|
+
// A message larger than the window goes out once nothing else is
|
|
352
|
+
// outstanding. An `end` (size 0) costs no window and never waits for credit.
|
|
353
|
+
if (next.size > 0 && c.enforced && c.outstanding > 0 && c.outstanding + next.size > c.window)
|
|
354
|
+
break;
|
|
263
355
|
c.queue.shift();
|
|
264
356
|
c.queued -= next.size;
|
|
265
357
|
c.outstanding += next.size;
|
|
@@ -312,6 +404,7 @@ export function multiplexPort(
|
|
|
312
404
|
enforced: false,
|
|
313
405
|
opener,
|
|
314
406
|
exact,
|
|
407
|
+
caps: new Set(),
|
|
315
408
|
outstanding: 0,
|
|
316
409
|
queue: [],
|
|
317
410
|
queued: 0,
|
|
@@ -319,10 +412,15 @@ export function multiplexPort(
|
|
|
319
412
|
waiters: [],
|
|
320
413
|
toGrant: 0,
|
|
321
414
|
grantScheduled: false,
|
|
415
|
+
ending: false,
|
|
416
|
+
endSent: false,
|
|
417
|
+
peerEnded: false,
|
|
322
418
|
}
|
|
323
419
|
: undefined;
|
|
324
420
|
const self = newVirtualPort(
|
|
325
421
|
(payload, transfer) => {
|
|
422
|
+
// This side's direction has ended: nothing more goes out on it.
|
|
423
|
+
if (c?.ending) return;
|
|
326
424
|
touch(id);
|
|
327
425
|
const envelope: PortEnvelope = { type: "message", id, payload };
|
|
328
426
|
// Without a window nothing frames or waits: post at once, so the
|
|
@@ -346,14 +444,22 @@ export function multiplexPort(
|
|
|
346
444
|
!bytes && waits
|
|
347
445
|
? { ...envelope, payload: structuredClone(payload, { transfer }) }
|
|
348
446
|
: undefined;
|
|
349
|
-
|
|
447
|
+
// `whole`: at close, what is still queued leaves past the window, and
|
|
448
|
+
// a frame past the window is what the peer refuses (`maxReassembledBytes`).
|
|
449
|
+
const send = (whole = false): void => {
|
|
350
450
|
if (!bytes) {
|
|
351
451
|
sendOnPort(id, held ? () => post(held) : () => post(envelope, transfer));
|
|
352
452
|
return;
|
|
353
453
|
}
|
|
354
454
|
// Frames only once the peer has acknowledged exact sizing: before
|
|
355
455
|
// that, it may be a peer that cannot reassemble them.
|
|
356
|
-
if (
|
|
456
|
+
if (
|
|
457
|
+
!whole &&
|
|
458
|
+
frameSize != null &&
|
|
459
|
+
c.enforced &&
|
|
460
|
+
c.exact &&
|
|
461
|
+
bytes.byteLength > frameSize
|
|
462
|
+
) {
|
|
357
463
|
for (let at = 0; at < bytes.byteLength; at += frameSize) {
|
|
358
464
|
const data = bytes.subarray(at, at + frameSize);
|
|
359
465
|
const frame: PortEnvelope =
|
|
@@ -376,6 +482,7 @@ export function multiplexPort(
|
|
|
376
482
|
},
|
|
377
483
|
);
|
|
378
484
|
function release(reason: unknown): void {
|
|
485
|
+
freePartial(open.get(id));
|
|
379
486
|
open.delete(id);
|
|
380
487
|
leaks?.unregister(record);
|
|
381
488
|
// What is queued goes out now, before the close and regardless of the
|
|
@@ -384,7 +491,7 @@ export function multiplexPort(
|
|
|
384
491
|
// kept for a port nobody holds. Without a window it would all have
|
|
385
492
|
// gone out at once anyway.
|
|
386
493
|
if (c?.queue.length) {
|
|
387
|
-
for (const item of c.queue.splice(0)) item.send();
|
|
494
|
+
for (const item of c.queue.splice(0)) item.send(true);
|
|
388
495
|
c.queued = 0;
|
|
389
496
|
}
|
|
390
497
|
// Behind this port's frames, so the peer gets the whole message first.
|
|
@@ -411,6 +518,40 @@ export function multiplexPort(
|
|
|
411
518
|
else c.waiters.push({ below, resolve: () => nextTask(resolve) });
|
|
412
519
|
}),
|
|
413
520
|
});
|
|
521
|
+
setPortEndState(self.port, {
|
|
522
|
+
end: () => {
|
|
523
|
+
if (isClosed() || !c.caps.has("end")) return false;
|
|
524
|
+
if (c.ending) return true;
|
|
525
|
+
c.ending = true;
|
|
526
|
+
// Behind what this side already posted: first in the window queue
|
|
527
|
+
// (a size-0 item), then in the fair-framing ring behind its frames.
|
|
528
|
+
c.queue.push({
|
|
529
|
+
size: 0,
|
|
530
|
+
send: () =>
|
|
531
|
+
sendOnPort(id, () => {
|
|
532
|
+
// A port closed meanwhile was abandoned, not ended: its peer
|
|
533
|
+
// hears the close alone, never an end that looks like a normal finish.
|
|
534
|
+
if (open.get(id)?.credit !== c) return;
|
|
535
|
+
post({ type: "end", id });
|
|
536
|
+
c.endSent = true;
|
|
537
|
+
freeIfEnded(id, c);
|
|
538
|
+
}),
|
|
539
|
+
});
|
|
540
|
+
flush(c);
|
|
541
|
+
return true;
|
|
542
|
+
},
|
|
543
|
+
watchPeerEnd: (resolve) => {
|
|
544
|
+
if (!c.caps.has("end")) return false;
|
|
545
|
+
if (c.peerEnded) resolve();
|
|
546
|
+
else c.onPeerEnd = new WeakRef(resolve);
|
|
547
|
+
return true;
|
|
548
|
+
},
|
|
549
|
+
});
|
|
550
|
+
setPortAgreement(self.port, () => ({
|
|
551
|
+
window: c.window,
|
|
552
|
+
caps: c.caps,
|
|
553
|
+
enforced: c.enforced,
|
|
554
|
+
}));
|
|
414
555
|
}
|
|
415
556
|
const record: PortRecord = {
|
|
416
557
|
deliver: self.deliver,
|
|
@@ -458,13 +599,18 @@ export function multiplexPort(
|
|
|
458
599
|
// Acknowledge the window before `onPort` runs: what `onPort` posts then
|
|
459
600
|
// reaches the opener after the ack, so both ends count it the same way
|
|
460
601
|
// (exact bytes or the estimate). A rejection's close follows the ack.
|
|
602
|
+
// Only an opener that listed capabilities hears them answered; they
|
|
603
|
+
// hold from here for what it sends. This end's own sends still wait for
|
|
604
|
+
// the opener's answer to the ack before the window holds them: that
|
|
605
|
+
// answer is what shows this end's codec reads credit at all.
|
|
461
606
|
const credit = open.get(envelope.id)?.credit;
|
|
607
|
+
const listed = sharedCaps(caps, envelope.caps);
|
|
462
608
|
if (credit) {
|
|
463
|
-
|
|
464
|
-
credit.exact
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
);
|
|
609
|
+
const ack: PortEnvelope = credit.exact
|
|
610
|
+
? { type: "credit", id: envelope.id, bytes: 0, exact: true }
|
|
611
|
+
: { type: "credit", id: envelope.id, bytes: 0 };
|
|
612
|
+
if (listed) credit.caps = listed;
|
|
613
|
+
post(listed ? { ...ack, caps: [...listed] } : ack);
|
|
468
614
|
}
|
|
469
615
|
let accepted = false;
|
|
470
616
|
if (onPort) {
|
|
@@ -497,6 +643,7 @@ export function multiplexPort(
|
|
|
497
643
|
c.exact = false;
|
|
498
644
|
c.outstanding = 0;
|
|
499
645
|
}
|
|
646
|
+
c.caps = sharedCaps(caps, envelope.caps) ?? new Set();
|
|
500
647
|
// Show the responder its codec reads credit, so it may hold back too.
|
|
501
648
|
post({ type: "credit", id: envelope.id, bytes: 0 });
|
|
502
649
|
}
|
|
@@ -506,6 +653,19 @@ export function multiplexPort(
|
|
|
506
653
|
return;
|
|
507
654
|
}
|
|
508
655
|
|
|
656
|
+
// Nothing follows a peer's `end` on its direction but credit: an honest
|
|
657
|
+
// sender drops what is posted after it. Delivering more would hand the
|
|
658
|
+
// consumer data after it was told the input is over.
|
|
659
|
+
if ((envelope.type === "message" || envelope.type === "frame") && entry?.credit?.peerEnded) {
|
|
660
|
+
failPort(
|
|
661
|
+
envelope.id,
|
|
662
|
+
entry,
|
|
663
|
+
new Error("webrun-rpc: the peer sent on a port after its end"),
|
|
664
|
+
"protocol",
|
|
665
|
+
);
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
|
|
509
669
|
if (envelope.type === "message") {
|
|
510
670
|
// Drop, never queue. An id that was rejected, never opened, or already
|
|
511
671
|
// closed has no consumer, and holding its traffic would be exactly the
|
|
@@ -520,16 +680,51 @@ export function multiplexPort(
|
|
|
520
680
|
|
|
521
681
|
if (envelope.type === "frame") {
|
|
522
682
|
// Only where this end advertised exact sizing — the one place a peer may
|
|
523
|
-
// frame.
|
|
524
|
-
//
|
|
683
|
+
// frame. What a partial message may hold is bounded below, so frames
|
|
684
|
+
// that never end cannot make this side buffer without limit.
|
|
525
685
|
if (!entry?.credit?.exact || !codec.decode || !(envelope.data instanceof Uint8Array)) return;
|
|
526
686
|
touch(envelope.id);
|
|
527
|
-
const
|
|
687
|
+
const c = entry.credit;
|
|
688
|
+
// What this side still owes back (delivered and unconsumed, or consumed
|
|
689
|
+
// and not yet granted) is never more than what the sender counted as
|
|
690
|
+
// outstanding. So an honest sender starts a message only when owed plus
|
|
691
|
+
// the message fits the window, or when nothing is owed at all: a message
|
|
692
|
+
// larger than the window, bounded by `maxReassembledBytes` alone.
|
|
693
|
+
const owed = c.unconsumed + c.toGrant;
|
|
694
|
+
const partial = entry.partial ?? {
|
|
695
|
+
chunks: [],
|
|
696
|
+
bytes: 0,
|
|
697
|
+
owed,
|
|
698
|
+
charged: 0,
|
|
699
|
+
...(owed > 0 ? { room: c.window - owed } : {}),
|
|
700
|
+
};
|
|
528
701
|
entry.partial = partial;
|
|
702
|
+
const size = envelope.data.byteLength;
|
|
703
|
+
// Only a message begun with nothing owed may pass the window; what it
|
|
704
|
+
// holds past it is what the cap, shared by all ports, bounds.
|
|
705
|
+
const charged = partial.room == null ? Math.max(0, partial.bytes + size - c.window) : 0;
|
|
706
|
+
const overrun =
|
|
707
|
+
partial.room != null && partial.bytes + size > partial.room
|
|
708
|
+
? `what the window left (${partial.room} of ${c.window} bytes, ${partial.owed} still owed)`
|
|
709
|
+
: reassembling - partial.charged + charged > maxReassembledBytes
|
|
710
|
+
? `maxReassembledBytes (${maxReassembledBytes} bytes past the window, across all ports)`
|
|
711
|
+
: undefined;
|
|
712
|
+
if (overrun) {
|
|
713
|
+
// A peer that frames past what it may send: hold none of it.
|
|
714
|
+
failPort(
|
|
715
|
+
envelope.id,
|
|
716
|
+
entry,
|
|
717
|
+
new RangeError(`webrun-rpc: a framed message passed ${overrun}`),
|
|
718
|
+
"max-reassembled-bytes",
|
|
719
|
+
);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
partial.bytes += size;
|
|
723
|
+
reassembling += charged - partial.charged;
|
|
724
|
+
partial.charged = charged;
|
|
529
725
|
partial.chunks.push(envelope.data);
|
|
530
|
-
partial.bytes += envelope.data.byteLength;
|
|
531
726
|
if (!envelope.last) return;
|
|
532
|
-
entry
|
|
727
|
+
freePartial(entry);
|
|
533
728
|
const whole = new Uint8Array(partial.bytes);
|
|
534
729
|
let at = 0;
|
|
535
730
|
for (const chunk of partial.chunks) {
|
|
@@ -542,9 +737,31 @@ export function multiplexPort(
|
|
|
542
737
|
return;
|
|
543
738
|
}
|
|
544
739
|
|
|
740
|
+
if (envelope.type === "end") {
|
|
741
|
+
if (!entry) return;
|
|
742
|
+
const c = entry.credit;
|
|
743
|
+
// Only where `end` was agreed. Anywhere else the peer broke the protocol,
|
|
744
|
+
// as with a pipelined chunk where pipelining was not agreed.
|
|
745
|
+
if (!c?.caps.has("end")) {
|
|
746
|
+
failPort(
|
|
747
|
+
envelope.id,
|
|
748
|
+
entry,
|
|
749
|
+
new Error("webrun-rpc: the peer ended a port where end was not agreed"),
|
|
750
|
+
"protocol",
|
|
751
|
+
);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
touch(envelope.id);
|
|
755
|
+
c.peerEnded = true;
|
|
756
|
+
c.onPeerEnd?.deref()?.();
|
|
757
|
+
freeIfEnded(envelope.id, c);
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
|
|
545
761
|
// The peer closed the port: whatever it still had to receive is moot.
|
|
546
762
|
dropOutgoing(envelope.id);
|
|
547
763
|
if (!entry) return;
|
|
764
|
+
freePartial(entry);
|
|
548
765
|
open.delete(envelope.id);
|
|
549
766
|
releaseWaiters(entry.credit);
|
|
550
767
|
entry.handle.markClosed(new Error("webrun-rpc: the peer closed the port"));
|
|
@@ -652,8 +869,8 @@ export function multiplexPort(
|
|
|
652
869
|
post(
|
|
653
870
|
window
|
|
654
871
|
? exactCodec
|
|
655
|
-
? { type: "open", id, meta, window, exact: true }
|
|
656
|
-
: { type: "open", id, meta, window }
|
|
872
|
+
? { type: "open", id, meta, window, exact: true, caps: [...caps] }
|
|
873
|
+
: { type: "open", id, meta, window, caps: [...caps] }
|
|
657
874
|
: { type: "open", id, meta },
|
|
658
875
|
);
|
|
659
876
|
if (portSignal) {
|
package/src/port-credit.ts
CHANGED
|
@@ -59,3 +59,89 @@ export function portBufferedAmount(port: MessageTarget): number {
|
|
|
59
59
|
export function whenPortReady(port: MessageTarget, below = 0): Promise<void> {
|
|
60
60
|
return states.get(port)?.ready(below) ?? new Promise<void>((resolve) => nextTask(resolve));
|
|
61
61
|
}
|
|
62
|
+
|
|
63
|
+
/** A capability two ends agree on for one windowed port, in its `open` and the ack. */
|
|
64
|
+
export type PortCap = "pipeline" | "end";
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What two ends have agreed for one windowed port so far. `caps`: what both
|
|
68
|
+
* speak, and so what the peer may send; empty until the handshake settles it
|
|
69
|
+
* (the opener learns it from the ack, the responder from the `open`).
|
|
70
|
+
* `enforced`: the window holds this end's own sends — what this end needs
|
|
71
|
+
* before it relies on the window instead of a confirmation per message.
|
|
72
|
+
*/
|
|
73
|
+
export interface PortAgreement {
|
|
74
|
+
window: number;
|
|
75
|
+
caps: ReadonlySet<PortCap>;
|
|
76
|
+
enforced: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const agreements = new WeakMap<MessageTarget, () => PortAgreement | undefined>();
|
|
80
|
+
|
|
81
|
+
/** @internal Registered by the multiplexer for each port with a window. */
|
|
82
|
+
export function setPortAgreement(port: MessageTarget, agreement: () => PortAgreement | undefined) {
|
|
83
|
+
agreements.set(port, agreement);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @internal What has been agreed with the peer on `port` so far; `undefined`
|
|
88
|
+
* on a port without a window. Not exported from the package.
|
|
89
|
+
*/
|
|
90
|
+
export function portAgreement(port: MessageTarget): PortAgreement | undefined {
|
|
91
|
+
return agreements.get(port)?.();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** @internal The half-close of one windowed port (capability `"end"`). */
|
|
95
|
+
export interface PortEndState {
|
|
96
|
+
/** Queue this side's `end`; `false` when `end` was not agreed. */
|
|
97
|
+
end(): boolean;
|
|
98
|
+
/**
|
|
99
|
+
* Calls `resolve` once the peer has ended its direction (at once if it has
|
|
100
|
+
* already); `false`, and `resolve` is never called, when `end` was not
|
|
101
|
+
* agreed. The mux holds `resolve` weakly, so a port dropped while someone
|
|
102
|
+
* waits on it can still be collected (`onLeak`).
|
|
103
|
+
*/
|
|
104
|
+
watchPeerEnd(resolve: () => void): boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const ends = new WeakMap<MessageTarget, PortEndState>();
|
|
108
|
+
|
|
109
|
+
/** @internal Registered by the multiplexer for each port with a window. */
|
|
110
|
+
export function setPortEndState(port: MessageTarget, state: PortEndState): void {
|
|
111
|
+
ends.set(port, state);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @internal End this side's direction of `port`: an `end` is queued behind
|
|
116
|
+
* everything already posted on it, and anything posted afterwards is dropped.
|
|
117
|
+
* `false`, and nothing changes, when `end` was not agreed (no window, the
|
|
118
|
+
* handshake not answered yet, or a peer that does not speak it): the caller
|
|
119
|
+
* then closes the port. Never waits on the peer. Not exported from the package.
|
|
120
|
+
*/
|
|
121
|
+
export function endPort(port: MessageTarget): boolean {
|
|
122
|
+
return ends.get(port)?.end() ?? false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @internal Resolves once the peer has ended its direction of `port`. It never
|
|
127
|
+
* settles otherwise: not on a port where `end` is not agreed when it is asked
|
|
128
|
+
* (no window, the handshake not answered yet, a peer or codec without it), and
|
|
129
|
+
* not when the port closes first, so a caller races it against the port's
|
|
130
|
+
* close signal. Not exported from the package.
|
|
131
|
+
*/
|
|
132
|
+
export function whenPortEnded(port: MessageTarget): Promise<void> {
|
|
133
|
+
let waiting = peerEnds.get(port);
|
|
134
|
+
if (!waiting) {
|
|
135
|
+
let resolve: () => void = () => {};
|
|
136
|
+
const promise = new Promise<void>((r) => {
|
|
137
|
+
resolve = r;
|
|
138
|
+
});
|
|
139
|
+
if (!ends.get(port)?.watchPeerEnd(resolve)) return promise;
|
|
140
|
+
// Held from the port only: what waits on it lives as long as the port.
|
|
141
|
+
waiting = { promise, resolve };
|
|
142
|
+
peerEnds.set(port, waiting);
|
|
143
|
+
}
|
|
144
|
+
return waiting.promise;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const peerEnds = new WeakMap<MessageTarget, { promise: Promise<void>; resolve: () => void }>();
|
package/src/port-types.ts
CHANGED
|
@@ -9,11 +9,18 @@ import type { MessageTarget } from "./message-target.js";
|
|
|
9
9
|
* inspects, because stream semantics belong above this layer.
|
|
10
10
|
*/
|
|
11
11
|
export type PortEnvelope =
|
|
12
|
-
|
|
12
|
+
/** `caps` (with a window): the capabilities the opener speaks on this port. */
|
|
13
|
+
| { type: "open"; id: number; meta?: unknown; window?: number; exact?: boolean; caps?: string[] }
|
|
13
14
|
| { type: "message"; id: number; payload: unknown }
|
|
14
15
|
| { type: "close"; id: number; reason?: unknown }
|
|
15
|
-
/**
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Per-port flow control (`window`): the receiver has consumed `bytes` more.
|
|
18
|
+
* The responder's zero-byte ack carries `caps`, the shared subset, when the
|
|
19
|
+
* opener listed some.
|
|
20
|
+
*/
|
|
21
|
+
| { type: "credit"; id: number; bytes: number; exact?: boolean; caps?: string[] }
|
|
22
|
+
/** Half-close (capability `"end"`): the sender's direction of the port has ended. */
|
|
23
|
+
| { type: "end"; id: number }
|
|
17
24
|
/** Fair framing (`frameSize`): one piece of an encoded `message` envelope. */
|
|
18
25
|
| { type: "frame"; id: number; data: Uint8Array; last?: boolean }
|
|
19
26
|
/** Keepalive (`keepalive`): the mux's own liveness probe and its answer, on an id no port takes. */
|
|
@@ -42,6 +49,15 @@ export interface PortCodec {
|
|
|
42
49
|
encode?(envelope: PortEnvelope): Uint8Array;
|
|
43
50
|
/** Optional, with `encode`: an envelope back from its bytes, or `undefined`. */
|
|
44
51
|
decode?(data: unknown): PortEnvelope | undefined;
|
|
52
|
+
/**
|
|
53
|
+
* Optional: this codec's `read` (and `decode`) accept the `end` envelope.
|
|
54
|
+
* Only then does a windowed port offer the `end` capability. A codec that
|
|
55
|
+
* dropped `end` would leave a port that ended normally in the peer's table
|
|
56
|
+
* (`ports()`) until the mux closes, with no error anywhere. A codec spread
|
|
57
|
+
* from a built-in (`{ ...structuredCodec, read }`) inherits the flag: its
|
|
58
|
+
* own `read` must then accept `end` too, or it must drop the flag.
|
|
59
|
+
*/
|
|
60
|
+
halfClose?: true;
|
|
45
61
|
}
|
|
46
62
|
|
|
47
63
|
export interface PortMuxOptions {
|
|
@@ -124,6 +140,19 @@ export interface PortMuxOptions {
|
|
|
124
140
|
* whole. 16-64 KiB is a reasonable range.
|
|
125
141
|
*/
|
|
126
142
|
frameSize?: number;
|
|
143
|
+
/**
|
|
144
|
+
* Ceiling, in bytes, on what this side holds of a peer's framed messages
|
|
145
|
+
* past their ports' windows, summed over all ports (default 64 MiB).
|
|
146
|
+
* A partial message counts against its port's window as it arrives: one
|
|
147
|
+
* begun while this side still owed credit must fit what the window left,
|
|
148
|
+
* and one begun with nothing owed may pass the window, its excess charged
|
|
149
|
+
* to this cap. A frame past either limit makes the mux drop that port's
|
|
150
|
+
* partial message, close the port with a `RangeError` and send the peer a
|
|
151
|
+
* close with reason `"max-reassembled-bytes"`. A framed message more than
|
|
152
|
+
* this cap past the window never arrives, though the same message sent
|
|
153
|
+
* whole would.
|
|
154
|
+
*/
|
|
155
|
+
maxReassembledBytes?: number;
|
|
127
156
|
/**
|
|
128
157
|
* Liveness of the peer (off when unset). Every `intervalMs` the mux pings
|
|
129
158
|
* and the peer answers; anything from it counts as an answer. After `misses`
|
package/src/structured-codec.ts
CHANGED
|
@@ -12,7 +12,8 @@ function isEnvelope(value: unknown): value is PortEnvelope {
|
|
|
12
12
|
candidate.type === "credit" ||
|
|
13
13
|
candidate.type === "frame" ||
|
|
14
14
|
candidate.type === "ping" ||
|
|
15
|
-
candidate.type === "pong"
|
|
15
|
+
candidate.type === "pong" ||
|
|
16
|
+
candidate.type === "end"
|
|
16
17
|
);
|
|
17
18
|
}
|
|
18
19
|
|
|
@@ -26,6 +27,9 @@ function isEnvelope(value: unknown): value is PortEnvelope {
|
|
|
26
27
|
* not send anything a byte codec could not also carry.
|
|
27
28
|
*/
|
|
28
29
|
export const structuredCodec: PortCodec = {
|
|
30
|
+
// `read` accepts `end`, so windowed ports over this codec offer it.
|
|
31
|
+
halfClose: true,
|
|
32
|
+
|
|
29
33
|
post(port, envelope, transfer) {
|
|
30
34
|
// An empty transfer list is not the same as no transfer list — some
|
|
31
35
|
// implementations reject the former.
|