@statewalker/webrun-rpc 0.5.1 → 0.8.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.
@@ -0,0 +1,61 @@
1
+ import type { MessageTarget } from "./message-target.js";
2
+
3
+ /**
4
+ * The send side of a port with flow control (`PortMuxOptions.window`): what
5
+ * waits in its queue, and when it has drained.
6
+ */
7
+ export interface PortSendState {
8
+ /** Bytes queued on this port, waiting for the peer's credit. */
9
+ bufferedAmount(): number;
10
+ /** Resolves once at most `below` bytes are queued (default 0). */
11
+ ready(below?: number): Promise<void>;
12
+ }
13
+
14
+ /**
15
+ * Runs `fn` on a later task, never a microtask: a producer looping on
16
+ * `whenPortReady` must let the transport's own events run. `setImmediate`
17
+ * where it exists; elsewhere a `MessageChannel`, because nested `setTimeout`s
18
+ * are clamped to 4 ms in browsers.
19
+ */
20
+ const setImmediateFn = (globalThis as { setImmediate?: (fn: () => void) => unknown }).setImmediate;
21
+ /** @internal */
22
+ export const nextTask: (fn: () => void) => void = setImmediateFn
23
+ ? (fn) => {
24
+ setImmediateFn(fn);
25
+ }
26
+ : (() => {
27
+ let channel: MessageChannel | undefined;
28
+ const queue: Array<() => void> = [];
29
+ return (fn) => {
30
+ if (!channel) {
31
+ channel = new MessageChannel();
32
+ channel.port1.onmessage = () => queue.shift()?.();
33
+ }
34
+ queue.push(fn);
35
+ channel.port2.postMessage(0);
36
+ };
37
+ })();
38
+
39
+ const states = new WeakMap<MessageTarget, PortSendState>();
40
+
41
+ /** @internal Registered by the multiplexer for each port with a window. */
42
+ export function setPortSendState(port: MessageTarget, state: PortSendState): void {
43
+ states.set(port, state);
44
+ }
45
+
46
+ /**
47
+ * Bytes queued on `port` because its window is spent (0 for a port without
48
+ * flow control). A sender that should not queue without bound waits for
49
+ * `whenPortReady` before posting more.
50
+ */
51
+ export function portBufferedAmount(port: MessageTarget): number {
52
+ return states.get(port)?.bufferedAmount() ?? 0;
53
+ }
54
+
55
+ /**
56
+ * Resolves once at most `below` bytes are queued on `port`, always on a later
57
+ * task (for a port without flow control, on the next one).
58
+ */
59
+ export function whenPortReady(port: MessageTarget, below = 0): Promise<void> {
60
+ return states.get(port)?.ready(below) ?? new Promise<void>((resolve) => nextTask(resolve));
61
+ }
package/src/port-types.ts CHANGED
@@ -3,14 +3,22 @@ import type { MessageTarget } from "./message-target.js";
3
3
  /**
4
4
  * What a multiplexer exchanges over the underlying port.
5
5
  *
6
- * Three types and nothing more. There is no DATA/ACK split, no credit and no
7
- * error type: `close` carries an opaque `reason` that layer 1 never inspects,
8
- * because stream semantics belong above this layer.
6
+ * `open`, `message` and `close`, plus `credit` (a port's window) and `frame`
7
+ * (a piece of a large message, with `frameSize`). There is no DATA/ACK split
8
+ * and no error type: `close` carries an opaque `reason` that layer 1 never
9
+ * inspects, because stream semantics belong above this layer.
9
10
  */
10
11
  export type PortEnvelope =
11
- | { type: "open"; id: number; meta?: unknown }
12
+ | { type: "open"; id: number; meta?: unknown; window?: number; exact?: boolean }
12
13
  | { type: "message"; id: number; payload: unknown }
13
- | { type: "close"; id: number; reason?: unknown };
14
+ | { type: "close"; id: number; reason?: unknown }
15
+ /** Per-port flow control (`window`): the receiver has consumed `bytes` more. */
16
+ | { type: "credit"; id: number; bytes: number; exact?: boolean }
17
+ /** Fair framing (`frameSize`): one piece of an encoded `message` envelope. */
18
+ | { type: "frame"; id: number; data: Uint8Array; last?: boolean }
19
+ /** Keepalive (`keepalive`): the mux's own liveness probe and its answer, on an id no port takes. */
20
+ | { type: "ping"; id: number }
21
+ | { type: "pong"; id: number };
14
22
 
