@statewalker/webrun-rpc 0.8.0 → 0.10.1

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,14 +1089,18 @@ 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;
@@ -835,7 +1111,7 @@ async function sendChunks(port, channelName, output, { maxMessageSize, log }, si
835
1111
  if (signal.aborted) return;
836
1112
  const chunk = {
837
1113
  done,
838
- value,
1114
+ value: done ? value : chunkBytes(value),
839
1115
  error: error === void 0 ? void 0 : serializeError(error)
840
1116
  };
841
1117
  log?.("[duplexOverPort] send", {
@@ -843,12 +1119,24 @@ async function sendChunks(port, channelName, output, { maxMessageSize, log }, si
843
1119
  done,
844
1120
  size: value?.byteLength
845
1121
  });
1122
+ if (mayPipeline(port)) {
1123
+ port.postMessage({
1124
+ type: CHUNK,
1125
+ channelName,
1126
+ chunk
1127
+ });
1128
+ touch();
1129
+ if (done && !error) onSent();
1130
+ if (portBufferedAmount(port) > 0) await whenReadyOrAborted(port, signal);
1131
+ return;
1132
+ }
846
1133
  await callPort(port, chunk, {
847
1134
  channelName,
848
1135
  timeout: NO_TIMEOUT,
849
1136
  signal
850
1137
  });
851
1138
  touch();
1139
+ if (done && !error) onSent();
852
1140
  }, stream);
853
1141
  } catch (err) {
854
1142
  if (signal.aborted) return;
@@ -857,49 +1145,21 @@ async function sendChunks(port, channelName, output, { maxMessageSize, log }, si
857
1145
  signal.removeEventListener("abort", returnProducer);
858
1146
  }
859
1147
  }
860
- //#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
1148
  /**
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.
1149
+ * A pipelined sender waits only for room in the window, or for the abort. The
1150
+ * abort listener lives for this one wait: a promise held for the whole stream
1151
+ * would keep one reaction per wait until the stream ends.
893
1152
  */
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));
1153
+ function whenReadyOrAborted(port, signal) {
1154
+ if (signal.aborted) return Promise.resolve();
1155
+ return new Promise((resolve) => {
1156
+ const settle = () => {
1157
+ signal.removeEventListener("abort", settle);
1158
+ resolve();
1159
+ };
1160
+ signal.addEventListener("abort", settle, { once: true });
1161
+ whenPortReady(port).then(settle, settle);
1162
+ });
903
1163
  }
904
1164
  //#endregion
905
1165
  //#region src/virtual-port.ts
@@ -988,6 +1248,21 @@ const MUX_CLOSED_MESSAGE = "webrun-rpc: the multiplexer was closed";
988
1248
  const KEEPALIVE_ID = Number.MAX_SAFE_INTEGER;
989
1249
  /** The longest delay a timer holds; past it `setInterval` fires at once. */
990
1250
  const MAX_TIMER_MS = 2 ** 31 - 1;
1251
+ /**
1252
+ * What a windowed port offers in its `open`: every capability this end
1253
+ * speaks. `end` only over a codec that reads it (`PortCodec.halfClose`).
1254
+ */
1255
+ function ownCaps(codec) {
1256
+ return codec.halfClose ? ["pipeline", "end"] : ["pipeline"];
1257
+ }
1258
+ /**
1259
+ * The capabilities in a peer's `caps` that this end speaks too, or
1260
+ * `undefined` when the peer listed none (it is then treated as an older peer).
1261
+ */
1262
+ function sharedCaps(own, listed) {
1263
+ if (!Array.isArray(listed)) return void 0;
1264
+ return new Set(own.filter((cap) => listed.includes(cap)));
1265
+ }
991
1266
  /** Ceiling on concurrently open virtual ports. Bounds the id table only. */
992
1267
  const DEFAULT_MAX_PORTS = 1024;
993
1268
  /** Default ceiling on one message reassembled from frames (`maxReassembledBytes`). */
