@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/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,21 +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) {
828
- const stream = throughAbort(maxMessageSize ? toChunks(maxMessageSize)(output) : output, signal);
1103
+ async function sendChunks(port, channelName, output, { maxMessageSize, log }, signal, touch, onSent) {
1104
+ const framed = maxMessageSize ? toChunks(maxMessageSize)(output) : output;
1105
+ const stream = throughAbort(framed, signal);
1106
+ const producer = output;
1107
+ const returnProducer = () => void Promise.resolve(producer.return?.()).catch(() => {});
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 });
829
1115
  try {
830
1116
  await sendIterator(async ({ done, value, error }) => {
831
1117
  if (signal.aborted) return;
832
1118
  const chunk = {
833
1119
  done,
834
- value,
1120
+ value: done ? value : chunkBytes(value),
835
1121
  error: error === void 0 ? void 0 : serializeError(error)
836
1122
  };
837
1123
  log?.("[duplexOverPort] send", {
@@ -839,63 +1125,34 @@ async function sendChunks(port, channelName, output, { maxMessageSize, log }, si
839
1125
  done,
840
1126
  size: value?.byteLength
841
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
+ }
842
1139
  await callPort(port, chunk, {
843
1140
  channelName,
844
1141
  timeout: NO_TIMEOUT,
845
1142
  signal
846
1143
  });
847
1144
  touch();
1145
+ if (done && !error) onSent();
848
1146
  }, stream);
849
1147
  } catch (err) {
850
1148
  if (signal.aborted) return;
851
1149
  throw err;
1150
+ } finally {
1151
+ signal.removeEventListener("abort", returnProducer);
1152
+ signal.removeEventListener("abort", onAborted);
852
1153
  }
853
1154
  }
854
1155
  //#endregion
855
- //#region src/port-credit.ts
856
- /**
857
- * Runs `fn` on a later task, never a microtask: a producer looping on
858
- * `whenPortReady` must let the transport's own events run. `setImmediate`
859
- * where it exists; elsewhere a `MessageChannel`, because nested `setTimeout`s
860
- * are clamped to 4 ms in browsers.
861
- */
862
- const setImmediateFn = globalThis.setImmediate;
863
- /** @internal */
864
- const nextTask = setImmediateFn ? (fn) => {
865
- setImmediateFn(fn);
866
- } : (() => {
867
- let channel;
868
- const queue = [];
869
- return (fn) => {
870
- if (!channel) {
871
- channel = new MessageChannel();
872
- channel.port1.onmessage = () => queue.shift()?.();
873
- }
874
- queue.push(fn);
875
- channel.port2.postMessage(0);
876
- };
877
- })();
878
- const states = /* @__PURE__ */ new WeakMap();
879
- /** @internal Registered by the multiplexer for each port with a window. */
880
- function setPortSendState(port, state) {
881
- states.set(port, state);
882
- }
883
- /**
884
- * Bytes queued on `port` because its window is spent (0 for a port without
885
- * flow control). A sender that should not queue without bound waits for
886
- * `whenPortReady` before posting more.
887
- */
888
- function portBufferedAmount(port) {
889
- return states.get(port)?.bufferedAmount() ?? 0;
890
- }
891
- /**
892
- * Resolves once at most `below` bytes are queued on `port`, always on a later
893
- * task (for a port without flow control, on the next one).
894
- */
895
- function whenPortReady(port, below = 0) {
896
- return states.get(port)?.ready(below) ?? new Promise((resolve) => nextTask(resolve));
897
- }
898
- //#endregion
899
1156
  //#region src/virtual-port.ts
900
1157
  /**
901
1158
  * One virtual port.
@@ -982,8 +1239,25 @@ const MUX_CLOSED_MESSAGE = "webrun-rpc: the multiplexer was closed";
982
1239
  const KEEPALIVE_ID = Number.MAX_SAFE_INTEGER;
983
1240
  /** The longest delay a timer holds; past it `setInterval` fires at once. */
984
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
+ }
985
1257
  /** Ceiling on concurrently open virtual ports. Bounds the id table only. */
986
1258
  const DEFAULT_MAX_PORTS = 1024;
1259
+ /** Default ceiling on one message reassembled from frames (`maxReassembledBytes`). */
1260
+ const DEFAULT_MAX_REASSEMBLED_BYTES = 67108864;
987
1261
  /**
988
1262
  * What a message costs a port's window when the codec cannot say
989
1263
  * (`PortCodec.encode`): its bytes and string lengths, found in its values up
@@ -1013,10 +1287,18 @@ function estimateMessageSize(payload) {
1013
1287
  */