15
23
  /**
16
24
  * How an envelope reaches the wire.
@@ -25,6 +33,15 @@ export interface PortCodec {
25
33
  post(port: MessageTarget, envelope: PortEnvelope, transfer?: Transferable[]): void;
26
34
  /** Recover an envelope from a message event, or `undefined` to ignore it. */
27
35
  read(event: MessageEvent): PortEnvelope | undefined;
36
+ /**
37
+ * Optional, for codecs whose wire form is bytes, with `decode`: the bytes
38
+ * `post` would place on the port. A port with a window then counts real
39
+ * bytes instead of an estimate, posts them as they are (encoding once), and
40
+ * can split them into frames (`frameSize`).
41
+ */
42
+ encode?(envelope: PortEnvelope): Uint8Array;
43
+ /** Optional, with `encode`: an envelope back from its bytes, or `undefined`. */
44
+ decode?(data: unknown): PortEnvelope | undefined;
28
45
  }
29
46
 
30
47
  export interface PortMuxOptions {
@@ -67,14 +84,126 @@ export interface PortMuxOptions {
67
84
  * delivered a body as zero bytes with no error on either side.
68
85
  */
69
86
  maxMessageSize?: number;
87
+ /**
88
+ * The transport's life. When it aborts, the mux closes with
89
+ * `signal.reason`: every port's close signal aborts and the calls in flight
90
+ * on them reject at once. Wire it to whatever reports the pipe's death — a
91
+ * worker's `exit`, a libp2p stream's `close`, a liveness lock — because a
92
+ * pipe that dies silently otherwise leaves every port open, forever.
93
+ */
94
+ signal?: AbortSignal;
95
+ /**
96
+ * Per-port flow control, in bytes (off when unset). A port this mux opens
97
+ * asks for it in its `open`; the peer's mux then applies it to that port in
98
+ * both directions. Each side may have at most `window` bytes of a port's
99
+ * messages delivered but not yet consumed by the other side; what exceeds
100
+ * it waits in the port's send queue (`portBufferedAmount`,
101
+ * `whenPortReady`). A message counts as consumed once every listener has
102
+ * returned, or, for a listener that returns a promise, once it settles: a
103
+ * slow consumer holds its own port's budget, and no other port's.
104
+ *
105
+ * A message's size is its byte length (`ArrayBuffer`, typed arrays), a
106
+ * string's length, or for an object the sizes of its values (4 levels deep,
107
+ * at most 256 values), and at least 64 — or, when both ends' codecs
108
+ * implement `encode` / `decode`, the encoded bytes. A message larger than
109
+ * the window is sent when nothing else is outstanding.
110
+ *
111
+ * Safe against an older peer: neither end holds back until the other has
112
+ * shown it reads credit (the responder's zero-byte ack, then the opener's
113
+ * answer to it), so a peer that never sends credit runs unwindowed.
114
+ */
115
+ window?: number;
116
+ /**
117
+ * Fair framing, in bytes (off when unset). Every port's messages leave in
118
+ * turn, one item per port, whenever the pipe has room (its `whenDrained`),
119
+ * and a message whose encoding is larger than `frameSize` goes as frames of
120
+ * that size, reassembled by the peer. A small message then waits for one
121
+ * frame of another port's large message, not for all of it. It needs
122
+ * `window` and codecs with `encode` / `decode` on both ends, agreed in the
123
+ * window's `open` and acknowledgement (`exact`); otherwise messages go
124
+ * whole. 16-64 KiB is a reasonable range.
125
+ */
126
+ frameSize?: number;
127
+ /**
128
+ * Ceiling, in bytes, on what this side holds of a peer's framed messages
129
+ * past their ports' windows, summed over all ports (default 64 MiB).
130
+ * A partial message counts against its port's window as it arrives: one
131
+ * begun while this side still owed credit must fit what the window left,
132
+ * and one begun with nothing owed may pass the window, its excess charged
133
+ * to this cap. A frame past either limit makes the mux drop that port's
134
+ * partial message, close the port with a `RangeError` and send the peer a
135
+ * close with reason `"max-reassembled-bytes"`. A framed message more than
136
+ * this cap past the window never arrives, though the same message sent
137
+ * whole would.
138
+ */
139
+ maxReassembledBytes?: number;
140
+ /**
141
+ * Liveness of the peer (off when unset). Every `intervalMs` the mux pings
142
+ * and the peer answers; anything from it counts as an answer. After `misses`
143
+ * pings in a row (default 3) with nothing back from a peer that has answered
144
+ * before, the mux closes with `webrun-rpc: the peer did not answer …`: the
145
+ * peer died or froze. A tick taken while this end's own pipe reports a
146
+ * backlog (`bufferedAmount`) is not counted. Every current mux answers pings,
147
+ * with or without this option; an older one never does and is never closed
148
+ * for it — nor is a peer that dies before its first answer, so wire
149
+ * `signal` too where the transport can report death.
150
+ */
151
+ keepalive?: { intervalMs: number; misses?: number };
152
+ /**
153
+ * Leak detection, for development (off when unset). A port its consumer
154
+ * dropped without `close()` is reported here once the garbage collector
155
+ * reclaims it. If it has no listener left it is freed on both ends, as a
156
+ * close would free it; one that still has a listener is still receiving, so
157
+ * it is reported and left open. A listener that closes over its own port
158
+ * keeps it reachable from the mux, so such a port is never collected, and
159
+ * never reported. Each port costs a `FinalizationRegistry` entry, and when
160
+ * the collector runs is up to the runtime.
161
+ */
162
+ onLeak?: (info: PortInfo) => void;
163
+ }
164
+
165
+ /** What `PortMux.ports()` reports for one open port. */
166
+ export interface PortInfo {
167
+ id: number;
168
+ meta?: unknown;
169
+ /** `Date.now()` when the port was opened. */
170
+ openedAt: number;
171
+ /** `Date.now()` of the last message in either direction. */
172
+ lastActivity: number;
173
+ /**
174
+ * The window in force, once the peer acknowledged it. Absent on a port that
175
+ * asked for one from a peer that never answers credit (an older version).
176
+ */
177
+ window?: number;
178
+ /** With a window: bytes waiting in this port's send queue. */
179
+ queued?: number;
180
+ /** With a window: bytes sent and not yet granted back by the peer. */
181
+ outstanding?: number;
182
+ /**
183
+ * With a window: bytes delivered to this side's listeners and not yet
184
+ * consumed. A value that stays up names a listener whose promise never
185
+ * settles, which holds the port's window spent.
186
+ */
187
+ unconsumed?: number;
188
+ }
189
+
190
+ export interface OpenPortOptions {
191
+ /** Closes the port when it aborts, so the port lives no longer than its user. */
192
+ signal?: AbortSignal;
70
193
  }
71
194
 
72
195
  /** One port in, many ports out. */
73
196
  export interface PortMux {
74
197
  /** Allocate a port, announce it, and return the local end. */
75
- openPort(meta?: unknown): Promise<MessageTarget>;
76
- /** Close every virtual port, then release the underlying port. */
77
- close(): Promise<void>;
198
+ openPort(meta?: unknown, options?: OpenPortOptions): Promise<MessageTarget>;
199
+ /**
200
+ * Close every port, then release the underlying port. With
201
+ * `multiplexPort`, `reason` becomes the reason of every port's close signal
202
+ * (`getPortCloseSignal`); `transferPortMux`'s real ports have none.
203
+ */
204
+ close(reason?: unknown): Promise<void>;
205
+ /** The ports open now, to find who holds a full table. */
206
+ ports?(): PortInfo[];
78
207
  /** See {@link PortMuxOptions.maxMessageSize}. */
79
208
  readonly maxMessageSize?: number;
80
209
  }
@@ -5,7 +5,15 @@ function isEnvelope(value: unknown): value is PortEnvelope {
5
5
  const candidate = value as { type?: unknown; id?: unknown };
6
6
  if (typeof candidate.id !== "number") return false;
7
7
  if (!Number.isInteger(candidate.id) || candidate.id < 0) return false;
8
- return candidate.type === "open" || candidate.type === "message" || candidate.type === "close";
8
+ return (
9
+ candidate.type === "open" ||
10
+ candidate.type === "message" ||
11
+ candidate.type === "close" ||
12
+ candidate.type === "credit" ||
13
+ candidate.type === "frame" ||
14
+ candidate.type === "ping" ||
15
+ candidate.type === "pong"
16
+ );
9
17
  }
10
18
 
11
19
  /**
@@ -1,5 +1,5 @@
1
1
  import type { MessageTarget } from "./message-target.js";
2
- import type { PortMux } from "./port-types.js";
2
+ import type { OpenPortOptions, PortMux } from "./port-types.js";
3
3
 
4
4
  /** The `type` of the envelope that carries a transferred port to the peer. */
5
5
  export const PORT_TRANSFER = "webrun-rpc:port-transfer";
@@ -43,7 +43,8 @@ export function transferPortMux(
43
43
  options: TransferPortMuxOptions = {},
44
44
  ): PortMux {
45
45
  const { onPort, maxMessageSize } = options;
46
- const issued = new Set<MessagePort>();
46
+ // Each port this side issued, with the function that detaches it from its `openPort` signal.
47
+ const issued = new Map<MessagePort, () => void>();
47
48
  let closed = false;
48
49
 
49
50
  const listener = (event: MessageEvent): void => {
@@ -67,7 +68,14 @@ export function transferPortMux(
67
68
  port.close();
68
69
  return;
69
70
  }
70
- issued.add(port);
71
+ // A port its user closes is forgotten, as on the opening side.
72
+ const forget = () => issued.delete(port);
73
+ issued.set(port, forget);
74
+ const close = port.close.bind(port);
75
+ port.close = () => {
76
+ forget();
77
+ close();
78
+ };
71
79
  };
72
80
 
73
81
  target.addEventListener("message", listener);
@@ -76,25 +84,41 @@ export function transferPortMux(
76
84
  return {
77
85
  maxMessageSize,
78
86
 
79
- async openPort(meta?: unknown): Promise<MessageTarget> {
87
+ async openPort(meta?: unknown, { signal }: OpenPortOptions = {}): Promise<MessageTarget> {
80
88
  if (closed) throw new Error("webrun-rpc: the multiplexer is closed");
89
+ if (signal?.aborted) throw signal.reason;
81
90
  const channel = new MessageChannel();
82
91
  channel.port1.start();
83
92
  // The transferred end is not referenced from the message itself, so it
84
93
  // arrives in `event.ports` on the peer — the platform's own hand-off,
85
94
  // identical in Node and in browsers.
86
95
  target.postMessage({ type: PORT_TRANSFER, meta }, [channel.port2]);
87
- issued.add(channel.port1);
88
- return channel.port1;
96
+ const port = channel.port1;
97
+ const onAbort = () => port.close();
98
+ signal?.addEventListener("abort", onAbort, { once: true });
99
+ // The port is done when its user closes it, the signal aborts, or the mux closes: each
100
+ // forgets it and removes the signal's listener, so a long-lived signal reused for many
101
+ // ports holds none of them.
102
+ const forget = () => {
103
+ signal?.removeEventListener("abort", onAbort);
104
+ issued.delete(port);
105
+ };
106
+ issued.set(port, forget);
107
+ const close = port.close.bind(port);
108
+ port.close = () => {
109
+ forget();
110
+ close();
111
+ };
112
+ return port;
89
113
  },
90
114
 
91
115
  async close(): Promise<void> {
92
116
  if (closed) return;
93
117
  closed = true;
94
118
  target.removeEventListener("message", listener);
95
- for (const port of issued) {
119
+ for (const port of [...issued.keys()]) {
96
120
  try {
97
- port.close();
121
+ port.close(); // forgets it too
98
122
  } catch {
99
123
  /* already gone */
100
124
  }
@@ -4,8 +4,12 @@ import type { MessageListener, MessageTarget } from "./message-target.js";
4
4
  export interface VirtualPortHandle {
5
5
  /** The consumer-facing end. Indistinguishable from a real `MessagePort`. */
6
6
  port: MessageTarget;
7
- /** Multiplexer-only: hand an inbound payload to the consumer's listeners. */
8
- deliver(payload: unknown): void;
7
+ /**
8
+ * Multiplexer-only: hand an inbound payload to the consumer's listeners.
9
+ * Returns a promise when a listener returned one (settled once they all
10
+ * have), so flow control can count the message as consumed only then.
11
+ */
12
+ deliver(payload: unknown, size?: number): Promise<unknown> | undefined;
9
13
  /**
10
14
  * Multiplexer-only: the port is finished; drop listeners and go inert.
11
15
  * `reason` becomes the reason of the port's close signal (see
@@ -13,6 +17,8 @@ export interface VirtualPortHandle {
13
17
  */
14
18
  markClosed(reason?: unknown): void;
15
19
  isClosed(): boolean;
20
+ /** Multiplexer-only: the consumer still listens, so the port is in use. */
21
+ hasListeners(): boolean;
16
22
  }
17
23
 
18
24
  /**
@@ -39,6 +45,21 @@ export function newVirtualPort(
39
45
  closing.abort(reason ?? new Error("webrun-rpc: the port was closed"));
40
46
  };
41
47
 
48
+ // A standalone function, not a method using `port`: closures here share one
49
+ // scope, and one that captured `port` would make `deliver` and `markClosed`
50
+ // — which the mux keeps — hold the consumer's port too (see `onLeak`). It
51
+ // also works detached, as `Symbol.dispose` may be called.
52
+ const close = (reason?: unknown): void => {
53
+ if (closed) return;
54
+ closed = true;
55
+ const notify = requestClose;
56
+ listeners.clear();
57
+ // The mux frees the port first (its slot and its entry in `ports()`), then the close
58
+ // signal fires with the caller's reason: a listener that reopens (reconnect logic) finds
59
+ // the slot free. `notify` passes the reason on to `markClosed`, which aborts with it.
60
+ notify(reason);
61
+ abort(reason);
62
+ };
42
63
  const port: MessageTarget = {
43
64
  addEventListener(_type, listener) {
44
65
  listeners.add(listener);
@@ -50,32 +71,38 @@ export function newVirtualPort(
50
71
  if (closed) return;
51
72
  send(message, transfer);
52
73
  },
53
- close() {
54
- if (closed) return;
55
- closed = true;
56
- const notify = requestClose;
57
- listeners.clear();
58
- notify();
59
- abort();
60
- },
74
+ close,
61
75
  };
62
76
  setPortCloseSignal(port, closing.signal);
77
+ // `using port = await mux.openPort()` closes it on scope exit. Installed
78
+ // only where the runtime has the symbol, and kept off the declared type,
79
+ // which would require consumers to compile with `esnext.disposable`.
80
+ const dispose = (Symbol as { dispose?: symbol }).dispose;
81
+ if (dispose) {
82
+ Object.defineProperty(port, dispose, { value: () => close(), configurable: true });
83
+ }
63
84
 
64
85
  return {
65
86
  port,
66
87
  deliver(payload) {
67
88
  if (closed) return;
68
89
  const event = new MessageEvent("message", { data: payload });
90
+ let pending: PromiseLike<unknown>[] | undefined;
69
91
  // Copy first: a listener may add or remove listeners while running.
70
92
  for (const listener of [...listeners]) {
71
93
  try {
72
- void listener(event);
94
+ const result = listener(event) as unknown;
95
+ if (result && typeof (result as PromiseLike<unknown>).then === "function") {
96
+ pending ??= [];
97
+ pending.push(result as PromiseLike<unknown>);
98
+ }
73
99
  } catch {
74
100
  // One consumer's fault is not the multiplexer's. Swallowing here
75
101
  // keeps a thrown listener from killing the inbound loop and every
76
102
  // other port with it.
77
103
  }
78
104
  }
105
+ return pending ? Promise.allSettled(pending) : undefined;
79
106
  },
80
107
  markClosed(reason) {
81
108
  closed = true;
@@ -85,5 +112,8 @@ export function newVirtualPort(
85
112
  isClosed() {
86
113
  return closed;
87
114
  },
115
+ hasListeners() {
116
+ return listeners.size > 0;
117
+ },
88
118
  };
89
119
  }