@@ -1053,6 +1328,8 @@ function multiplexPort(port, options) {
1053
1328
  let muxClosed = false;
1054
1329
  /** This end counts encoded bytes and reassembles frames. */
1055
1330
  const exactCodec = codec.encode != null && codec.decode != null;
1331
+ /** What this end offers on a windowed port. */
1332
+ const caps = ownCaps(codec);
1056
1333
  const post = (envelope, transfer) => {
1057
1334
  if (muxClosed) return;
1058
1335
  codecPost(envelope, transfer);
@@ -1110,13 +1387,39 @@ function multiplexPort(port, options) {
1110
1387
  port.postMessage(bytes);
1111
1388
  } catch {}
1112
1389
  };
1390
+ /**
1391
+ * Both directions have ended: free the id as a close would, with no `close`
1392
+ * on the wire (each end frees it on its own once it has seen both ends).
1393
+ * Aborting the close signal is how the port's consumer learns it is gone.
1394
+ */
1395
+ const freeIfEnded = (id, c) => {
1396
+ const entry = open.get(id);
1397
+ if (!c.endSent || !c.peerEnded || entry?.credit !== c) return;
1398
+ freePartial(entry);
1399
+ open.delete(id);
1400
+ leaks?.unregister(entry.handle);
1401
+ entry.handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the port ended in both directions"));
1402
+ };
1403
+ /** A peer that broke the port's protocol: hold none of its traffic, and close. */
1404
+ const failPort = (id, entry, error, reason) => {
1405
+ freePartial(entry);
1406
+ dropOutgoing(id);
1407
+ open.delete(id);
1408
+ releaseWaiters(entry.credit);
1409
+ entry.handle.markClosed(error);
1410
+ post({
1411
+ type: "close",
1412
+ id,
1413
+ reason
1414
+ });
1415
+ };
1113
1416
  const releaseWaiters = (c) => {
1114
1417
  for (const w of c?.waiters.splice(0) ?? []) w.resolve();
1115
1418
  };
1116
1419
  const flush = (c) => {
1117
1420
  while (c.queue.length) {
1118
1421
  const next = c.queue[0];
1119
- if (c.enforced && c.outstanding > 0 && c.outstanding + next.size > c.window) break;
1422
+ if (next.size > 0 && c.enforced && c.outstanding > 0 && c.outstanding + next.size > c.window) break;
1120
1423
  c.queue.shift();
1121
1424
  c.queued -= next.size;
1122
1425
  c.outstanding += next.size;
@@ -1154,15 +1457,20 @@ function multiplexPort(port, options) {
1154
1457
  enforced: false,
1155
1458
  opener,
1156
1459
  exact,
1460
+ caps: /* @__PURE__ */ new Set(),
1157
1461
  outstanding: 0,
1158
1462
  queue: [],
1159
1463
  queued: 0,
1160
1464
  unconsumed: 0,
1161
1465
  waiters: [],
1162
1466
  toGrant: 0,
1163
- grantScheduled: false
1467
+ grantScheduled: false,
1468
+ ending: false,
1469
+ endSent: false,
1470
+ peerEnded: false
1164
1471
  } : void 0;
1165
1472
  const self = newVirtualPort((payload, transfer) => {
1473
+ if (c?.ending) return;
1166
1474
  touch(id);
1167
1475
  const envelope = {
1168
1476
  type: "message",
@@ -1249,6 +1557,38 @@ function multiplexPort(port, options) {
1249
1557
  });
1250
1558
  })
1251
1559
  });
1560
+ setPortEndState(self.port, {
1561
+ end: () => {
1562
+ if (isClosed() || !c.caps.has("end")) return false;
1563
+ if (c.ending) return true;
1564
+ c.ending = true;
1565
+ c.queue.push({
1566
+ size: 0,
1567
+ send: () => sendOnPort(id, () => {
1568
+ if (open.get(id)?.credit !== c) return;
1569
+ post({
1570
+ type: "end",
1571
+ id
1572
+ });
1573
+ c.endSent = true;
1574
+ freeIfEnded(id, c);
1575
+ })
1576
+ });
1577
+ flush(c);
1578
+ return true;
1579
+ },
1580
+ watchPeerEnd: (resolve) => {
1581
+ if (!c.caps.has("end")) return false;
1582
+ if (c.peerEnded) resolve();
1583
+ else c.onPeerEnd = new WeakRef(resolve);
1584
+ return true;
1585
+ }
1586
+ });
1587
+ setPortAgreement(self.port, () => ({
1588
+ window: c.window,
1589
+ caps: c.caps,
1590
+ enforced: c.enforced
1591
+ }));
1252
1592
  }
