@statewalker/webrun-rpc 0.4.3 → 0.5.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.
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Makes `target` disposable with `await using`: installs `dispose` as its
3
+ * `[Symbol.asyncDispose]`, where the runtime defines the symbol. Hand-written
4
+ * iterators lose what a native generator gets from `AsyncIteratorPrototype`;
5
+ * this gives it back. A no-op where the symbol is missing. Kept off the
6
+ * declared types, which would require consumers to compile with the
7
+ * `esnext.disposable` lib.
8
+ */
9
+ export declare function asyncDisposable<T extends object>(target: T, dispose: () => Promise<unknown>): T;
10
+ //# sourceMappingURL=async-dispose.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"async-dispose.d.ts","sourceRoot":"","sources":["../src/async-dispose.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,EAAE,MAAM,EAAE,CAAC,EAAE,OAAO,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,CAa/F"}
@@ -1 +1 @@
1
- {"version":3,"file":"connect-serve.d.ts","sourceRoot":"","sources":["../src/connect-serve.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAU,KAAK,EAAE,MAAM,6BAA6B,CAAC;AAM1E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD,OAAO,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAU1D,uEAAuE;AACvE,MAAM,MAAM,MAAM,GAAG,CAAC,IAAI,EAAE,aAAa,EAAE,IAAI,CAAC,EAAE,OAAO,KAAK,OAAO,GAAG,SAAS,CAAC;AAElF;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,MAAM,CAAC,EAAE,MAAM,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;AAE7E,MAAM,WAAW,UAAU;IACzB;;;;;;;;;;OAUG;IACH,GAAG,EAAE,cAAc,CAAC;IACpB;;;yEAGqE;IACrE,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,OAAO,EAAE,OAAO,CAAC,UAAU,CA0BvC,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,KAAK,EAAE,KAAK,CAAC,UAAU,CAqDnC,CAAC;AAEF,MAAM,WAAW,eAAe;IAC9B,mFAAmF;IACnF,KAAK,EAAE,SAAS,CAAC;IACjB;;;;OAIG;IACH,IAAI,CAAC,EAAE,WAAW,GAAG,WAAW,CAAC;IACjC,oEAAoE;IACpE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,aAAa,EAAE,OAAO,EAAE,eAAe,GAAG,cAAc,CAEtF;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,cAAc,GAAG,cAAc,CAEjE"}
1
+ {"version":3,"file":"connect-serve.d.ts","sourceRoot":"","sources":["../src/connect-serve.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAU,KAAK,EAAE,MAAM,6BAA6B,CAAC;AAM1E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD,OAAO,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAW1D,uEAAuE;AACvE,MAAM,MAAM,MAAM,GAAG,CAAC,IAAI,EAAE,aAAa,EAAE,IAAI,CAAC,EAAE,OAAO,KAAK,OAAO,GAAG,SAAS,CAAC;AAElF;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,MAAM,CAAC,EAAE,MAAM,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;AAE7E,MAAM,WAAW,UAAU;IACzB;;;;;;;;;;OAUG;IACH,GAAG,EAAE,cAAc,CAAC;IACpB;;;yEAGqE;IACrE,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,OAAO,EAAE,OAAO,CAAC,UAAU,CAyCvC,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,KAAK,EAAE,KAAK,CAAC,UAAU,CAqDnC,CAAC;AAEF,MAAM,WAAW,eAAe;IAC9B,mFAAmF;IACnF,KAAK,EAAE,SAAS,CAAC;IACjB;;;;OAIG;IACH,IAAI,CAAC,EAAE,WAAW,GAAG,WAAW,CAAC;IACjC,oEAAoE;IACpE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,aAAa,EAAE,OAAO,EAAE,eAAe,GAAG,cAAc,CAEtF;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,cAAc,GAAG,cAAc,CAEjE"}
@@ -1 +1 @@
1
- {"version":3,"file":"duplex-over-port.d.ts","sourceRoot":"","sources":["../src/duplex-over-port.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,MAAM,EAOZ,MAAM,6BAA6B,CAAC;AAGrC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAGzD;;;;;;;GAOG;AACH,eAAO,MAAM,YAAY,4BAA4B,CAAC;AActD,MAAM,WAAW,qBAAqB;IACpC;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,6CAA6C;IAC7C,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,CAAC;CACpC;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,aAAa,EAAE,OAAO,GAAE,qBAA0B,GAAG,MAAM,CAE/F;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,aAAa,EACnB,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,qBAA0B,GAClC,MAAM,IAAI,CA6BZ"}
1
+ {"version":3,"file":"duplex-over-port.d.ts","sourceRoot":"","sources":["../src/duplex-over-port.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,MAAM,EAOZ,MAAM,6BAA6B,CAAC;AAIrC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAIzD;;;;;;;GAOG;AACH,eAAO,MAAM,YAAY,4BAA4B,CAAC;AActD,MAAM,WAAW,qBAAqB;IACpC;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,6CAA6C;IAC7C,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,CAAC;CACpC;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,aAAa,EAAE,OAAO,GAAE,qBAA0B,GAAG,MAAM,CAE/F;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,aAAa,EACnB,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,qBAA0B,GAClC,MAAM,IAAI,CA8BZ"}
package/dist/index.d.ts CHANGED
@@ -10,7 +10,7 @@ export * from "./io-send.js";
10
10
  export * from "./listen-bidi.js";
11
11
  export * from "./listen-port.js";
12
12
  export * from "./message-target.js";
13
- export { DEFAULT_MAX_PORTS, multiplexPort } from "./multiplex-port.js";
13
+ export { DEFAULT_MAX_PORTS, MUX_CLOSED_MESSAGE, multiplexPort } from "./multiplex-port.js";
14
14
  export type { PortCodec, PortEnvelope, PortMux, PortMuxOptions } from "./port-types.js";
15
15
  export * from "./recieve.js";
16
16
  export * from "./send.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,0BAA0B,EAAE,MAAM,mBAAmB,CAAC;AAC/D,cAAc,gBAAgB,CAAC;AAC/B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,qBAAqB,CAAC;AACpC,OAAO,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAC3E,OAAO,EACL,OAAO,EACP,KAAK,MAAM,EACX,KAAK,eAAe,EACpB,QAAQ,EACR,SAAS,EACT,KAAK,cAAc,EACnB,KAAK,UAAU,EACf,KAAK,GACN,MAAM,oBAAoB,CAAC;AAC5B,cAAc,uBAAuB,CAAC;AACtC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,kBAAkB,CAAC;AACjC,cAAc,kBAAkB,CAAC;AACjC,cAAc,qBAAqB,CAAC;AACpC,OAAO,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACvE,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACxF,cAAc,cAAc,CAAC;AAC7B,cAAc,WAAW,CAAC;AAC1B,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACxD,cAAc,wBAAwB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,0BAA0B,EAAE,MAAM,mBAAmB,CAAC;AAC/D,cAAc,gBAAgB,CAAC;AAC/B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,qBAAqB,CAAC;AACpC,OAAO,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAC3E,OAAO,EACL,OAAO,EACP,KAAK,MAAM,EACX,KAAK,eAAe,EACpB,QAAQ,EACR,SAAS,EACT,KAAK,cAAc,EACnB,KAAK,UAAU,EACf,KAAK,GACN,MAAM,oBAAoB,CAAC;AAC5B,cAAc,uBAAuB,CAAC;AACtC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,kBAAkB,CAAC;AACjC,cAAc,kBAAkB,CAAC;AACjC,cAAc,qBAAqB,CAAC;AACpC,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAC3F,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACxF,cAAc,cAAc,CAAC;AAC7B,cAAc,WAAW,CAAC;AAC1B,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACxD,cAAc,wBAAwB,CAAC"}
package/dist/index.js CHANGED
@@ -468,6 +468,73 @@ async function* callBidi(port, input, { options = {}, ...params } = {}) {
468
468
  if (outerError !== void 0) throw outerError;
469
469
  }
470
470
  //#endregion
471
+ //#region src/async-dispose.ts
472
+ /**
473
+ * Makes `target` disposable with `await using`: installs `dispose` as its
474
+ * `[Symbol.asyncDispose]`, where the runtime defines the symbol. Hand-written
475
+ * iterators lose what a native generator gets from `AsyncIteratorPrototype`;
476
+ * this gives it back. A no-op where the symbol is missing. Kept off the
477
+ * declared types, which would require consumers to compile with the
478
+ * `esnext.disposable` lib.
479
+ */
480
+ function asyncDisposable(target, dispose) {
481
+ const asyncDispose = Symbol.asyncDispose;
482
+ if (asyncDispose && !(asyncDispose in target)) Object.defineProperty(target, asyncDispose, {
483
+ value: async () => {
484
+ await dispose();
485
+ },
486
+ configurable: true,
487
+ writable: true
488
+ });
489
+ return target;
490
+ }
491
+ //#endregion
492
+ //#region src/return-at-once.ts
493
+ /**
494
+ * `gen`, except that `return()` and `throw()` take effect at once.
495
+ *
496
+ * On an async generator both are queued behind a `next()` that is still
497
+ * pending: a consumer that gives up on a stream while it waits for the peer's
498
+ * next chunk — an aborted fetch whose response head has not come, say — cannot
499
+ * end it until that chunk arrives, which for a peer that never answers is
500
+ * never. The stream's `finally` (which closes its port and tells the peer)
501
+ * then never runs, and on a multiplexer the port is kept for good.
502
+ *
503
+ * So `abandon` runs first, synchronously: it must make the pending `next()`
504
+ * settle (abort the stream), which then resolves done, after which the
505
+ * generator's own `return()` / `throw()` proceeds as usual. On a generator
506
+ * suspended at a `yield` it is the same ending, a step earlier. `abandon` runs once, whichever comes first.
507
+ */
508
+ function returningAtOnce(gen, abandon) {
509
+ let abandoned = false;
510
+ const giveUp = (reason) => {
511
+ if (abandoned) return;
512
+ abandoned = true;
513
+ abandon(reason);
514
+ };
515
+ const wrapped = {
516
+ next: (...args) => gen.next(...args).catch((error) => {
517
+ if (abandoned) return {
518
+ done: true,
519
+ value: void 0
520
+ };
521
+ throw error;
522
+ }),
523
+ return(value) {
524
+ giveUp();
525
+ return gen.return(value);
526
+ },
527
+ throw(error) {
528
+ giveUp(error);
529
+ return gen.throw(error);
530
+ },
531
+ [Symbol.asyncIterator]() {
532
+ return wrapped;
533
+ }
534
+ };
535
+ return asyncDisposable(wrapped, () => wrapped.return(void 0));
536
+ }
537
+ //#endregion
471
538
  //#region src/duplex-over-port.ts
472
539
  /**
473
540
  * The `type` of the out-of-band notice a side posts when it abandons a stream.
@@ -505,6 +572,7 @@ function duplexOverPort(port, options = {}) {
505
572
  function serveDuplexOverPort(port, handler, options = {}) {
506
573
  const controller = new AbortController();
507
574
  const notice = installAbortNotice(port, controller);
575
+ abortOnPortClose(port, controller);
508
576
  const clock = installStreamTimeout(controller, options.timeout);
509
577
  const inbound = receiveChunks(port, CHANNEL_IN, controller, clock.touch);
510
578
  let output;
@@ -559,11 +627,12 @@ function teardownOnce(controller, notice, clock, inbound, output) {
559
627
  function runCallerSide(port, input, options) {
560
628
  const controller = new AbortController();
561
629
  const notice = installAbortNotice(port, controller);
630
+ abortOnPortClose(port, controller);
562
631
  const clock = installStreamTimeout(controller, options.timeout);
563
632
  const inbound = receiveChunks(port, CHANNEL_OUT, controller, clock.touch);
564
633
  const pump = sendChunks(port, CHANNEL_IN, input, options, controller.signal, clock.touch);
565
634
  pump.catch(() => {});
566
- return (async function* () {
635
+ return returningAtOnce((async function* () {
567
636
  try {
568
637
  yield* inbound.stream;
569
638
  } finally {
@@ -577,7 +646,9 @@ function runCallerSide(port, input, options) {
577
646
  await port.close?.();
578
647
  } catch {}
579
648
  }
580
- })();
649
+ })(), () => {
650
+ if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error("webrun-rpc: the caller abandoned the stream"));
651
+ });
581
652
  }
582
653
  /**
583
654
  * Listens for the peer's abort notice, and can post our own. The notice is a
@@ -607,6 +678,27 @@ function installAbortNotice(port, controller) {
607
678
  };
608
679
  }
609
680
  /**
681
+ * Aborts the stream when its port closes underneath it — the multiplexer was
682
+ * closed, or the peer closed the port — for ports that report it (see
683
+ * `getPortCloseSignal`). Without this a stream waiting for the peer's next
684
+ * chunk would wait forever on a port that can no longer deliver one: the
685
+ * outbound half's `callPort` already fails on the close signal, but nothing
686
+ * failed the inbound half.
687
+ */
688
+ function abortOnPortClose(port, controller) {
689
+ const closed = getPortCloseSignal(port);
690
+ if (!closed) return;
691
+ const onClose = () => {
692
+ if (!controller.signal.aborted) controller.abort(closed.reason);
693
+ };
694
+ if (closed.aborted) {
695
+ onClose();
696
+ return;
697
+ }
698
+ closed.addEventListener("abort", onClose, { once: true });
699
+ controller.signal.addEventListener("abort", () => closed.removeEventListener("abort", onClose), { once: true });
700
+ }
701
+ /**
610
702
  * The per-stream inactivity timeout (spec D8). Reset by any chunk in either
611
703
  * direction; elapsing aborts the stream. Unset, zero or non-finite installs no
612
704
  * timer at all, which is the default: a slow consumer is throttled, not failed.
@@ -768,26 +860,34 @@ async function sendChunks(port, channelName, output, { maxMessageSize, log }, si
768
860
  function newVirtualPort(send, requestClose) {
769
861
  const listeners = /* @__PURE__ */ new Set();
770
862
  let closed = false;
771
- return {
772
- port: {
773
- addEventListener(_type, listener) {
774
- listeners.add(listener);
775
- },
776
- removeEventListener(_type, listener) {
777
- listeners.delete(listener);
778
- },
779
- postMessage(message, transfer) {
780
- if (closed) return;
781
- send(message, transfer);
782
- },
783
- close() {
784
- if (closed) return;
785
- closed = true;
786
- const notify = requestClose;
787
- listeners.clear();
788
- notify();
789
- }
863
+ const closing = new AbortController();
864
+ const abort = (reason) => {
865
+ if (closing.signal.aborted) return;
866
+ closing.abort(reason ?? /* @__PURE__ */ new Error("webrun-rpc: the port was closed"));
867
+ };
868
+ const port = {
869
+ addEventListener(_type, listener) {
870
+ listeners.add(listener);
790
871
  },
872
+ removeEventListener(_type, listener) {
873
+ listeners.delete(listener);
874
+ },
875
+ postMessage(message, transfer) {
876
+ if (closed) return;
877
+ send(message, transfer);
878
+ },
879
+ close() {
880
+ if (closed) return;
881
+ closed = true;
882
+ const notify = requestClose;
883
+ listeners.clear();
884
+ notify();
885
+ abort();
886
+ }
887
+ };
888
+ setPortCloseSignal(port, closing.signal);
889
+ return {
890
+ port,
791
891
  deliver(payload) {
792
892
  if (closed) return;
793
893
  const event = new MessageEvent("message", { data: payload });
@@ -795,9 +895,10 @@ function newVirtualPort(send, requestClose) {
795
895
  listener(event);
796
896
  } catch {}
797
897
  },
798
- markClosed() {
898
+ markClosed(reason) {
799
899
  closed = true;
800
900
  listeners.clear();
901
+ abort(reason);
801
902
  },
802
903
  isClosed() {
803
904
  return closed;
@@ -806,6 +907,11 @@ function newVirtualPort(send, requestClose) {
806
907
  }
807
908
  //#endregion
808
909
  //#region src/multiplex-port.ts
910
+ /**
911
+ * The message of the error that fails the calls still in flight on a
912
+ * multiplexer when it is closed (`PortMux.close()`).
913
+ */
914
+ const MUX_CLOSED_MESSAGE = "webrun-rpc: the multiplexer was closed";
809
915
  /** Ceiling on concurrently open virtual ports. Bounds the id table only. */
810
916
  const DEFAULT_MAX_PORTS = 1024;
811
917
  /**
@@ -864,7 +970,7 @@ function multiplexPort(port, options) {
864
970
  }
865
971
  if (!accepted) {
866
972
  open.delete(envelope.id);
867
- handle.markClosed();
973
+ handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the port was rejected"));
868
974
  post({
869
975
  type: "close",
870
976
  id: envelope.id,
@@ -881,7 +987,7 @@ function multiplexPort(port, options) {
881
987
  }
882
988
  if (!handle) return;
883
989
  open.delete(envelope.id);
884
- handle.markClosed();
990
+ handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the peer closed the port"));
885
991
  };
886
992
  const listener = (event) => {
887
993
  const envelope = codec.read(event);
@@ -913,7 +1019,7 @@ function multiplexPort(port, options) {
913
1019
  id
914
1020
  });
915
1021
  open.delete(id);
916
- handle.markClosed();
1022
+ handle.markClosed(/* @__PURE__ */ new Error(MUX_CLOSED_MESSAGE));
917
1023
  }
918
1024
  muxClosed = true;
919
1025
  port.removeEventListener("message", listener);
@@ -944,9 +1050,22 @@ const connect = async ({ mux: factory, timeout }) => {
944
1050
  maxMessageSize: mux.maxMessageSize,
945
1051
  timeout
946
1052
  };
947
- const call = (input) => (async function* () {
948
- yield* duplexOverPort(await mux.openPort(STREAM_META), streamOptions)(input);
949
- })();
1053
+ const call = (input) => {
1054
+ let stream;
1055
+ let abandoned = false;
1056
+ return returningAtOnce((async function* () {
1057
+ const port = await mux.openPort(STREAM_META);
1058
+ if (abandoned) {
1059
+ await port.close?.();
1060
+ return;
1061
+ }
1062
+ stream = duplexOverPort(port, streamOptions)(input);
1063
+ yield* stream;
1064
+ })(), () => {
1065
+ abandoned = true;
1066
+ stream?.return(void 0).catch(() => {});
1067
+ });
1068
+ };
950
1069
  return {
951
1070
  call,
952
1071
  async close() {
@@ -1177,4 +1296,4 @@ function transferPortMux(target, options = {}) {
1177
1296
  };
1178
1297
  }
1179
1298
  //#endregion
1180
- export { CANCEL_CHANNEL_TYPE, DEFAULT_MAX_PORTS, NO_TIMEOUT, PORT_TRANSFER, STREAM_ABORT, byteChannelFromMessagePort, callBidi, callPort, connect, duplexOverPort, getPortCloseSignal, ioHandle, ioSend, listenBidi, listenCancelChannel, listenPort, multiplexPort, overPipe, overPorts, postCancelChannel, recieve, send, serve, serveDuplexOverPort, setPortCloseSignal, structuredCodec, transferPortMux };
1299
+ 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, postCancelChannel, recieve, send, serve, serveDuplexOverPort, setPortCloseSignal, structuredCodec, transferPortMux };
@@ -1,5 +1,10 @@
1
1
  import type { MessageTarget } from "./message-target.js";
2
2
  import type { PortMux, PortMuxOptions } from "./port-types.js";
3
+ /**
4
+ * The message of the error that fails the calls still in flight on a
5
+ * multiplexer when it is closed (`PortMux.close()`).
6
+ */
7
+ export declare const MUX_CLOSED_MESSAGE = "webrun-rpc: the multiplexer was closed";
3
8
  /** Ceiling on concurrently open virtual ports. Bounds the id table only. */
4
9
  export declare const DEFAULT_MAX_PORTS = 1024;
5
10
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"multiplex-port.d.ts","sourceRoot":"","sources":["../src/multiplex-port.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,KAAK,EAAgB,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAG7E,4EAA4E;AAC5E,eAAO,MAAM,iBAAiB,OAAO,CAAC;AAEtC;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,aAAa,EAAE,OAAO,EAAE,cAAc,GAAG,OAAO,CAwHnF"}
1
+ {"version":3,"file":"multiplex-port.d.ts","sourceRoot":"","sources":["../src/multiplex-port.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,KAAK,EAAgB,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAG7E;;;GAGG;AACH,eAAO,MAAM,kBAAkB,2CAA2C,CAAC;AAE3E,4EAA4E;AAC5E,eAAO,MAAM,iBAAiB,OAAO,CAAC;AAEtC;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,aAAa,EAAE,OAAO,EAAE,cAAc,GAAG,OAAO,CAwHnF"}
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `gen`, except that `return()` and `throw()` take effect at once.
3
+ *
4
+ * On an async generator both are queued behind a `next()` that is still
5
+ * pending: a consumer that gives up on a stream while it waits for the peer's
6
+ * next chunk — an aborted fetch whose response head has not come, say — cannot
7
+ * end it until that chunk arrives, which for a peer that never answers is
8
+ * never. The stream's `finally` (which closes its port and tells the peer)
9
+ * then never runs, and on a multiplexer the port is kept for good.
10
+ *
11
+ * So `abandon` runs first, synchronously: it must make the pending `next()`
12
+ * settle (abort the stream), which then resolves done, after which the
13
+ * generator's own `return()` / `throw()` proceeds as usual. On a generator
14
+ * suspended at a `yield` it is the same ending, a step earlier. `abandon` runs once, whichever comes first.
15
+ */
16
+ export declare function returningAtOnce<T>(gen: AsyncGenerator<T>, abandon: (reason?: unknown) => void): AsyncGenerator<T>;
17
+ //# sourceMappingURL=return-at-once.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"return-at-once.d.ts","sourceRoot":"","sources":["../src/return-at-once.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAC/B,GAAG,EAAE,cAAc,CAAC,CAAC,CAAC,EACtB,OAAO,EAAE,CAAC,MAAM,CAAC,EAAE,OAAO,KAAK,IAAI,GAClC,cAAc,CAAC,CAAC,CAAC,CA8BnB"}
@@ -4,8 +4,12 @@ export interface VirtualPortHandle {
4
4
  port: MessageTarget;
5
5
  /** Multiplexer-only: hand an inbound payload to the consumer's listeners. */
6
6
  deliver(payload: unknown): void;
7
- /** Multiplexer-only: the port is finished; drop listeners and go inert. */
8
- markClosed(): void;
7
+ /**
8
+ * Multiplexer-only: the port is finished; drop listeners and go inert.
9
+ * `reason` becomes the reason of the port's close signal (see
10
+ * `getPortCloseSignal`), which is what fails the calls still waiting on it.
11
+ */
12
+ markClosed(reason?: unknown): void;
9
13
  isClosed(): boolean;
10
14
  }
11
15
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"virtual-port.d.ts","sourceRoot":"","sources":["../src/virtual-port.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAmB,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAE1E,MAAM,WAAW,iBAAiB;IAChC,4EAA4E;IAC5E,IAAI,EAAE,aAAa,CAAC;IACpB,6EAA6E;IAC7E,OAAO,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;IAChC,2EAA2E;IAC3E,UAAU,IAAI,IAAI,CAAC;IACnB,QAAQ,IAAI,OAAO,CAAC;CACrB;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,CAAC,EAAE,YAAY,EAAE,KAAK,IAAI,EAC3D,YAAY,EAAE,CAAC,MAAM,CAAC,EAAE,OAAO,KAAK,IAAI,GACvC,iBAAiB,CAgDnB"}
1
+ {"version":3,"file":"virtual-port.d.ts","sourceRoot":"","sources":["../src/virtual-port.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAmB,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAE1E,MAAM,WAAW,iBAAiB;IAChC,4EAA4E;IAC5E,IAAI,EAAE,aAAa,CAAC;IACpB,6EAA6E;IAC7E,OAAO,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;IAChC;;;;OAIG;IACH,UAAU,CAAC,MAAM,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC;IACnC,QAAQ,IAAI,OAAO,CAAC;CACrB;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,CAAC,EAAE,YAAY,EAAE,KAAK,IAAI,EAC3D,YAAY,EAAE,CAAC,MAAM,CAAC,EAAE,OAAO,KAAK,IAAI,GACvC,iBAAiB,CA6DnB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statewalker/webrun-rpc",
3
- "version": "0.4.3",
3
+ "version": "0.5.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Ports and RPC over them: a port multiplexer, and typed request/response and streaming primitives over any MessageTarget.",
@@ -30,7 +30,7 @@
30
30
  "src"
31
31
  ],
32
32
  "dependencies": {
33
- "@statewalker/webrun-streams": "^0.2.2"
33
+ "@statewalker/webrun-streams": "^0.2.3"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@biomejs/biome": "^2.5.15",
@@ -39,7 +39,7 @@
39
39
  "rolldown": "^1.2.12",
40
40
  "typescript": "^7.0.2",
41
41
  "vitest": "^5.0.3",
42
- "@statewalker/webrun-streams-conformance": "^0.3.2"
42
+ "@statewalker/webrun-streams-conformance": "^0.3.3"
43
43
  },
44
44
  "sideEffects": false,
45
45
  "publishConfig": {
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Makes `target` disposable with `await using`: installs `dispose` as its
3
+ * `[Symbol.asyncDispose]`, where the runtime defines the symbol. Hand-written
4
+ * iterators lose what a native generator gets from `AsyncIteratorPrototype`;
5
+ * this gives it back. A no-op where the symbol is missing. Kept off the
6
+ * declared types, which would require consumers to compile with the
7
+ * `esnext.disposable` lib.
8
+ */
9
+ export function asyncDisposable<T extends object>(target: T, dispose: () => Promise<unknown>): T {
10
+ // Read here, not at module level: a top-level read is code a bundler keeps.
11
+ const asyncDispose = (Symbol as { asyncDispose?: symbol }).asyncDispose;
12
+ if (asyncDispose && !(asyncDispose in target)) {
13
+ Object.defineProperty(target, asyncDispose, {
14
+ value: async () => {
15
+ await dispose();
16
+ },
17
+ configurable: true,
18
+ writable: true,
19
+ });
20
+ }
21
+ return target;
22
+ }
@@ -22,7 +22,7 @@
22
22
  */
23
23
  import type { MessageTarget } from "./message-target.js";
24
24
 
25
- const closeSignals = new WeakMap<MessageTarget, AbortSignal>();
25
+ const closeSignals = /* @__PURE__ */ new WeakMap<MessageTarget, AbortSignal>();
26
26
 
27
27
  /**
28
28
  * Internal: register a port's close signal. Called once by `bindBytesToPort`.
@@ -7,6 +7,7 @@ import {
7
7
  import type { MessageTarget } from "./message-target.js";
8
8
  import { multiplexPort } from "./multiplex-port.js";
9
9
  import type { PortCodec, PortMux } from "./port-types.js";
10
+ import { returningAtOnce } from "./return-at-once.js";
10
11
 
11
12
  /**
12
13
  * Announced with every port this pair opens. Layer 1 never reads `meta` — it is
@@ -74,19 +75,34 @@ export const connect: Connect<PortParams> = async ({ mux: factory, timeout }) =>
74
75
  const mux = await factory();
75
76
  const streamOptions: DuplexOverPortOptions = { maxMessageSize: mux.maxMessageSize, timeout };
76
77
 
77
- const call: Duplex = (input) =>
78
+ const call: Duplex = (input) => {
79
+ let stream: AsyncGenerator<Uint8Array> | undefined;
80
+ let abandoned = false;
78
81
  // The port is opened on the consumer's first pull, not when `call` is
79
82
  // invoked. A caller that builds a stream and then drops it without
80
83
  // iterating therefore costs the peer nothing — under eager opening it
81
84
  // would burn a port (and over libp2p, a whole stream) for a call that
82
85
  // never arrives.
83
- (async function* () {
86
+ const outer = (async function* () {
84
87
  const port = await mux.openPort(STREAM_META);
88
+ if (abandoned) {
89
+ // Given up while the port was being opened.
90
+ await port.close?.();
91
+ return;
92
+ }
93
+ stream = duplexOverPort(port, streamOptions)(input);
85
94
  // `yield*` and not a hand-rolled pump: delegation already forwards
86
95
  // `return()` and `throw()` into `duplexOverPort`'s generator, which is
87
96
  // exactly the cancellation path `Duplex` specifies.
88
- yield* duplexOverPort(port, streamOptions)(input);
97
+ yield* stream;
89
98
  })();
99
+ // Delegation forwards them only once the pending `next()` settles, though:
100
+ // giving up while the peer has not answered must reach the stream now.
101
+ return returningAtOnce(outer, () => {
102
+ abandoned = true;
103
+ void stream?.return(undefined).catch(() => {});
104
+ });
105
+ };
90
106
 
91
107
  return {
92
108
  call,
@@ -9,8 +9,10 @@ import {
9
9
  toChunks,
10
10
  } from "@statewalker/webrun-streams";
11
11
  import { callPort, NO_TIMEOUT } from "./call-port.js";
12
+ import { getPortCloseSignal } from "./close-signal.js";
12
13
  import { listenPort } from "./listen-port.js";
13
14
  import type { MessageTarget } from "./message-target.js";
15
+ import { returningAtOnce } from "./return-at-once.js";
14
16
  import { throughAbort } from "./through-abort.js";
15
17
 
16
18
  /**
@@ -82,6 +84,7 @@ export function serveDuplexOverPort(
82
84
  ): () => void {
83
85
  const controller = new AbortController();
84
86
  const notice = installAbortNotice(port, controller);
87
+ abortOnPortClose(port, controller);
85
88
  const clock = installStreamTimeout(controller, options.timeout);
86
89
  const inbound = receiveChunks(port, CHANNEL_IN, controller, clock.touch);
87
90
  let output: AsyncGenerator<Uint8Array>;
@@ -166,6 +169,7 @@ function runCallerSide(
166
169
  ): AsyncGenerator<Uint8Array> {
167
170
  const controller = new AbortController();
168
171
  const notice = installAbortNotice(port, controller);
172
+ abortOnPortClose(port, controller);
169
173
  const clock = installStreamTimeout(controller, options.timeout);
170
174
  const inbound = receiveChunks(port, CHANNEL_OUT, controller, clock.touch);
171
175
  const pump = sendChunks(port, CHANNEL_IN, input, options, controller.signal, clock.touch);
@@ -174,7 +178,7 @@ function runCallerSide(
174
178
  // the consumer's contract is the inbound half.
175
179
  });
176
180
 
177
- return (async function* () {
181
+ const stream = (async function* () {
178
182
  try {
179
183
  yield* inbound.stream;
180
184
  } finally {
@@ -197,6 +201,13 @@ function runCallerSide(
197
201
  }
198
202
  }
199
203
  })();
204
+ // A caller that gives up while it waits for the peer's next chunk ends the
205
+ // stream now: the abort settles that wait, and the `finally` above runs.
206
+ return returningAtOnce(stream, () => {
207
+ if (!controller.signal.aborted) {
208
+ controller.abort(new Error("webrun-rpc: the caller abandoned the stream"));
209
+ }
210
+ });
200
211
  }
201
212
 
202
213
  /**
@@ -234,6 +245,30 @@ function installAbortNotice(
234
245
  };
235
246
  }
236
247
 
248
+ /**
249
+ * Aborts the stream when its port closes underneath it — the multiplexer was
250
+ * closed, or the peer closed the port — for ports that report it (see
251
+ * `getPortCloseSignal`). Without this a stream waiting for the peer's next
252
+ * chunk would wait forever on a port that can no longer deliver one: the
253
+ * outbound half's `callPort` already fails on the close signal, but nothing
254
+ * failed the inbound half.
255
+ */
256
+ function abortOnPortClose(port: MessageTarget, controller: AbortController): void {
257
+ const closed = getPortCloseSignal(port);
258
+ if (!closed) return;
259
+ const onClose = () => {
260
+ if (!controller.signal.aborted) controller.abort(closed.reason);
261
+ };
262
+ if (closed.aborted) {
263
+ onClose();
264
+ return;
265
+ }
266
+ closed.addEventListener("abort", onClose, { once: true });
267
+ controller.signal.addEventListener("abort", () => closed.removeEventListener("abort", onClose), {
268
+ once: true,
269
+ });
270
+ }
271
+
237
272
  /**
238
273
  * The per-stream inactivity timeout (spec D8). Reset by any chunk in either
239
274
  * direction; elapsing aborts the stream. Unset, zero or non-finite installs no
package/src/index.ts CHANGED
@@ -21,7 +21,7 @@ export * from "./io-send.js";
21
21
  export * from "./listen-bidi.js";
22
22
  export * from "./listen-port.js";
23
23
  export * from "./message-target.js";
24
- export { DEFAULT_MAX_PORTS, multiplexPort } from "./multiplex-port.js";
24
+ export { DEFAULT_MAX_PORTS, MUX_CLOSED_MESSAGE, multiplexPort } from "./multiplex-port.js";
25
25
  export type { PortCodec, PortEnvelope, PortMux, PortMuxOptions } from "./port-types.js";
26
26
  export * from "./recieve.js";
27
27
  export * from "./send.js";
@@ -2,6 +2,12 @@ import type { MessageTarget } from "./message-target.js";
2
2
  import type { PortEnvelope, PortMux, PortMuxOptions } from "./port-types.js";
3
3
  import { newVirtualPort, type VirtualPortHandle } from "./virtual-port.js";
4
4
 
5
+ /**
6
+ * The message of the error that fails the calls still in flight on a
7
+ * multiplexer when it is closed (`PortMux.close()`).
8
+ */
9
+ export const MUX_CLOSED_MESSAGE = "webrun-rpc: the multiplexer was closed";
10
+
5
11
  /** Ceiling on concurrently open virtual ports. Bounds the id table only. */
6
12
  export const DEFAULT_MAX_PORTS = 1024;
7
13
 
@@ -70,7 +76,7 @@ export function multiplexPort(port: MessageTarget, options: PortMuxOptions): Por
70
76
  }
71
77
  if (!accepted) {
72
78
  open.delete(envelope.id);
73
- handle.markClosed();
79
+ handle.markClosed(new Error("webrun-rpc: the port was rejected"));
74
80
  post({ type: "close", id: envelope.id, reason: "rejected" });
75
81
  }
76
82
  return;
@@ -89,7 +95,7 @@ export function multiplexPort(port: MessageTarget, options: PortMuxOptions): Por
89
95
 
90
96
  if (!handle) return;
91
97
  open.delete(envelope.id);
92
- handle.markClosed();
98
+ handle.markClosed(new Error("webrun-rpc: the peer closed the port"));
93
99
  };
94
100
 
95
101
  const listener = (event: MessageEvent): void => {
@@ -124,7 +130,7 @@ export function multiplexPort(port: MessageTarget, options: PortMuxOptions): Por
124
130
  for (const [id, handle] of [...open]) {
125
131
  post({ type: "close", id });
126
132
  open.delete(id);
127
- handle.markClosed();
133
+ handle.markClosed(new Error(MUX_CLOSED_MESSAGE));
128
134
  }
129
135
  muxClosed = true;
130
136
  port.removeEventListener("message", listener);
@@ -0,0 +1,51 @@
1
+ import { asyncDisposable } from "./async-dispose.js";
2
+
3
+ /**
4
+ * `gen`, except that `return()` and `throw()` take effect at once.
5
+ *
6
+ * On an async generator both are queued behind a `next()` that is still
7
+ * pending: a consumer that gives up on a stream while it waits for the peer's
8
+ * next chunk — an aborted fetch whose response head has not come, say — cannot
9
+ * end it until that chunk arrives, which for a peer that never answers is
10
+ * never. The stream's `finally` (which closes its port and tells the peer)
11
+ * then never runs, and on a multiplexer the port is kept for good.
12
+ *
13
+ * So `abandon` runs first, synchronously: it must make the pending `next()`
14
+ * settle (abort the stream), which then resolves done, after which the
15
+ * generator's own `return()` / `throw()` proceeds as usual. On a generator
16
+ * suspended at a `yield` it is the same ending, a step earlier. `abandon` runs once, whichever comes first.
17
+ */
18
+ export function returningAtOnce<T>(
19
+ gen: AsyncGenerator<T>,
20
+ abandon: (reason?: unknown) => void,
21
+ ): AsyncGenerator<T> {
22
+ let abandoned = false;
23
+ const giveUp = (reason?: unknown) => {
24
+ if (abandoned) return;
25
+ abandoned = true;
26
+ abandon(reason);
27
+ };
28
+ const wrapped: AsyncGenerator<T> = {
29
+ // A `next()` that was pending when the consumer gave up ends as the
30
+ // stream does — done — rather than rejecting with the abort it caused: a
31
+ // consumer that raced it against a timer may hold no handler for it.
32
+ next: (...args: [] | [unknown]) =>
33
+ gen.next(...args).catch((error: unknown) => {
34
+ if (abandoned) return { done: true, value: undefined } as IteratorResult<T>;
35
+ throw error;
36
+ }),
37
+ return(value) {
38
+ giveUp();
39
+ return gen.return(value);
40
+ },
41
+ throw(error) {
42
+ giveUp(error);
43
+ return gen.throw(error);
44
+ },
45
+ [Symbol.asyncIterator]() {
46
+ return wrapped;
47
+ },
48
+ };
49
+ // `await using` ends the stream as leaving a `for await` early does.
50
+ return asyncDisposable(wrapped, () => wrapped.return(undefined));
51
+ }
@@ -1,3 +1,4 @@
1
+ import { setPortCloseSignal } from "./close-signal.js";
1
2
  import type { MessageListener, MessageTarget } from "./message-target.js";
2
3
 
3
4
  export interface VirtualPortHandle {
@@ -5,8 +6,12 @@ export interface VirtualPortHandle {
5
6
  port: MessageTarget;
6
7
  /** Multiplexer-only: hand an inbound payload to the consumer's listeners. */
7
8
  deliver(payload: unknown): void;
8
- /** Multiplexer-only: the port is finished; drop listeners and go inert. */
9
- markClosed(): void;
9
+ /**
10
+ * Multiplexer-only: the port is finished; drop listeners and go inert.
11
+ * `reason` becomes the reason of the port's close signal (see
12
+ * `getPortCloseSignal`), which is what fails the calls still waiting on it.
13
+ */
14
+ markClosed(reason?: unknown): void;
10
15
  isClosed(): boolean;
11
16
  }
12
17
 
@@ -23,6 +28,16 @@ export function newVirtualPort(
23
28
  ): VirtualPortHandle {
24
29
  const listeners = new Set<MessageListener>();
25
30
  let closed = false;
31
+ // A virtual port knows when it is finished, unlike a raw `MessagePort`. Its
32
+ // close signal is what lets the calls still waiting on it — `callPort`, and
33
+ // the streams of `duplexOverPort` — fail at once instead of hanging on a
34
+ // port that has gone inert. Without it, closing a multiplexer left every call
35
+ // in flight on it pending forever.
36
+ const closing = new AbortController();
37
+ const abort = (reason?: unknown) => {
38
+ if (closing.signal.aborted) return;
39
+ closing.abort(reason ?? new Error("webrun-rpc: the port was closed"));
40
+ };
26
41
 
27
42
  const port: MessageTarget = {
28
43
  addEventListener(_type, listener) {
@@ -41,8 +56,10 @@ export function newVirtualPort(
41
56
  const notify = requestClose;
42
57
  listeners.clear();
43
58
  notify();
59
+ abort();
44
60
  },
45
61
  };
62
+ setPortCloseSignal(port, closing.signal);
46
63
 
47
64
  return {
48
65
  port,
@@ -60,9 +77,10 @@ export function newVirtualPort(
60
77
  }
61
78
  }
62
79
  },
63
- markClosed() {
80
+ markClosed(reason) {
64
81
  closed = true;
65
82
  listeners.clear();
83
+ abort(reason);
66
84
  },
67
85
  isClosed() {
68
86
  return closed;