@statewalker/webrun-rpc 0.8.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/dist/index.js CHANGED
@@ -471,6 +471,101 @@ async function* callBidi(port, input, { options = {}, ...params } = {}) {
471
471
  if (outerError !== void 0) throw outerError;
472
472
  }
473
473
  //#endregion
474
+ //#region src/port-credit.ts
475
+ /**
476
+ * Runs `fn` on a later task, never a microtask: a producer looping on
477
+ * `whenPortReady` must let the transport's own events run. `setImmediate`
478
+ * where it exists; elsewhere a `MessageChannel`, because nested `setTimeout`s
479
+ * are clamped to 4 ms in browsers.
480
+ */
481
+ const setImmediateFn = globalThis.setImmediate;
482
+ /** @internal */
483
+ const nextTask = setImmediateFn ? (fn) => {
484
+ setImmediateFn(fn);
485
+ } : (() => {
486
+ let channel;
487
+ const queue = [];
488
+ return (fn) => {
489
+ if (!channel) {
490
+ channel = new MessageChannel();
491
+ channel.port1.onmessage = () => queue.shift()?.();
492
+ }
493
+ queue.push(fn);
494
+ channel.port2.postMessage(0);
495
+ };
496
+ })();
497
+ const states = /* @__PURE__ */ new WeakMap();
498
+ /** @internal Registered by the multiplexer for each port with a window. */
499
+ function setPortSendState(port, state) {
500
+ states.set(port, state);
501
+ }
502
+ /**
503
+ * Bytes queued on `port` because its window is spent (0 for a port without
504
+ * flow control). A sender that should not queue without bound waits for
505
+ * `whenPortReady` before posting more.
506
+ */
507
+ function portBufferedAmount(port) {
508
+ return states.get(port)?.bufferedAmount() ?? 0;
509
+ }
510
+ /**
511
+ * Resolves once at most `below` bytes are queued on `port`, always on a later
512
+ * task (for a port without flow control, on the next one).
513
+ */
514
+ function whenPortReady(port, below = 0) {
515
+ return states.get(port)?.ready(below) ?? new Promise((resolve) => nextTask(resolve));
516
+ }
517
+ const agreements = /* @__PURE__ */ new WeakMap();
518
+ /** @internal Registered by the multiplexer for each port with a window. */
519
+ function setPortAgreement(port, agreement) {
520
+ agreements.set(port, agreement);
521
+ }
522
+ /**
523
+ * @internal What has been agreed with the peer on `port` so far; `undefined`
524
+ * on a port without a window. Not exported from the package.
525
+ */
526
+ function portAgreement(port) {
527
+ return agreements.get(port)?.();
528
+ }
529
+ const ends = /* @__PURE__ */ new WeakMap();
530
+ /** @internal Registered by the multiplexer for each port with a window. */
531
+ function setPortEndState(port, state) {
532
+ ends.set(port, state);
533
+ }
534
+ /**
535
+ * @internal End this side's direction of `port`: an `end` is queued behind
536
+ * everything already posted on it, and anything posted afterwards is dropped.
537
+ * `false`, and nothing changes, when `end` was not agreed (no window, the
538
+ * handshake not answered yet, or a peer that does not speak it): the caller
539
+ * then closes the port. Never waits on the peer. Not exported from the package.
540
+ */
541
+ function endPort(port) {
542
+ return ends.get(port)?.end() ?? false;
543
+ }
544
+ /**
545
+ * @internal Resolves once the peer has ended its direction of `port`. It never
546
+ * settles otherwise: not on a port where `end` is not agreed when it is asked
547
+ * (no window, the handshake not answered yet, a peer or codec without it), and
548
+ * not when the port closes first, so a caller races it against the port's
549
+ * close signal. Not exported from the package.
550
+ */
551
+ function whenPortEnded(port) {
552
+ let waiting = peerEnds.get(port);
553
+ if (!waiting) {
554
+ let resolve = () => {};
555
+ const promise = new Promise((r) => {
556
+ resolve = r;
557
+ });
558
+ if (!ends.get(port)?.watchPeerEnd(resolve)) return promise;
559
+ waiting = {
560
+ promise,
561
+ resolve
562
+ };
563
+ peerEnds.set(port, waiting);
564
+ }
565
+ return waiting.promise;
566
+ }
567
+ const peerEnds = /* @__PURE__ */ new WeakMap();
568
+ //#endregion
474
569
  //#region src/async-dispose.ts
475
570
  /**
476
571
  * Makes `target` disposable with `await using`: installs `dispose` as its
@@ -548,6 +643,61 @@ function returningAtOnce(gen, abandon) {
548
643
  * tests and adapters assert on it.
549
644
  */