1253
1593
  const record = {
1254
1594
  deliver: self.deliver,
@@ -1300,16 +1640,24 @@ function multiplexPort(port, options) {
1300
1640
  }
1301
1641
  const handle = attach(envelope.id, envelope.meta, envelope.window, false, envelope.exact === true && exactCodec);
1302
1642
  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
- });
1643
+ const listed = sharedCaps(caps, envelope.caps);
1644
+ if (credit) {
1645
+ const ack = credit.exact ? {
1646
+ type: "credit",
1647
+ id: envelope.id,
1648
+ bytes: 0,
1649
+ exact: true
1650
+ } : {
1651
+ type: "credit",
1652
+ id: envelope.id,
1653
+ bytes: 0
1654
+ };
1655
+ if (listed) credit.caps = listed;
1656
+ post(listed ? {
1657
+ ...ack,
1658
+ caps: [...listed]
1659
+ } : ack);
1660
+ }
1313
1661
  let accepted = false;
1314
1662
  if (onPort) try {
1315
1663
  accepted = onPort(handle.port, envelope.meta) !== false;
@@ -1337,6 +1685,7 @@ function multiplexPort(port, options) {
1337
1685
  c.exact = false;
1338
1686
  c.outstanding = 0;
1339
1687
  }
1688
+ c.caps = sharedCaps(caps, envelope.caps) ?? /* @__PURE__ */ new Set();
1340
1689
  post({
1341
1690
  type: "credit",
1342
1691
  id: envelope.id,
@@ -1348,6 +1697,10 @@ function multiplexPort(port, options) {
1348
1697
  flush(c);
1349
1698
  return;
1350
1699
  }
1700
+ if ((envelope.type === "message" || envelope.type === "frame") && entry?.credit?.peerEnded) {
1701
+ failPort(envelope.id, entry, /* @__PURE__ */ new Error("webrun-rpc: the peer sent on a port after its end"), "protocol");
1702
+ return;
1703
+ }
1351
1704
  if (envelope.type === "message") {
1352
1705
  const sizeOf = (c) => (c.exact ? byteLengthOf(event.data) : void 0) ?? estimateMessageSize(envelope.payload);
1353
1706
  if (!entry) return;
@@ -1372,16 +1725,7 @@ function multiplexPort(port, options) {
1372
1725
  const charged = partial.room == null ? Math.max(0, partial.bytes + size - c.window) : 0;
1373
1726
  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
1727
  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
- });
1728
+ failPort(envelope.id, entry, /* @__PURE__ */ new RangeError(`webrun-rpc: a framed message passed ${overrun}`), "max-reassembled-bytes");
1385
1729
  return;
1386
1730
  }
1387
1731
  partial.bytes += size;
@@ -1401,6 +1745,19 @@ function multiplexPort(port, options) {
1401
1745
  entry.handle.deliver(inner.payload, whole.byteLength);
1402
1746
  return;
1403
1747
  }
1748
+ if (envelope.type === "end") {
1749
+ if (!entry) return;
1750
+ const c = entry.credit;
1751
+ if (!c?.caps.has("end")) {
1752
+ failPort(envelope.id, entry, /* @__PURE__ */ new Error("webrun-rpc: the peer ended a port where end was not agreed"), "protocol");
1753
+ return;
1754
+ }
1755
+ touch(envelope.id);
1756
+ c.peerEnded = true;
1757
+ c.onPeerEnd?.deref()?.();
1758
+ freeIfEnded(envelope.id, c);
1759
+ return;
1760
+ }
1404
1761
  dropOutgoing(envelope.id);
1405
1762
  if (!entry) return;
1406
1763
  freePartial(entry);
@@ -1482,12 +1839,14 @@ function multiplexPort(port, options) {
1482
1839
  id,
1483
1840
  meta,
1484
1841
  window,
1485
- exact: true
1842
+ exact: true,
1843
+ caps: [...caps]
1486
1844
  } : {
1487
1845
  type: "open",
1488
1846
  id,
1489
1847
  meta,
1490
- window
1848
+ window,
1849
+ caps: [...caps]
1491
1850
  } : {
1492
1851
  type: "open",
1493
1852
  id,
@@ -1710,7 +2069,7 @@ function isEnvelope(value) {
1710
2069
  const candidate = value;
1711
2070
  if (typeof candidate.id !== "number") return false;
1712
2071
  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";
2072
+ 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
2073
  }
1715
2074
  /**
1716
2075
  * For ports whose messages are structured values — a real `MessagePort`, a
@@ -1722,6 +2081,7 @@ function isEnvelope(value) {
1722
2081
  * not send anything a byte codec could not also carry.
1723
2082
  */
1724
2083
  const structuredCodec = {
2084
+ halfClose: true,
1725
2085
  post(port, envelope, transfer) {
1726
2086
  if (transfer && transfer.length > 0) port.postMessage(envelope, transfer);
1727
2087
  else port.postMessage(envelope);