1014
1288
  function multiplexPort(port, options) {
1015
1289
  const { codec, onPort, side = "initiator", maxPorts = DEFAULT_MAX_PORTS, maxMessageSize, signal, window, frameSize, keepalive, onLeak } = options;
1290
+ const maxReassembledBytes = options.maxReassembledBytes ?? 67108864;
1016
1291
  const misses = keepalive?.misses ?? 3;
1017
1292
  if (keepalive && !(Number.isFinite(keepalive.intervalMs) && keepalive.intervalMs > 0 && keepalive.intervalMs <= MAX_TIMER_MS && Number.isSafeInteger(misses) && misses > 0)) throw new RangeError(`webrun-rpc: keepalive needs an intervalMs in (0, ${MAX_TIMER_MS}] and a positive integer misses`);
1018
1293
  if (frameSize != null && !(Number.isSafeInteger(frameSize) && frameSize > 0)) throw new RangeError(`webrun-rpc: frameSize must be a positive integer, got ${frameSize}`);
1294
+ if (!(Number.isSafeInteger(maxReassembledBytes) && maxReassembledBytes > 0)) throw new RangeError(`webrun-rpc: maxReassembledBytes must be a positive integer, got ${maxReassembledBytes}`);
1019
1295
  const open = /* @__PURE__ */ new Map();
1296
+ let reassembling = 0;
1297
+ const freePartial = (entry) => {
1298
+ if (!entry?.partial) return;
1299
+ reassembling -= entry.partial.charged;
1300
+ entry.partial = void 0;
1301
+ };
1020
1302
  const leaks = onLeak ? new FinalizationRegistry(({ id, record, release }) => {
1021
1303
  const entry = open.get(id);
1022
1304
  if (entry?.handle !== record) return;
@@ -1037,6 +1319,8 @@ function multiplexPort(port, options) {
1037
1319
  let muxClosed = false;
1038
1320
  /** This end counts encoded bytes and reassembles frames. */
1039
1321
  const exactCodec = codec.encode != null && codec.decode != null;
1322
+ /** What this end offers on a windowed port. */
1323
+ const caps = ownCaps(codec);
1040
1324
  const post = (envelope, transfer) => {
1041
1325
  if (muxClosed) return;
1042
1326
  codecPost(envelope, transfer);
@@ -1094,13 +1378,39 @@ function multiplexPort(port, options) {
1094
1378
  port.postMessage(bytes);
1095
1379
  } catch {}
1096
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
+ };
1097
1407
  const releaseWaiters = (c) => {
1098
1408
  for (const w of c?.waiters.splice(0) ?? []) w.resolve();
1099
1409
  };
1100
1410
  const flush = (c) => {
1101
1411
  while (c.queue.length) {
1102
1412
  const next = c.queue[0];
1103
- 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;
1104
1414
  c.queue.shift();
1105
1415
  c.queued -= next.size;
1106
1416
  c.outstanding += next.size;
@@ -1138,15 +1448,20 @@ function multiplexPort(port, options) {
1138
1448
  enforced: false,
1139
1449
  opener,
1140
1450
  exact,
1451
+ caps: /* @__PURE__ */ new Set(),
1141
1452
  outstanding: 0,
1142
1453
  queue: [],
1143
1454
  queued: 0,
1144
1455
  unconsumed: 0,
1145
1456
  waiters: [],
1146
1457
  toGrant: 0,
1147
- grantScheduled: false
1458
+ grantScheduled: false,
1459
+ ending: false,
1460
+ endSent: false,
1461
+ peerEnded: false
1148
1462
  } : void 0;
1149
1463
  const self = newVirtualPort((payload, transfer) => {
1464
+ if (c?.ending) return;
1150
1465
  touch(id);
1151
1466
  const envelope = {
1152
1467
  type: "message",
@@ -1164,12 +1479,12 @@ function multiplexPort(port, options) {
1164
1479
  ...envelope,
1165
1480
  payload: structuredClone(payload, { transfer })
1166
1481
  } : void 0;
1167
- const send = () => {
1482
+ const send = (whole = false) => {
1168
1483
  if (!bytes) {
1169
1484
  sendOnPort(id, held ? () => post(held) : () => post(envelope, transfer));
1170
1485
  return;
1171
1486
  }
1172
- if (frameSize != null && c.enforced && c.exact && bytes.byteLength > frameSize) {
1487
+ if (!whole && frameSize != null && c.enforced && c.exact && bytes.byteLength > frameSize) {
1173
1488
  for (let at = 0; at < bytes.byteLength; at += frameSize) {
1174
1489
  const data = bytes.subarray(at, at + frameSize);
1175
1490
  const frame = at + frameSize >= bytes.byteLength ? {
@@ -1199,10 +1514,11 @@ function multiplexPort(port, options) {
1199
1514
  record.markClosed(reason);
1200
1515
  });
1201
1516
  function release(reason) {
1517
+ freePartial(open.get(id));
1202
1518
  open.delete(id);
1203
1519
  leaks?.unregister(record);
1204
1520
  if (c?.queue.length) {
1205
- for (const item of c.queue.splice(0)) item.send();
1521
+ for (const item of c.queue.splice(0)) item.send(true);
1206
1522
  c.queued = 0;
1207
1523
  }
1208
1524
  sendOnPort(id, () => post({
@@ -1232,6 +1548,38 @@ function multiplexPort(port, options) {
1232
1548
  });
1233
1549
  })
1234
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
+ }));
1235
1583
  }
1236
1584
  const record = {
1237
1585
  deliver: self.deliver,
@@ -1283,16 +1631,24 @@ function multiplexPort(port, options) {
1283
1631
  }
1284
1632
  const handle = attach(envelope.id, envelope.meta, envelope.window, false, envelope.exact === true && exactCodec);
1285
1633
  const credit = open.get(envelope.id)?.credit;
1286
- if (credit) post(credit.exact ? {
1287
- type: "credit",
1288
- id: envelope.id,
1289
- bytes: 0,
1290
- exact: true
1291
- } : {
1292
- type: "credit",
1293
- id: envelope.id,
1294
- bytes: 0
1295
- });
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
+ }
1296
1652
  let accepted = false;
1297
1653
  if (onPort) try {
1298
1654
  accepted = onPort(handle.port, envelope.meta) !== false;
@@ -1320,6 +1676,7 @@ function multiplexPort(port, options) {
1320
1676
  c.exact = false;
1321
1677
  c.outstanding = 0;
1322
1678
  }
1679
+ c.caps = sharedCaps(caps, envelope.caps) ?? /* @__PURE__ */ new Set();
1323
1680
  post({
1324
1681
  type: "credit",
1325
1682
  id: envelope.id,
@@ -1331,6 +1688,10 @@ function multiplexPort(port, options) {
1331
1688
  flush(c);
1332
1689
  return;
1333
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
+ }
1334
1695
  if (envelope.type === "message") {
1335
1696
  const sizeOf = (c) => (c.exact ? byteLengthOf(event.data) : void 0) ?? estimateMessageSize(envelope.payload);
1336
1697
  if (!entry) return;
@@ -1341,15 +1702,29 @@ function multiplexPort(port, options) {
1341
1702
  if (envelope.type === "frame") {
1342
1703
  if (!entry?.credit?.exact || !codec.decode || !(envelope.data instanceof Uint8Array)) return;
1343
1704
  touch(envelope.id);
1705
+ const c = entry.credit;
1706
+ const owed = c.unconsumed + c.toGrant;
1344
1707
  const partial = entry.partial ?? {
1345
1708
  chunks: [],
1346
- bytes: 0
1709
+ bytes: 0,
1710
+ owed,
1711
+ charged: 0,
1712
+ ...owed > 0 ? { room: c.window - owed } : {}
1347
1713
  };
1348
1714
  entry.partial = partial;
1715
+ const size = envelope.data.byteLength;
1716
+ const charged = partial.room == null ? Math.max(0, partial.bytes + size - c.window) : 0;
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;
1718
+ if (overrun) {
1719
+ failPort(envelope.id, entry, /* @__PURE__ */ new RangeError(`webrun-rpc: a framed message passed ${overrun}`), "max-reassembled-bytes");
1720
+ return;
1721
+ }
1722
+ partial.bytes += size;
1723
+ reassembling += charged - partial.charged;
1724
+ partial.charged = charged;
1349
1725
  partial.chunks.push(envelope.data);
1350
- partial.bytes += envelope.data.byteLength;
1351
1726
  if (!envelope.last) return;
1352
- entry.partial = void 0;
1727
+ freePartial(entry);
1353
1728
  const whole = new Uint8Array(partial.bytes);
1354
1729
  let at = 0;
1355
1730
  for (const chunk of partial.chunks) {
@@ -1361,8 +1736,22 @@ function multiplexPort(port, options) {
1361
1736
  entry.handle.deliver(inner.payload, whole.byteLength);
1362
1737
  return;
1363
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
+ }
1364
1752
  dropOutgoing(envelope.id);
1365
1753
  if (!entry) return;
1754
+ freePartial(entry);
1366
1755
  open.delete(envelope.id);
1367
1756
  releaseWaiters(entry.credit);
1368
1757
  entry.handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the peer closed the port"));
@@ -1441,12 +1830,14 @@ function multiplexPort(port, options) {
1441
1830
  id,
1442
1831
  meta,
1443
1832
  window,
1444
- exact: true
1833
+ exact: true,
1834
+ caps: [...caps]
1445
1835
  } : {
1446
1836
  type: "open",
1447
1837
  id,
1448
1838
  meta,
1449
- window
1839
+ window,
1840
+ caps: [...caps]
1450
1841
  } : {
1451
1842
  type: "open",
1452
1843
  id,
@@ -1555,14 +1946,17 @@ const serve = async ({ mux: factory, timeout }, handler) => {
1555
1946
  const mux = await factory((port) => {
1556
1947
  let off;
1557
1948
  let finished = false;
1558
- const tracked = (input) => (async function* () {
1559
- try {
1560
- yield* handler(input);
1561
- } finally {
1562
- finished = true;
1563
- if (off) live.delete(off);
1564
- }
1565
- })();
1949
+ const tracked = (input) => {
1950
+ const output = handler(input);
1951
+ return returningAtOnce((async function* () {
1952
+ try {
1953
+ yield* output;
1954
+ } finally {
1955
+ finished = true;
1956
+ if (off) live.delete(off);
1957
+ }
1958
+ })(), () => void output.return(void 0).catch(() => {}));
1959
+ };
1566
1960
  off = serveDuplexOverPort(port, tracked, streamOptions);
1567
1961
  if (!finished) live.add(off);
1568
1962
  });
@@ -1666,7 +2060,7 @@ function isEnvelope(value) {
1666
2060
  const candidate = value;
1667
2061
  if (typeof candidate.id !== "number") return false;
1668
2062
  if (!Number.isInteger(candidate.id) || candidate.id < 0) return false;
1669
- 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";
1670
2064
  }
1671
2065
  /**
1672
2066
  * For ports whose messages are structured values — a real `MessagePort`, a
@@ -1678,6 +2072,7 @@ function isEnvelope(value) {
1678
2072
  * not send anything a byte codec could not also carry.
1679
2073
  */
1680
2074
  const structuredCodec = {
2075
+ halfClose: true,
1681
2076
  post(port, envelope, transfer) {
1682
2077
  if (transfer && transfer.length > 0) port.postMessage(envelope, transfer);
1683
2078
  else port.postMessage(envelope);
@@ -1780,4 +2175,4 @@ function transferPortMux(target, options = {}) {
1780
2175
  };
1781
2176
  }
1782
2177
  //#endregion
1783
- export { CANCEL_CHANNEL_TYPE, DEFAULT_MAX_PORTS, MUX_CLOSED_MESSAGE, NO_TIMEOUT, PORT_TRANSFER, STREAM_ABORT, byteChannelFromMessagePort, callBidi, callPort, connect, duplexOverPort, getPortCloseSignal, ioHandle, ioSend, listenBidi, listenCancelChannel, listenPort, multiplexPort, overPipe, overPorts, portBufferedAmount, postCancelChannel, recieve, send, serve, serveDuplexOverPort, setPortCloseSignal, structuredCodec, transferPortMux, whenPortReady };
2178
+ export { CANCEL_CHANNEL_TYPE, DEFAULT_MAX_PORTS, DEFAULT_MAX_REASSEMBLED_BYTES, MUX_CLOSED_MESSAGE, NO_TIMEOUT, PORT_TRANSFER, STREAM_ABORT, byteChannelFromMessagePort, callBidi, callPort, connect, duplexOverPort, getPortCloseSignal, ioHandle, ioSend, listenBidi, listenCancelChannel, listenPort, multiplexPort, overPipe, overPorts, portBufferedAmount, postCancelChannel, recieve, send, serve, serveDuplexOverPort, setPortCloseSignal, structuredCodec, transferPortMux, whenPortReady };