@statewalker/webrun-rpc 0.5.0 → 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;AAIrC,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,CA8BZ"}
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.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.
@@ -565,7 +632,7 @@ function runCallerSide(port, input, options) {
565
632
  const inbound = receiveChunks(port, CHANNEL_OUT, controller, clock.touch);
566
633
  const pump = sendChunks(port, CHANNEL_IN, input, options, controller.signal, clock.touch);
567
634
  pump.catch(() => {});
568
- return (async function* () {
635
+ return returningAtOnce((async function* () {
569
636
  try {
570
637
  yield* inbound.stream;
571
638
  } finally {
@@ -579,7 +646,9 @@ function runCallerSide(port, input, options) {
579
646
  await port.close?.();
580
647
  } catch {}
581
648
  }
582
- })();
649
+ })(), () => {
650
+ if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error("webrun-rpc: the caller abandoned the stream"));
651
+ });
583
652
  }
584
653
  /**
585
654
  * Listens for the peer's abort notice, and can post our own. The notice is a
@@ -981,9 +1050,22 @@ const connect = async ({ mux: factory, timeout }) => {
981
1050
  maxMessageSize: mux.maxMessageSize,
982
1051
  timeout
983
1052
  };
984
- const call = (input) => (async function* () {
985
- yield* duplexOverPort(await mux.openPort(STREAM_META), streamOptions)(input);
986
- })();
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
+ };
987
1069
  return {
988
1070
  call,
989
1071
  async close() {
@@ -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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statewalker/webrun-rpc",
3
- "version": "0.5.0",
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,
@@ -12,6 +12,7 @@ import { callPort, NO_TIMEOUT } from "./call-port.js";
12
12
  import { getPortCloseSignal } from "./close-signal.js";
13
13
  import { listenPort } from "./listen-port.js";
14
14
  import type { MessageTarget } from "./message-target.js";
15
+ import { returningAtOnce } from "./return-at-once.js";
15
16
  import { throughAbort } from "./through-abort.js";
16
17
 
17
18
  /**
@@ -177,7 +178,7 @@ function runCallerSide(
177
178
  // the consumer's contract is the inbound half.
178
179
  });
179
180
 
180
- return (async function* () {
181
+ const stream = (async function* () {
181
182
  try {
182
183
  yield* inbound.stream;
183
184
  } finally {
@@ -200,6 +201,13 @@ function runCallerSide(
200
201
  }
201
202
  }
202
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
+ });
203
211
  }
204
212
 
205
213
  /**
@@ -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
+ }