@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.
@@ -1,8 +1,15 @@
1
1
  import { getPortCloseSignal } from "./close-signal.js";
2
2
  import type { MessageTarget } from "./message-target.js";
3
- import { nextTask, setPortSendState } from "./port-credit.js";
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
- /** The frames of a message still arriving. */
142
- partial?: { chunks: Uint8Array[]; bytes: number };
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 outstanding.
262
- if (c.enforced && c.outstanding > 0 && c.outstanding + next.size > c.window) break;
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
- const send = (): void => {
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 (frameSize != null && c.enforced && c.exact && bytes.byteLength > frameSize) {
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
- post(
464
- credit.exact
465
- ? { type: "credit", id: envelope.id, bytes: 0, exact: true }
466
- : { type: "credit", id: envelope.id, bytes: 0 },
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. A frame sequence buffers no more than the same message sent
524
- // whole, which a byte transport would also have to hold entire.
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 partial = entry.partial ?? { chunks: [], bytes: 0 };
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.partial = undefined;
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) {
@@ -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
- | { type: "open"; id: number; meta?: unknown; window?: number; exact?: boolean }
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
- /** Per-port flow control (`window`): the receiver has consumed `bytes` more. */
16
- | { type: "credit"; id: number; bytes: number; exact?: boolean }
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`
@@ -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.