@statewalker/webrun-rpc 0.4.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.
Files changed (66) hide show
  1. package/README.md +549 -0
  2. package/dist/byte-channel.d.ts +15 -0
  3. package/dist/byte-channel.d.ts.map +1 -0
  4. package/dist/call-bidi.d.ts +25 -0
  5. package/dist/call-bidi.d.ts.map +1 -0
  6. package/dist/call-port.d.ts +37 -0
  7. package/dist/call-port.d.ts.map +1 -0
  8. package/dist/cancel-channel.d.ts +15 -0
  9. package/dist/cancel-channel.d.ts.map +1 -0
  10. package/dist/close-signal.d.ts +36 -0
  11. package/dist/close-signal.d.ts.map +1 -0
  12. package/dist/connect-serve.d.ts +104 -0
  13. package/dist/connect-serve.d.ts.map +1 -0
  14. package/dist/duplex-over-port.d.ts +49 -0
  15. package/dist/duplex-over-port.d.ts.map +1 -0
  16. package/dist/index.d.ts +19 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +1180 -0
  19. package/dist/io-handle.d.ts +17 -0
  20. package/dist/io-handle.d.ts.map +1 -0
  21. package/dist/io-send.d.ts +26 -0
  22. package/dist/io-send.d.ts.map +1 -0
  23. package/dist/listen-bidi.d.ts +11 -0
  24. package/dist/listen-bidi.d.ts.map +1 -0
  25. package/dist/listen-port.d.ts +16 -0
  26. package/dist/listen-port.d.ts.map +1 -0
  27. package/dist/message-target.d.ts +16 -0
  28. package/dist/message-target.d.ts.map +1 -0
  29. package/dist/multiplex-port.d.ts +12 -0
  30. package/dist/multiplex-port.d.ts.map +1 -0
  31. package/dist/port-types.d.ts +86 -0
  32. package/dist/port-types.d.ts.map +1 -0
  33. package/dist/recieve.d.ts +35 -0
  34. package/dist/recieve.d.ts.map +1 -0
  35. package/dist/send.d.ts +23 -0
  36. package/dist/send.d.ts.map +1 -0
  37. package/dist/structured-codec.d.ts +12 -0
  38. package/dist/structured-codec.d.ts.map +1 -0
  39. package/dist/through-abort.d.ts +8 -0
  40. package/dist/through-abort.d.ts.map +1 -0
  41. package/dist/transfer-port-mux.d.ts +39 -0
  42. package/dist/transfer-port-mux.d.ts.map +1 -0
  43. package/dist/virtual-port.d.ts +19 -0
  44. package/dist/virtual-port.d.ts.map +1 -0
  45. package/package.json +51 -0
  46. package/src/byte-channel.ts +109 -0
  47. package/src/call-bidi.ts +60 -0
  48. package/src/call-port.ts +119 -0
  49. package/src/cancel-channel.ts +42 -0
  50. package/src/close-signal.ts +43 -0
  51. package/src/connect-serve.ts +208 -0
  52. package/src/duplex-over-port.ts +471 -0
  53. package/src/index.ts +29 -0
  54. package/src/io-handle.ts +40 -0
  55. package/src/io-send.ts +70 -0
  56. package/src/listen-bidi.ts +31 -0
  57. package/src/listen-port.ts +47 -0
  58. package/src/message-target.ts +18 -0
  59. package/src/multiplex-port.ts +134 -0
  60. package/src/port-types.ts +80 -0
  61. package/src/recieve.ts +89 -0
  62. package/src/send.ts +60 -0
  63. package/src/structured-codec.ts +30 -0
  64. package/src/through-abort.ts +32 -0
  65. package/src/transfer-port-mux.ts +106 -0
  66. package/src/virtual-port.ts +71 -0