550
645
  const STREAM_ABORT = "webrun-rpc:stream-abort";
646
+ /**
647
+ * The `type` of a pipelined chunk: a plain port message, not a `callPort`
648
+ * request, sent only on a port where both ends agreed `pipeline`. The window's
649
+ * credit is its confirmation.
650
+ */
651
+ const CHUNK = "webrun-rpc:chunk";
652
+ /**
653
+ * What a pipelined chunk counts beyond its bytes, against the window, on the
654
+ * receiving side. Both codecs' windows count more for the chunk's envelope
655
+ * (the estimate at least 64, msgpack's keys alone more than this), so an honest
656
+ * sender never makes the receiver count past the window; a sender of empty
657
+ * chunks still cannot have it hold an unbounded number of them.
658
+ */
659
+ const CHUNK_OVERHEAD = 32;
660
+ /**
661
+ * The bytes of `view` as a plain `Uint8Array` that owns its whole buffer:
662
+ * `view` itself when it already is one, else a copy. Structured clone carries
663
+ * and keeps a view's whole `ArrayBuffer`, and a byte codec may decode into
664
+ * views of the frame, while the window and the receiver's count see only the
665
+ * view: a 4 KiB `subarray` of a 4 MiB buffer would cost 4 MiB per chunk.
666
+ * Copied with the `Uint8Array` constructor, not `slice()`: a Node `Buffer`'s
667
+ * `slice()` is another view on the same memory.
668
+ */
669
+ function ownBytes(view) {
670
+ if (view instanceof Uint8Array && view.constructor === Uint8Array && view.byteOffset === 0 && view.byteLength === view.buffer.byteLength) return view;
671
+ return new Uint8Array(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
672
+ }
673
+ /**
674
+ * A producer's chunk as bytes this end may send, in either mode: any view or
675
+ * `ArrayBuffer`, normalised by `ownBytes`. Anything else fails the stream here,
676
+ * as the producer's own error, rather than reaching a peer that would refuse it.
677
+ */
678
+ /**
679
+ * A received confirmed chunk's value, as its own bytes, put back in place of
680
+ * the original in `chunk`: `listenPort` keeps the request until the handler
681
+ * returns, and the original would keep its whole buffer that long. A function
682
+ * of its own, so that no temporary of the suspended handler holds the
683
+ * original either.
684
+ */
685
+ function adoptValue(chunk) {
686
+ const original = chunk.value;
687
+ if (ArrayBuffer.isView(original)) chunk.value = ownBytes(original);
688
+ return chunk.value;
689
+ }
690
+ function chunkBytes(value) {
691
+ if (ArrayBuffer.isView(value)) return ownBytes(value);
692
+ if (value instanceof ArrayBuffer || Object.prototype.toString.call(value) === "[object ArrayBuffer]") return new Uint8Array(value);
693
+ throw new TypeError(`webrun-rpc: a stream chunk must be bytes (a typed array, a DataView or an ArrayBuffer), got ${value === null ? "null" : typeof value}`);
694
+ }
695
+ const MIXED_MESSAGE = "webrun-rpc: peer mixed confirmed and pipelined chunks; the stream port is closed";
696
+ /** This end may pipeline its own chunks: agreed, and the window holds them. */
697
+ const mayPipeline = (port) => {
698
+ const agreement = portAgreement(port);
699
+ return !!agreement?.enforced && agreement.caps.has("pipeline");
700
+ };
551
701
  /** Caller's input travels on this channel; the handler listens on it. */
552
702
  const CHANNEL_IN = "in";
553
703
  /** The handler's output travels on this channel; the caller listens on it. */
@@ -560,7 +710,9 @@ const CHANNEL_OUT = "out";
560
710
  * same way on the other channel. Within each direction the next chunk is never
561
711
  * sent until the previous one has been delivered *and* pulled past by the
562
712
  * consumer (spec D11) — the reply to a chunk call *is* the confirmation, and
563
- * `listenPort` withholds it until then.
713
+ * `listenPort` withholds it until then. On a port whose window both ends
714
+ * speak and that agreed `pipeline`, chunks go as plain messages instead, and
715
+ * the window's credit is the confirmation: up to a window is in flight.
564
716
  *
565
717
  * A stream port carries exactly one invocation. To make several calls, open
566
718
  * several ports: `mux.openPort({ kind: "stream" })` per call.
@@ -577,22 +729,49 @@ function serveDuplexOverPort(port, handler, options = {}) {
577
729
  const notice = installAbortNotice(port, controller);
578
730
  abortOnPortClose(port, controller);
579
731
  const clock = installStreamTimeout(controller, options.timeout);
580
- const inbound = receiveChunks(port, CHANNEL_IN, controller, clock.touch);
732
+ const inbound = receiveChunks(port, CHANNEL_IN, controller, clock.touch, notice);
581
733
  let output;
582
734
  try {
583
735
  output = handler(inbound.stream);
584
736
  } catch (err) {
585
- const pump = sendChunks(port, CHANNEL_OUT, failing(err), options, controller.signal, clock.touch);
737
+ const pump = sendChunks(port, CHANNEL_OUT, failing(err), options, controller.signal, clock.touch, () => {});
586
738
  pump.catch(() => {});
587
739
  disarmClockWhenBothSidesSettle(clock, pump, inbound.ended);
588
740
  return teardownOnce(controller, notice, clock, inbound, void 0);
589
741
  }
590
- const pump = sendChunks(port, CHANNEL_OUT, output, options, controller.signal, clock.touch);
742
+ let onSent = () => {};
743
+ const sent = new Promise((resolve) => {
744
+ onSent = resolve;
745
+ });
746
+ const pump = sendChunks(port, CHANNEL_OUT, output, options, controller.signal, clock.touch, onSent);
591
747
  pump.catch(() => {});
592
748
  disarmClockWhenBothSidesSettle(clock, pump, inbound.ended);
749
+ endServedStream(port, sent, inbound.over);
593
750
  return teardownOnce(controller, notice, clock, inbound, output);
594
751
  }
595
752
  /**
753
+ * The serving side's normal finish. Once the handler's last chunk has gone
754
+ * without an error, and no more input is coming (`over`: the caller's last
755
+ * chunk arrived without an error and was read, or the caller ended its
756
+ * direction behind it, for a handler that did not read all its input), this
757
+ * side ends its direction too, where `end` was agreed. Without `end` nothing
758
+ * happens here: the caller closes the port. A stream that never gets that far
759
+ * was abandoned, and the caller's notice and `close` free the port; this then
760
+ * never resumes. It needs no race against the port's close signal: nothing
761
+ * awaits it, and what keeps it waiting is the input's watcher, a listener of
762
+ * the port that a close drops, after which the wait is collected.
763
+ *
764
+ * Nothing this side still posts is lost behind its `end`. The caller ends only
765
+ * once its own last chunk was confirmed, so no confirmation is owed to it
766
+ * here, and what the freed port's close signal aborts has settled: the last
767
+ * chunk has left, and input nobody reads is dropped, as on a close.
768
+ */
769
+ async function endServedStream(port, sent, over) {
770
+ await sent;
771
+ await over;
772
+ endPort(port);
773
+ }
774
+ /**
596
775
  * A stream that completes normally has nobody left to call the returned
597
776
  * teardown — the caller only knows the stream ended, not that it must also
598
777
  * dispose the serve-side handle. Without this, the last `touch()`'s timer
@@ -632,22 +811,31 @@ function runCallerSide(port, input, options) {
632
811
  const notice = installAbortNotice(port, controller);
633
812
  abortOnPortClose(port, controller);
634
813
  const clock = installStreamTimeout(controller, options.timeout);
635
- const inbound = receiveChunks(port, CHANNEL_OUT, controller, clock.touch);
636
- const pump = sendChunks(port, CHANNEL_IN, input, options, controller.signal, clock.touch);
814
+ const inbound = receiveChunks(port, CHANNEL_OUT, controller, clock.touch, notice);
815
+ let sent = false;
816
+ const pump = sendChunks(port, CHANNEL_IN, input, options, controller.signal, clock.touch, () => {
817
+ sent = true;
818
+ });
637
819
  pump.catch(() => {});
638
820
  return returningAtOnce((async function* () {
821
+ let received = false;
639
822
  try {
640
823
  yield* inbound.stream;
824
+ received = true;
641
825
  } finally {
826
+ const ended = received && sent;
642
827
  if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error("webrun-rpc: the caller abandoned the stream"));
643
- notice.post();
828
+ if (!ended) notice.post();
644
829
  notice.stop();
645
830
  clock.stop();
646
831
  inbound.stop();
647
832
  await pump.catch(() => {});
648
- try {
649
- await port.close?.();
650
- } catch {}
833
+ if (!(ended && endPort(port))) {
834
+ notice.post();
835
+ try {
836
+ await port.close?.();
837
+ } catch {}
838
+ }
651
839
  }
652
840
  })(), () => {
653
841
  if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error("webrun-rpc: the caller abandoned the stream"));
@@ -741,7 +929,7 @@ function installStreamTimeout(controller, timeout) {
741
929
  * the local consumer has not started iterating, an inbound chunk waits for it —
742
930
  * which is the correct backpressure, and different from having no listener.
743
931
  */
744
- function receiveChunks(port, channelName, controller, touch) {
932
+ function receiveChunks(port, channelName, controller, touch, notice) {
745
933
  let deliver;
746
934
  const waiting = [];
747
935
  let finished = false;
@@ -751,25 +939,64 @@ function receiveChunks(port, channelName, controller, touch) {
751
939
  const ended = new Promise((resolve) => {
752
940
  resolveEnded = resolve;
753
941
  });
942
+ let resolveOver = () => {};
943
+ const over = new Promise((resolve) => {
944
+ resolveOver = resolve;
945
+ });
754
946
  let aborted = false;
755
947
  let abortReason;
756
948
  const ready = () => deliver || finished ? Promise.resolve() : new Promise((r) => waiting.push(r));
757
949
  const wake = () => {
758
950
  for (const r of waiting.splice(0)) r();
759
951
  };
760
- const off = listenPort(port, async ({ done, value, error }) => {
952
+ const violate = (message) => {
953
+ poison = new Error(message);
954
+ controller.abort(poison);
955
+ setTimeout(() => {
956
+ try {
957
+ port.close?.();
958
+ } catch {}
959
+ }, 0);
960
+ return poison;
961
+ };
962
+ let lastChunkArrived = false;
963
+ let watching = false;
964
+ const watchPeerEnd = () => {
965
+ if (watching) return true;
966
+ if (!portAgreement(port)?.caps.has("end")) return false;
967
+ watching = true;
968
+ whenPortEnded(port).then(() => {
969
+ port.removeEventListener("message", watcher);
970
+ if (lastChunkArrived) {
971
+ resolveOver();
972
+ return;
973
+ }
974
+ notice.post();
975
+ violate("webrun-rpc: the peer ended its direction without finishing it; the stream port is closed");
976
+ });
977
+ return true;
978
+ };
979
+ const watcher = (event) => {
980
+ const data = event.data;
981
+ if (data?.channelName !== channelName) return;
982
+ const chunk = data.type === CHUNK ? data.chunk : data.type === "request" ? data.params : void 0;
983
+ if (!chunk) return;
984
+ if (!watchPeerEnd()) {
985
+ port.removeEventListener("message", watcher);
986
+ return;
987
+ }
988
+ if (chunk.done && !chunk.error) lastChunkArrived = true;
989
+ };
990
+ port.addEventListener("message", watcher);
991
+ watchPeerEnd();
992
+ let held = 0;
993
+ const off = listenPort(port, async (params) => {
994
+ const { done, error } = params;
995
+ const value = adoptValue(params);
761
996
  touch();
762
997
  if (poison) throw poison;
763
- if (outstanding) {
764
- poison = /* @__PURE__ */ new Error("webrun-rpc: peer sent a second unconfirmed chunk; the stream port is closed");
765
- controller.abort(poison);
766
- setTimeout(() => {
767
- try {
768
- port.close?.();
769
- } catch {}
770
- }, 0);
771
- throw poison;
772
- }
998
+ if (held > 0) throw violate(MIXED_MESSAGE);
999
+ if (outstanding) throw violate("webrun-rpc: peer sent a second unconfirmed chunk; the stream port is closed");
773
1000
  outstanding = true;
774
1001
  try {
775
1002
  await ready();
@@ -779,15 +1006,60 @@ function receiveChunks(port, channelName, controller, touch) {
779
1006
  value,
780
1007
  error: error ? deserializeError(error) : void 0
781
1008
  });
1009
+ if (done && !error) resolveOver();
782
1010
  } finally {
783
1011
  outstanding = false;
784
1012
  }
785
1013
  if (done) resolveEnded();
786
1014
  }, { channelName });
1015
+ const onChunk = (event) => {
1016
+ const data = event.data;
1017
+ if (data?.type !== CHUNK || data.channelName !== channelName || !data.chunk) return;
1018
+ if (poison) return;
1019
+ const agreement = portAgreement(port);
1020
+ if (!agreement?.caps.has("pipeline")) {
1021
+ violate("webrun-rpc: peer sent a pipelined chunk where pipelining was not agreed");
1022
+ return;
1023
+ }
1024
+ if (outstanding) {
1025
+ violate(MIXED_MESSAGE);
1026
+ return;
1027
+ }
1028
+ if (data.chunk.value !== void 0 && !ArrayBuffer.isView(data.chunk.value)) {
1029
+ violate("webrun-rpc: peer sent a pipelined chunk whose value is not bytes");
1030
+ return;
1031
+ }
1032
+ if (held > agreement.window) {
1033
+ violate(`webrun-rpc: peer sent past the window (${agreement.window} bytes) without waiting for credit; the stream port is closed`);
1034
+ return;
1035
+ }
1036
+ touch();
1037
+ const { done, error } = data.chunk;
1038
+ const value = data.chunk.value && ownBytes(data.chunk.value);
1039
+ const size = (value?.byteLength ?? 0) + CHUNK_OVERHEAD;
1040
+ held += size;
1041
+ return (async () => {
1042
+ try {
1043
+ await ready();
1044
+ if (finished) return;
1045
+ await deliver?.({
1046
+ done,
1047
+ value,
1048
+ error: error ? deserializeError(error) : void 0
1049
+ });
1050
+ if (done && !error) resolveOver();
1051
+ if (done) resolveEnded();
1052
+ } finally {
1053
+ held -= size;
1054
+ }
1055
+ })();
1056
+ };
1057
+ port.addEventListener("message", onChunk);
787
1058
  const detach = () => {
788
1059
  finished = true;
789
1060
  wake();
790
1061
  off();
1062
+ port.removeEventListener("message", onChunk);
791
1063
  controller.signal.removeEventListener("abort", onAbort);
792
1064
  resolveEnded();
793
1065
  };
@@ -817,25 +1089,35 @@ function receiveChunks(port, channelName, controller, touch) {
817
1089
  return detach;
818
1090
  }),
819
1091
  stop: detach,
820
- ended
1092
+ ended,
1093
+ over
821
1094
  };
822
1095
  }
823
1096
  /**
824
1097
  * The sending half of one direction: one `callPort` per chunk, one call
825
- * outstanding at a time (spec D11/D12), with no per-chunk deadline (spec D8).
1098
+ * outstanding at a time (spec D11/D12), with no per-chunk deadline (spec D8) —
1099
+ * or, once pipelining is agreed, plain chunk messages held back by the window.
1100
+ * `onSent` runs once the end of the output has left without an error: posted
1101
+ * when pipelined, confirmed when not.
826
1102
  */
827
- async function sendChunks(port, channelName, output, { maxMessageSize, log }, signal, touch) {
1103
+ async function sendChunks(port, channelName, output, { maxMessageSize, log }, signal, touch, onSent) {
828
1104
  const framed = maxMessageSize ? toChunks(maxMessageSize)(output) : output;
829
1105
  const stream = throughAbort(framed, signal);
830
1106
  const producer = output;
831
1107
  const returnProducer = () => void Promise.resolve(producer.return?.()).catch(() => {});
832
1108
  if (framed !== output) signal.addEventListener("abort", returnProducer, { once: true });
1109
+ let onAborted = () => {};
1110
+ const aborted = new Promise((resolve) => {
1111
+ onAborted = () => resolve();
1112
+ });
1113
+ if (signal.aborted) onAborted();
1114
+ else signal.addEventListener("abort", onAborted, { once: true });
833
1115
  try {
834
1116
  await sendIterator(async ({ done, value, error }) => {
835
1117
  if (signal.aborted) return;
836
1118
  const chunk = {
837
1119
  done,
838
- value,
1120
+ value: done ? value : chunkBytes(value),
839
1121
  error: error === void 0 ? void 0 : serializeError(error)
840
1122
  };
841
1123
  log?.("[duplexOverPort] send", {
@@ -843,65 +1125,34 @@ async function sendChunks(port, channelName, output, { maxMessageSize, log }, si
843
1125
  done,
844
1126
  size: value?.byteLength
845
1127
  });
1128
+ if (mayPipeline(port)) {
1129
+ port.postMessage({
1130
+ type: CHUNK,
1131
+ channelName,
1132
+ chunk
1133
+ });
1134
+ touch();
1135
+ if (done && !error) onSent();
1136
+ if (portBufferedAmount(port) > 0) await Promise.race([whenPortReady(port), aborted]);
1137
+ return;
1138
+ }
846
1139
  await callPort(port, chunk, {
847
1140
  channelName,
848
1141
  timeout: NO_TIMEOUT,
849
1142
  signal
850
1143
  });
851
1144
  touch();
1145
+ if (done && !error) onSent();
852
1146
  }, stream);
853
1147
  } catch (err) {
854
1148
  if (signal.aborted) return;
855
1149
  throw err;
856
1150
  } finally {
857
1151
  signal.removeEventListener("abort", returnProducer);
1152
+ signal.removeEventListener("abort", onAborted);
858
1153
  }
859
1154
  }
860
1155
  //#endregion
861
- //#region src/port-credit.ts
862
- /**
863
- * Runs `fn` on a later task, never a microtask: a producer looping on
864
- * `whenPortReady` must let the transport's own events run. `setImmediate`
865
- * where it exists; elsewhere a `MessageChannel`, because nested `setTimeout`s
866
- * are clamped to 4 ms in browsers.
867
- */
868
- const setImmediateFn = globalThis.setImmediate;
869
- /** @internal */
870
- const nextTask = setImmediateFn ? (fn) => {
871
- setImmediateFn(fn);
872
- } : (() => {
873
- let channel;
874
- const queue = [];
875
- return (fn) => {
876
- if (!channel) {
877
- channel = new MessageChannel();
878
- channel.port1.onmessage = () => queue.shift()?.();
879
- }
880
- queue.push(fn);
881
- channel.port2.postMessage(0);
882
- };
883
- })();
884
- const states = /* @__PURE__ */ new WeakMap();
885
- /** @internal Registered by the multiplexer for each port with a window. */
886
- function setPortSendState(port, state) {
887
- states.set(port, state);
888
- }
889
- /**
890
- * Bytes queued on `port` because its window is spent (0 for a port without
891
- * flow control). A sender that should not queue without bound waits for
892
- * `whenPortReady` before posting more.
893
- */
894
- function portBufferedAmount(port) {
895
- return states.get(port)?.bufferedAmount() ?? 0;
896
- }
897
- /**
898
- * Resolves once at most `below` bytes are queued on `port`, always on a later
899
- * task (for a port without flow control, on the next one).
900
- */
901
- function whenPortReady(port, below = 0) {
902
- return states.get(port)?.ready(below) ?? new Promise((resolve) => nextTask(resolve));
903
- }
904
- //#endregion
905
1156
  //#region src/virtual-port.ts
906
1157
  /**
907
1158
  * One virtual port.
@@ -988,6 +1239,21 @@ const MUX_CLOSED_MESSAGE = "webrun-rpc: the multiplexer was closed";
988
1239
  const KEEPALIVE_ID = Number.MAX_SAFE_INTEGER;
989
1240
  /** The longest delay a timer holds; past it `setInterval` fires at once. */
990
1241
  const MAX_TIMER_MS = 2 ** 31 - 1;
1242
+ /**
1243
+ * What a windowed port offers in its `open`: every capability this end
1244
+ * speaks. `end` only over a codec that reads it (`PortCodec.halfClose`).
1245
+ */
1246
+ function ownCaps(codec) {
1247
+ return codec.halfClose ? ["pipeline", "end"] : ["pipeline"];
1248
+ }
1249
+ /**
1250
+ * The capabilities in a peer's `caps` that this end speaks too, or
1251
+ * `undefined` when the peer listed none (it is then treated as an older peer).
1252
+ */
1253
+ function sharedCaps(own, listed) {
1254
+ if (!Array.isArray(listed)) return void 0;
1255
+ return new Set(own.filter((cap) => listed.includes(cap)));
1256
+ }
991
1257
  /** Ceiling on concurrently open virtual ports. Bounds the id table only. */
992
1258
  const DEFAULT_MAX_PORTS = 1024;
993
1259
  /** Default ceiling on one message reassembled from frames (`maxReassembledBytes`). */
@@ -1053,6 +1319,8 @@ function multiplexPort(port, options) {
1053
1319
  let muxClosed = false;
1054
1320
  /** This end counts encoded bytes and reassembles frames. */
1055
1321
  const exactCodec = codec.encode != null && codec.decode != null;
1322
+ /** What this end offers on a windowed port. */
1323
+ const caps = ownCaps(codec);
1056
1324
  const post = (envelope, transfer) => {
1057
1325
  if (muxClosed) return;
1058
1326
  codecPost(envelope, transfer);
@@ -1110,13 +1378,39 @@ function multiplexPort(port, options) {
1110
1378
  port.postMessage(bytes);
1111
1379
  } catch {}
1112
1380
  };
1381
+ /**
1382
+ * Both directions have ended: free the id as a close would, with no `close`
1383
+ * on the wire (each end frees it on its own once it has seen both ends).
1384
+ * Aborting the close signal is how the port's consumer learns it is gone.
1385
+ */
1386
+ const freeIfEnded = (id, c) => {
1387
+ const entry = open.get(id);
1388
+ if (!c.endSent || !c.peerEnded || entry?.credit !== c) return;
1389
+ freePartial(entry);
1390
+ open.delete(id);
1391
+ leaks?.unregister(entry.handle);
1392
+ entry.handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the port ended in both directions"));
1393
+ };
1394
+ /** A peer that broke the port's protocol: hold none of its traffic, and close. */
1395
+ const failPort = (id, entry, error, reason) => {
1396
+ freePartial(entry);
1397
+ dropOutgoing(id);
1398
+ open.delete(id);
1399
+ releaseWaiters(entry.credit);
1400
+ entry.handle.markClosed(error);
1401
+ post({
1402
+ type: "close",
1403
+ id,
1404
+ reason
1405
+ });
1406
+ };
1113
1407
  const releaseWaiters = (c) => {
1114
1408
  for (const w of c?.waiters.splice(0) ?? []) w.resolve();
1115
1409
  };
1116
1410
  const flush = (c) => {
1117
1411
  while (c.queue.length) {
1118
1412
  const next = c.queue[0];
1119
- if (c.enforced && c.outstanding > 0 && c.outstanding + next.size > c.window) break;
1413
+ if (next.size > 0 && c.enforced && c.outstanding > 0 && c.outstanding + next.size > c.window) break;
1120
1414
  c.queue.shift();
1121
1415
  c.queued -= next.size;
1122
1416
  c.outstanding += next.size;
@@ -1154,15 +1448,20 @@ function multiplexPort(port, options) {
1154
1448
  enforced: false,
1155
1449
  opener,
1156
1450
  exact,
1451
+ caps: /* @__PURE__ */ new Set(),
1157
1452
  outstanding: 0,
1158
1453
  queue: [],
1159
1454
  queued: 0,
1160
1455
  unconsumed: 0,
1161
1456
  waiters: [],
1162
1457
  toGrant: 0,
1163
- grantScheduled: false
1458
+ grantScheduled: false,
1459
+ ending: false,
1460
+ endSent: false,
1461
+ peerEnded: false
1164
1462
  } : void 0;
1165
1463
  const self = newVirtualPort((payload, transfer) => {
1464
+ if (c?.ending) return;
1166
1465
  touch(id);
1167
1466
  const envelope = {
1168
1467
  type: "message",
@@ -1249,6 +1548,38 @@ function multiplexPort(port, options) {
1249
1548
  });
1250
1549
  })
1251
1550
  });
1551
+ setPortEndState(self.port, {
1552
+ end: () => {
1553
+ if (isClosed() || !c.caps.has("end")) return false;
1554
+ if (c.ending) return true;
1555
+ c.ending = true;
1556
+ c.queue.push({
1557
+ size: 0,
1558
+ send: () => sendOnPort(id, () => {
1559
+ if (open.get(id)?.credit !== c) return;
1560
+ post({
1561
+ type: "end",
1562
+ id
1563
+ });
1564
+ c.endSent = true;
1565
+ freeIfEnded(id, c);
1566
+ })
1567
+ });
1568
+ flush(c);
1569
+ return true;
1570
+ },
1571
+ watchPeerEnd: (resolve) => {
1572
+ if (!c.caps.has("end")) return false;
1573
+ if (c.peerEnded) resolve();
1574
+ else c.onPeerEnd = new WeakRef(resolve);
1575
+ return true;
1576
+ }
1577
+ });
1578
+ setPortAgreement(self.port, () => ({
1579
+ window: c.window,
1580
+ caps: c.caps,
1581
+ enforced: c.enforced
1582
+ }));
1252
1583
  }
1253
1584
  const record = {
1254
1585
  deliver: self.deliver,
@@ -1300,16 +1631,24 @@ function multiplexPort(port, options) {
1300
1631
  }
1301
1632
  const handle = attach(envelope.id, envelope.meta, envelope.window, false, envelope.exact === true && exactCodec);
1302
1633
  const credit = open.get(envelope.id)?.credit;
1303
- if (credit) post(credit.exact ? {
1304
- type: "credit",
1305
- id: envelope.id,
1306
- bytes: 0,
1307
- exact: true
1308
- } : {
1309
- type: "credit",
1310
- id: envelope.id,
1311
- bytes: 0
1312
- });
1634
+ const listed = sharedCaps(caps, envelope.caps);
1635
+ if (credit) {
1636
+ const ack = credit.exact ? {
1637
+ type: "credit",
1638
+ id: envelope.id,
1639
+ bytes: 0,
1640
+ exact: true
1641
+ } : {
1642
+ type: "credit",
1643
+ id: envelope.id,
1644
+ bytes: 0
1645
+ };
1646
+ if (listed) credit.caps = listed;
1647
+ post(listed ? {
1648
+ ...ack,
1649
+ caps: [...listed]
1650
+ } : ack);
1651
+ }
1313
1652
  let accepted = false;
1314
1653
  if (onPort) try {
1315
1654
  accepted = onPort(handle.port, envelope.meta) !== false;
@@ -1337,6 +1676,7 @@ function multiplexPort(port, options) {
1337
1676
  c.exact = false;
1338
1677
  c.outstanding = 0;
1339
1678
  }
1679
+ c.caps = sharedCaps(caps, envelope.caps) ?? /* @__PURE__ */ new Set();
1340
1680
  post({
1341
1681
  type: "credit",
1342
1682
  id: envelope.id,
@@ -1348,6 +1688,10 @@ function multiplexPort(port, options) {
1348
1688
  flush(c);
1349
1689
  return;
1350
1690
  }
1691
+ if ((envelope.type === "message" || envelope.type === "frame") && entry?.credit?.peerEnded) {
1692
+ failPort(envelope.id, entry, /* @__PURE__ */ new Error("webrun-rpc: the peer sent on a port after its end"), "protocol");
1693
+ return;
1694
+ }
1351
1695
  if (envelope.type === "message") {
1352
1696
  const sizeOf = (c) => (c.exact ? byteLengthOf(event.data) : void 0) ?? estimateMessageSize(envelope.payload);
1353
1697
  if (!entry) return;
@@ -1372,16 +1716,7 @@ function multiplexPort(port, options) {
1372
1716
  const charged = partial.room == null ? Math.max(0, partial.bytes + size - c.window) : 0;
1373
1717
  const overrun = partial.room != null && partial.bytes + size > partial.room ? `what the window left (${partial.room} of ${c.window} bytes, ${partial.owed} still owed)` : reassembling - partial.charged + charged > maxReassembledBytes ? `maxReassembledBytes (${maxReassembledBytes} bytes past the window, across all ports)` : void 0;
1374
1718
  if (overrun) {
1375
- freePartial(entry);
1376
- dropOutgoing(envelope.id);
1377
- open.delete(envelope.id);
1378
- releaseWaiters(c);
1379
- entry.handle.markClosed(/* @__PURE__ */ new RangeError(`webrun-rpc: a framed message passed ${overrun}`));
1380
- post({
1381
- type: "close",
1382
- id: envelope.id,
1383
- reason: "max-reassembled-bytes"
1384
- });
1719
+ failPort(envelope.id, entry, /* @__PURE__ */ new RangeError(`webrun-rpc: a framed message passed ${overrun}`), "max-reassembled-bytes");
1385
1720
  return;
1386
1721
  }
1387
1722
  partial.bytes += size;
@@ -1401,6 +1736,19 @@ function multiplexPort(port, options) {
1401
1736
  entry.handle.deliver(inner.payload, whole.byteLength);
1402
1737
  return;
1403
1738
  }
1739
+ if (envelope.type === "end") {
1740
+ if (!entry) return;
1741
+ const c = entry.credit;
1742
+ if (!c?.caps.has("end")) {
1743
+ failPort(envelope.id, entry, /* @__PURE__ */ new Error("webrun-rpc: the peer ended a port where end was not agreed"), "protocol");
1744
+ return;
1745
+ }
1746
+ touch(envelope.id);
1747
+ c.peerEnded = true;
1748
+ c.onPeerEnd?.deref()?.();
1749
+ freeIfEnded(envelope.id, c);
1750
+ return;
1751
+ }
1404
1752
  dropOutgoing(envelope.id);
1405
1753
  if (!entry) return;
1406
1754
  freePartial(entry);
@@ -1482,12 +1830,14 @@ function multiplexPort(port, options) {
1482
1830
  id,
1483
1831
  meta,
1484
1832
  window,
1485
- exact: true
1833
+ exact: true,
1834
+ caps: [...caps]
1486
1835
  } : {
1487
1836
  type: "open",
1488
1837
  id,
1489
1838
  meta,
1490
- window
1839
+ window,
1840
+ caps: [...caps]
1491
1841
  } : {
1492
1842
  type: "open",
1493
1843
  id,
@@ -1710,7 +2060,7 @@ function isEnvelope(value) {
1710
2060
  const candidate = value;
1711
2061
  if (typeof candidate.id !== "number") return false;
1712
2062
  if (!Number.isInteger(candidate.id) || candidate.id < 0) return false;
1713
- return candidate.type === "open" || candidate.type === "message" || candidate.type === "close" || candidate.type === "credit" || candidate.type === "frame" || candidate.type === "ping" || candidate.type === "pong";
2063
+ return candidate.type === "open" || candidate.type === "message" || candidate.type === "close" || candidate.type === "credit" || candidate.type === "frame" || candidate.type === "ping" || candidate.type === "pong" || candidate.type === "end";
1714
2064
  }
1715
2065
  /**
1716
2066
  * For ports whose messages are structured values — a real `MessagePort`, a
@@ -1722,6 +2072,7 @@ function isEnvelope(value) {
1722
2072
  * not send anything a byte codec could not also carry.
1723
2073
  */
1724
2074
  const structuredCodec = {
2075
+ halfClose: true,
1725
2076
  post(port, envelope, transfer) {
1726
2077
  if (transfer && transfer.length > 0) port.postMessage(envelope, transfer);
1727
2078
  else port.postMessage(envelope);