@@ -0,0 +1,119 @@
1
+ import { deserializeError, type SerializedError } from "@statewalker/webrun-streams";
2
+ import { getPortCloseSignal } from "./close-signal.js";
3
+ import type { MessageTarget } from "./message-target.js";
4
+
5
+ /**
6
+ * Pass as `timeout` to install no deadline at all. Used by the stream tier,
7
+ * where the deadline belongs to the stream rather than to one chunk (spec D8):
8
+ * a slow consumer is throttled, never failed.
9
+ */
10
+ export const NO_TIMEOUT = Number.POSITIVE_INFINITY;
11
+
12
+ export interface CallPortOptions {
13
+ /**
14
+ * Timeout in ms after which the call rejects (default 1000). A value that is
15
+ * not a finite number greater than zero — {@link NO_TIMEOUT}, or 0 — installs
16
+ * no deadline, and the call then settles only on a reply, an abort, or the
17
+ * port's close signal.
18
+ */
19
+ timeout?: number;
20
+ /** Channel name filter — peers with a different `channelName` ignore the message. */
21
+ channelName?: string;
22
+ /** Logging function; defaults to a no-op. */
23
+ log?: (...args: unknown[]) => void;
24
+ /** Override the call ID generator (default: `call-<timestamp>-<random>`). */
25
+ newCallId?: () => string;
26
+ /**
27
+ * Optional cancellation signal. Firing it rejects the pending call
28
+ * immediately (without waiting for the timeout) and cleans up the message
29
+ * listener. Use the signal's `reason` for the rejection if present.
30
+ */
31
+ signal?: AbortSignal;
32
+ }
33
+
34
+ type ResponseEnvelope<T> =
35
+ | { type: "response:result"; channelName: string; callId: string; result: T }
36
+ | { type: "response:error"; channelName: string; callId: string; error: SerializedError };
37
+
38
+ /**
39
+ * Asynchronous request/response over any `MessageTarget`.
40
+ *
41
+ * Sends `params` to the peer listening with `listenPort`, waits up to
42
+ * `timeout` ms for a matching reply, and either resolves with the result or
43
+ * rejects with the deserialised error.
44
+ */
45
+ export function callPort<TResult = unknown, TParams = unknown>(
46
+ port: MessageTarget,
47
+ params: TParams,
48
+ {
49
+ timeout = 1000,
50
+ channelName = "",
51
+ log = () => {},
52
+ newCallId = () => `call-${Date.now()}-${String(Math.random()).substring(2)}`,
53
+ signal,
54
+ }: CallPortOptions = {},
55
+ ): Promise<TResult> {
56
+ const callId = newCallId();
57
+ log("[callPort]", { channelName, callId, params });
58
+ // Combine the caller's signal (if any) with the port's transport-close
59
+ // signal (if the port is transport-backed). Either firing rejects the
60
+ // pending call. Without this, `callPort` would hang on transport-level
61
+ // disconnects until the per-call `timeout` fires (default 1 s, but
62
+ // higher-level operations like httpFetch dial it up to ~24 days).
63
+ const portCloseSignal = getPortCloseSignal(port);
64
+ const combinedSignal = combineSignals(signal, portCloseSignal);
65
+ let timerId: ReturnType<typeof setTimeout> | undefined;
66
+ let onMessage: ((event: MessageEvent) => void) | undefined;
67
+ let onAbort: (() => void) | undefined;
68
+ const promise = new Promise<TResult>((resolve, reject) => {
69
+ if (combinedSignal?.aborted) {
70
+ reject(abortReason(combinedSignal));
71
+ return;
72
+ }
73
+ if (Number.isFinite(timeout) && timeout > 0) {
74
+ timerId = setTimeout(() => reject(new Error(`Call timeout. CallId: "${callId}".`)), timeout);
75
+ }
76
+ onMessage = (event: MessageEvent) => {
77
+ const data = event.data as ResponseEnvelope<TResult> | undefined;
78
+ if (!data) return;
79
+ if (data.channelName !== channelName) return;
80
+ if (data.callId !== callId) return;
81
+ if (data.type === "response:error") reject(deserializeError(data.error));
82
+ else if (data.type === "response:result") resolve(data.result);
83
+ };
84
+ port.addEventListener("message", onMessage);
85
+ if (combinedSignal) {
86
+ onAbort = () => reject(abortReason(combinedSignal));
87
+ combinedSignal.addEventListener("abort", onAbort, { once: true });
88
+ }
89
+ });
90
+ // Swallow rejection on this side-branch so the cleanup .finally doesn't
91
+ // surface an unhandled rejection. The original `promise` keeps its
92
+ // rejection for the caller.
93
+ promise
94
+ .catch(() => {})
95
+ .finally(() => {
96
+ if (timerId !== undefined) clearTimeout(timerId);
97
+ if (onMessage) port.removeEventListener("message", onMessage);
98
+ if (onAbort && combinedSignal) combinedSignal.removeEventListener("abort", onAbort);
99
+ });
100
+ port.postMessage({ type: "request", channelName, callId, params });
101
+ return promise;
102
+ }
103
+
104
+ function abortReason(signal: AbortSignal): Error {
105
+ const reason = (signal as { reason?: unknown }).reason;
106
+ if (reason instanceof Error) return reason;
107
+ const err = new Error(reason === undefined ? "Aborted" : String(reason));
108
+ err.name = "AbortError";
109
+ return err;
110
+ }
111
+
112
+ function combineSignals(...signals: (AbortSignal | undefined)[]): AbortSignal | undefined {
113
+ const live = signals.filter((s): s is AbortSignal => s !== undefined);
114
+ if (live.length === 0) return undefined;
115
+ if (live.length === 1) return live[0];
116
+ // `AbortSignal.any` (ES2024) forwards `aborted` + `reason` from the first
117
+ // input that fires and auto-cleans up its listeners when the result is GC'd.
118
+ return AbortSignal.any(live);
119
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Wire-level "cancel this sub-channel" signal.
3
+ *
4
+ * Used by `ioSend` when the consumer breaks out of the bidi stream early
5
+ * (`iter.return()`) so the producer-side `ioHandle` can stop generating
6
+ * chunks immediately instead of waiting for `callPort` timeouts to fire.
7
+ *
8
+ * Format: `port.postMessage({ type: "cancel-channel", channelName })`.
9
+ * Fire-and-forget; no callId, no response.
10
+ */
11
+ import type { MessageTarget } from "./message-target.js";
12
+
13
+ export const CANCEL_CHANNEL_TYPE = "cancel-channel";
14
+
15
+ interface CancelChannelMessage {
16
+ type: typeof CANCEL_CHANNEL_TYPE;
17
+ channelName: string;
18
+ }
19
+
20
+ export function postCancelChannel(port: MessageTarget, channelName: string): void {
21
+ if (!channelName) return;
22
+ try {
23
+ port.postMessage({ type: CANCEL_CHANNEL_TYPE, channelName } satisfies CancelChannelMessage);
24
+ } catch {
25
+ /* port may be closed — best effort */
26
+ }
27
+ }
28
+
29
+ export function listenCancelChannel(
30
+ port: MessageTarget,
31
+ channelName: string,
32
+ onCancel: () => void,
33
+ ): () => void {
34
+ const handler = (event: MessageEvent) => {
35
+ const data = event.data as CancelChannelMessage | undefined;
36
+ if (!data || data.type !== CANCEL_CHANNEL_TYPE) return;
37
+ if (data.channelName !== channelName) return;
38
+ onCancel();
39
+ };
40
+ port.addEventListener("message", handler);
41
+ return () => port.removeEventListener("message", handler);
42
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Per-port AbortSignal that fires when the underlying transport adapter
3
+ * (libp2p stream, LiveKit participant, WebSocket, …) detects closure.
4
+ *
5
+ * Native `MessagePort` does NOT surface close events to either entangled
6
+ * half — neither a `close` event, nor a `messageerror`, nor any signal at
7
+ * all. Closing one half just silently stops message delivery on the other.
8
+ * A WHATWG proposal exists (https://github.com/whatwg/html/issues/1766) but
9
+ * no browser ships it.
10
+ *
11
+ * This module is the work-around: `bindBytesToPort` creates an
12
+ * `AbortController` per port and aborts it when `transport.onClose` fires.
13
+ * Consumers (`callPort`, `recieve`, `send`, application code) look up the
14
+ * signal via `getPortCloseSignal(port)` and combine it into their own
15
+ * abort plumbing — typically with `AbortSignal.any([userSignal, closeSignal])`
16
+ * — so pending awaits reject cleanly instead of hanging until a per-call
17
+ * timeout fires.
18
+ *
19
+ * Ports created via raw `new MessageChannel()` (i.e., without going through
20
+ * `bindBytesToPort`) return `undefined` from `getPortCloseSignal` — there's
21
+ * no underlying transport so there's nothing to track.
22
+ */
23
+ import type { MessageTarget } from "./message-target.js";
24
+
25
+ const closeSignals = new WeakMap<MessageTarget, AbortSignal>();
26
+
27
+ /**
28
+ * Internal: register a port's close signal. Called once by `bindBytesToPort`.
29
+ *
30
+ * @internal
31
+ */
32
+ export function setPortCloseSignal(port: MessageTarget, signal: AbortSignal): void {
33
+ closeSignals.set(port, signal);
34
+ }
35
+
36
+ /**
37
+ * Return the AbortSignal that fires when `port`'s transport closes, or
38
+ * `undefined` if `port` is not transport-backed (e.g., a raw
39
+ * `MessageChannel().port1`).
40
+ */
41
+ export function getPortCloseSignal(port: MessageTarget): AbortSignal | undefined {
42
+ return closeSignals.get(port);
43
+ }
@@ -0,0 +1,208 @@
1
+ import type { Connect, Duplex, Serve } from "@statewalker/webrun-streams";
2
+ import {
3
+ type DuplexOverPortOptions,
4
+ duplexOverPort,
5
+ serveDuplexOverPort,
6
+ } from "./duplex-over-port.js";
7
+ import type { MessageTarget } from "./message-target.js";
8
+ import { multiplexPort } from "./multiplex-port.js";
9
+ import type { PortCodec, PortMux } from "./port-types.js";
10
+
11
+ /**
12
+ * Announced with every port this pair opens. Layer 1 never reads `meta` — it is
13
+ * here so a peer running something else over the same multiplexer can tell a
14
+ * stream port from whatever else it hands out, and so a packet capture says
15
+ * what the port is for.
16
+ */
17
+ const STREAM_META = { kind: "stream" } as const;
18
+
19
+ /** What a mux calls when the peer opens a port. `false` rejects it. */
20
+ export type OnPort = (port: MessageTarget, meta?: unknown) => boolean | undefined;
21
+
22
+ /**
23
+ * A source of ports, built around the handler that will answer them.
24
+ *
25
+ * WHY A FACTORY AND NOT A `PortMux`. Every mux decides accept-or-reject
26
+ * **synchronously**, inside the `open` envelope, and tells the peer on the
27
+ * spot — `multiplexPort` posts `{type:"close", reason:"rejected"}` before
28
+ * returning. There is no "decide later", and layer 1 refuses to queue what it
29
+ * cannot deliver ("Drop, never queue").
30
+ *
31
+ * So a mux handed over already built has a window between its construction and
32
+ * its handler being attached, and a port arriving in that window is rejected
33
+ * outright — measured: zero messages delivered, and the peer told `rejected`
34
+ * for a call that was merely early. A factory closes the window by
35
+ * construction: the mux cannot exist before the thing that answers it.
36
+ *
37
+ * `connect` passes no handler, which is how a caller declines inbound ports.
38
+ * The same factory therefore serves both sides, and whichever of them called
39
+ * it owns the mux and closes it.
40
+ */
41
+ export type PortMuxFactory = (onPort?: OnPort) => PortMux | Promise<PortMux>;
42
+
43
+ export interface PortParams {
44
+ /**
45
+ * Where ports come from. See {@link PortMuxFactory}, and `overPipe` /
46
+ * `overPorts` for the two this package ships.
47
+ *
48
+ * This used to be a `MessageTarget` plus `side` and a credit window, which
49
+ * forced one strategy — an id table — on every transport. A libp2p
50
+ * connection multiplexes already, so that stacked two multiplexers with no
51
+ * way to tell which one stalled; a transferable boundary moves real ports
52
+ * and needs no table at all. The strategy belongs to whoever knows the
53
+ * transport, which is never this file.
54
+ */
55
+ mux: PortMuxFactory;
56
+ /** Per-stream inactivity timeout in ms, reset by any chunk in either direction.
57
+ *
58
+ * Unset — the default — means no timeout at all: a slow consumer is
59
+ * throttled, never failed. Set it on the side that must not hang. */
60
+ timeout?: number;
61
+ }
62
+
63
+ /**
64
+ * One port source in, one caller `Duplex` out.
65
+ *
66
+ * A call is a port: the mux allocates one, `duplexOverPort` runs the single
67
+ * invocation on it, and closing the stream closes the port. No stream ids, no
68
+ * framing and no credit accounting live here — the mux owns the first, and
69
+ * `duplexOverPort` owns the one-chunk window that makes memory bounded.
70
+ */
71
+ export const connect: Connect<PortParams> = async ({ mux: factory, timeout }) => {
72
+ // No handler: a caller answers no inbound calls, and an unexpected `open`
73
+ // from the peer is refused rather than silently accepted and then starved.
74
+ const mux = await factory();
75
+ const streamOptions: DuplexOverPortOptions = { maxMessageSize: mux.maxMessageSize, timeout };
76
+
77
+ const call: Duplex = (input) =>
78
+ // The port is opened on the consumer's first pull, not when `call` is
79
+ // invoked. A caller that builds a stream and then drops it without
80
+ // iterating therefore costs the peer nothing — under eager opening it
81
+ // would burn a port (and over libp2p, a whole stream) for a call that
82
+ // never arrives.
83
+ (async function* () {
84
+ const port = await mux.openPort(STREAM_META);
85
+ // `yield*` and not a hand-rolled pump: delegation already forwards
86
+ // `return()` and `throw()` into `duplexOverPort`'s generator, which is
87
+ // exactly the cancellation path `Duplex` specifies.
88
+ yield* duplexOverPort(port, streamOptions)(input);
89
+ })();
90
+
91
+ return {
92
+ call,
93
+ async close() {
94
+ await mux.close();
95
+ },
96
+ };
97
+ };
98
+
99
+ /**
100
+ * One port source in, `handler` serving every call that arrives on it.
101
+ *
102
+ * Each inbound port is one invocation, so the factory's `onPort` is the accept
103
+ * loop: it installs `handler` on the new port and nothing else. The returned
104
+ * teardown abandons the streams still running *before* dropping the mux, so
105
+ * the peer's callers reject with "the peer abandoned the stream" instead of
106
+ * parking forever on a port that has silently gone inert — layer 1's close is
107
+ * not observable to layer 2, which is why the notice has to be posted
108
+ * deliberately.
109
+ */
110
+ export const serve: Serve<PortParams> = async ({ mux: factory, timeout }, handler) => {
111
+ // Live streams only. A set that merely accumulated one entry per call would
112
+ // grow for the life of the connection — the same unbounded retention this
113
+ // stack exists to avoid, just moved from bytes to closures — so each entry is
114
+ // dropped the moment its handler is finished with.
115
+ const live = new Set<() => void>();
116
+ let streamOptions: DuplexOverPortOptions = { timeout };
117
+
118
+ const mux = await factory((port) => {
119
+ let off: (() => void) | undefined;
120
+ let finished = false;
121
+ // Wrapping the handler's *output* is how this side learns a stream is
122
+ // over: `sendChunks` drives that generator to completion, and an abort
123
+ // reaches it as `return()` through `throughAbort`, so the `finally` runs
124
+ // on every ending — normal exhaustion, handler throw, peer abort, local
125
+ // teardown. `serveDuplexOverPort` reports no completion of its own.
126
+ const tracked: Duplex = (input) =>
127
+ (async function* () {
128
+ try {
129
+ yield* handler(input);
130
+ } finally {
131
+ finished = true;
132
+ if (off) live.delete(off);
133
+ }
134
+ })();
135
+ off = serveDuplexOverPort(port, tracked, streamOptions);
136
+ // `serveDuplexOverPort` invokes the handler synchronously. The generator
137
+ // body above is lazy, so today it cannot have finished by the time `off`
138
+ // exists — but if it ever could, the `finally` would have run with `off`
139
+ // still unassigned and this line would add back an entry nothing will
140
+ // ever remove. The flag costs a word and closes that door.
141
+ if (!finished) live.add(off);
142
+ });
143
+
144
+ // Only knowable after the factory has run, and the first inbound port cannot
145
+ // arrive before it returns — the mux does not exist to receive one yet.
146
+ streamOptions = { maxMessageSize: mux.maxMessageSize, timeout };
147
+
148
+ let torn = false;
149
+ return async () => {
150
+ if (torn) return;
151
+ torn = true;
152
+ for (const off of [...live]) {
153
+ live.delete(off);
154
+ try {
155
+ off();
156
+ } catch {
157
+ // One stream's teardown failing must not strand the rest, nor the
158
+ // mux close below.
159
+ }
160
+ }
161
+ await mux.close();
162
+ };
163
+ };
164
+
165
+ export interface OverPipeOptions {
166
+ /** How envelopes are placed on the pipe. `structuredCodec` for a `MessagePort`. */
167
+ codec: PortCodec;
168
+ /**
169
+ * Id parity. The initiator allocates even ids, the responder odd, so both
170
+ * ends may open concurrently with no negotiation. The two ends of one pair
171
+ * must disagree, or their ids collide.
172
+ */
173
+ side?: "initiator" | "responder";
174
+ /** Ceiling on concurrently open ports. Bounds the id table only. */
175
+ maxPorts?: number;
176
+ /**
177
+ * Largest payload one message may carry, if the pipe imposes a limit.
178
+ * Bodies are split to fit. Leave at least 256 bytes of margin below the
179
+ * transport's real ceiling — see `PortMuxOptions.maxMessageSize`.
180
+ */
181
+ maxMessageSize?: number;
182
+ }
183
+
184
+ /**
185
+ * Ports over ONE PIPE OF BYTES — a `MessagePort`, a worker, a WebSocket.
186
+ *
187
+ * There is no second port to be had, so `multiplexPort`'s id table invents
188
+ * them. Use this when the transport gives you exactly one channel.
189
+ */
190
+ export function overPipe(pipe: MessageTarget, options: OverPipeOptions): PortMuxFactory {
191
+ return (onPort) => multiplexPort(pipe, { ...options, onPort });
192
+ }
193
+
194
+ /**
195
+ * Ports from a source that already has them — a transferable boundary, or a
196
+ * transport that multiplexes on its own (libp2p's yamux, say).
197
+ *
198
+ * A pass-through, so an adapter that builds its own `PortMux` plugs in without
199
+ * this package learning what that transport is. It exists to make the
200
+ * three-way choice legible at the call site rather than to do work:
201
+ *
202
+ * ```ts
203
+ * await serve({ mux: overPorts((onPort) => libp2pPortMux({ node, onPort })) }, handler);
204
+ * ```
205
+ */
206
+ export function overPorts(factory: PortMuxFactory): PortMuxFactory {
207
+ return factory;
208
+ }