@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
package/README.md ADDED
@@ -0,0 +1,549 @@
1
+ # @statewalker/webrun-rpc
2
+
3
+ Ports and RPC over them, in the `webrun-streams-*` family: two port
4
+ multiplexers that turn one `MessageTarget` into many, a `Duplex` stream tier
5
+ that runs one stream over one port, a `Connect` / `Serve` adapter over any
6
+ single port, and a typed-JSON RPC tier that runs over any `MessageTarget`.
7
+
8
+ ## What this is
9
+
10
+ Four pieces, one dependency:
11
+
12
+ - **Port multiplexing** (`multiplexPort` / `PortMux`, and `transferPortMux`) —
13
+ turns one `MessageTarget` into many virtual ones. Each virtual port is itself
14
+ a `MessageTarget`, so a multiplexer composes over another multiplexer's port,
15
+ and everything below can run directly on top of it. `multiplexPort` emulates
16
+ multiplexing over any single port; `transferPortMux` hands the peer real
17
+ transferred `MessagePort`s where the platform provides them. See
18
+ [Port multiplexing](#port-multiplexing) and
19
+ [Transferring ports](#transferring-ports).
20
+ - **Stream tier** (`duplexOverPort` / `serveDuplexOverPort`) — one
21
+ [`webrun-streams`](../webrun-streams) `Duplex` over one port, with
22
+ backpressure that is a property of the protocol rather than a configured
23
+ buffer. **This is what new code should use.** See
24
+ [Streams over a port](#streams-over-a-port).
25
+ - **Connect/serve adapter** (`connect` / `serve`) — the `webrun-streams`
26
+ `Connect` / `Serve` seam over a single port, so a port pair drops into the
27
+ same slot as a socket or a data channel. It is the two pieces above wired
28
+ together and nothing else: `multiplexPort` allocates one virtual port per
29
+ call and `duplexOverPort` runs the call on it, which is why it inherits the
30
+ stream tier's bounded memory rather than a buffer ceiling of its own. See
31
+ [Connect and serve](#connect-and-serve).
32
+ - **Typed-JSON RPC tier** (`callPort` / `listenPort` / `callBidi` /
33
+ `listenBidi` / `ioSend` / `ioHandle` / `send` / `recieve`) — request/response
34
+ with typed JSON arguments per call, relocated here from the retired
35
+ `webrun-streams-port` package. It types against `MessageTarget` rather than
36
+ `MessagePort`, so it also runs directly over a virtual port from
37
+ `multiplexPort` — one raw port fans out into many independent RPC channels
38
+ with no byte-stream layer in between. Use this when you want plain JSON
39
+ messaging and do not need byte-stream semantics.
40
+
41
+ All four are exported from the package root. The only runtime dependency is
42
+ [`@statewalker/webrun-streams`](../webrun-streams).
43
+
44
+ ## Why it exists
45
+
46
+ `MessagePort` is the browser's universal in-process seam — Workers,
47
+ SharedWorkers, ServiceWorkers, iframes, `MessageChannel` pipes between two
48
+ modules in the same tab. It is also the most primitive: `postMessage` fires and
49
+ forgets. There is no request, no correlation, no backpressure, no half-close,
50
+ and an exception on the far side simply never arrives.
51
+
52
+ The stream tier makes a port indistinguishable from any other transport in
53
+ this family, which is what lets an in-browser back-end be tested in-process and
54
+ then moved behind a real socket unchanged. The RPC tier exists because a lot of
55
+ port traffic is not a byte stream at all — it is one typed call with one typed
56
+ answer, and forcing that through a `Duplex` is more machinery than the job
57
+ needs. The multiplexer exists because a single `MessagePort` is usually the
58
+ only pipe you're handed — one `MessageChannel` per logical stream doesn't
59
+ scale — so `multiplexPort` fans it out into as many independent
60
+ `MessageTarget`s as the RPC tier or byte-stream tier need.
61
+
62
+ ## Install
63
+
64
+ ```sh
65
+ npm install @statewalker/webrun-rpc
66
+ ```
67
+
68
+ No peer dependencies. `MessagePort` and `MessageChannel` are platform globals in
69
+ browsers and in Node ≥ 15 (`node:worker_threads`).
70
+
71
+ ## Getting started
72
+
73
+ ### Connect and serve
74
+
75
+ Reach for this when you want the family's `Connect` / `Serve` shape over a
76
+ source of ports; reach for [Streams over a port](#streams-over-a-port) directly
77
+ when you are already holding a port per call.
78
+
79
+ `connect` and `serve` take a **port factory**, not a port. They ask it for one
80
+ port per call and run a stream on it — so *how* ports are made belongs to
81
+ whoever knows the transport:
82
+
83
+ | Transport | Factory | Id table? |
84
+ |---|---|---|
85
+ | one pipe of bytes (`MessagePort`, worker, WebSocket) | `overPipe(pipe, { codec })` | **yes** — there is no second port to be had |
86
+ | a transferable boundary | `overPorts((onPort) => transferPortMux(target, { onPort }))` | no — the platform moves real ports |
87
+ | a transport that multiplexes already (libp2p's yamux) | `overPorts((onPort) => yourMux({ onPort }))` | no — it did the work |
88
+
89
+ ```ts
90
+ import { connect, overPipe, serve, structuredCodec } from "@statewalker/webrun-rpc";
91
+
92
+ const channel = new MessageChannel();
93
+ const codec = structuredCodec;
94
+
95
+ const stop = await serve(
96
+ { mux: overPipe(channel.port2, { codec, side: "responder" }) },
97
+ async function* echo(input) {
98
+ for await (const chunk of input) yield chunk;
99
+ },
100
+ );
101
+
102
+ const { call, close } = await connect({
103
+ mux: overPipe(channel.port1, { codec, side: "initiator" }),
104
+ });
105
+
106
+ for await (const chunk of call([new TextEncoder().encode("ping")])) {
107
+ console.log(new TextDecoder().decode(chunk)); // "ping"
108
+ }
109
+
110
+ await close();
111
+ await stop();
112
+ ```
113
+
114
+ Across a Worker boundary, transfer one end and keep the other:
115
+
116
+ ```ts
117
+ const { port1, port2 } = new MessageChannel();
118
+ worker.postMessage({ port: port2 }, [port2]);
119
+ const { call } = await connect({ mux: overPipe(port1, { codec: structuredCodec }) });
120
+ ```
121
+
122
+ #### Why a factory and not a `PortMux`
123
+
124
+ Every mux decides accept-or-reject **synchronously**, inside the `open`
125
+ envelope, and tells the peer on the spot — `multiplexPort` posts
126
+ `{type:"close", reason:"rejected"}` before returning. There is no "decide
127
+ later", and layer 1 refuses to queue what it cannot deliver.
128
+
129
+ So handing `serve` a mux that already exists would leave a window between its
130
+ construction and its handler being attached, and a port arriving in that window
131
+ is rejected outright — **measured: zero messages delivered**, with the peer told
132
+ `rejected` for a call whose only fault was being early. A factory closes the
133
+ window by construction: the mux cannot exist before the thing that answers it.
134
+ `port-factory-no-race.test.ts` measures both halves.
135
+
136
+ `connect` passes no handler, which is how a caller declines inbound ports —
137
+ your factory receives `undefined`. Whichever of the two called the factory owns
138
+ the mux and closes it.
139
+
140
+ #### Port-id sides
141
+
142
+ `side: "initiator" | "responder"` belongs to `overPipe`, because it is
143
+ `multiplexPort`'s id parity: initiator takes even port ids, responder odd. The
144
+ two ends of one pipe must disagree, or their ids collide on the first
145
+ concurrent call. A transport that multiplexes on its own has no ids and no
146
+ parity, which is why this is not a `connect`/`serve` option any more.
147
+
148
+ #### Tuning
149
+
150
+ `overPipe` takes `maxPorts` (concurrent in-flight calls) and `maxMessageSize`
151
+ (payload split size, if the transport caps a message); `connect`/`serve` take
152
+ `timeout` (per-stream inactivity, off by default). `maxMessageSize` is
153
+ *reported* by the mux to the stream tier, so a transport with no message
154
+ ceiling — a libp2p stream — simply does not set one and nothing is split.
155
+ There is deliberately **no buffer size**. The stream tier withholds a chunk's confirmation until the
156
+ consumer has pulled past it, so a producer is at most one chunk ahead of its
157
+ consumer and the ceiling is one chunk per open port — a number there is nothing
158
+ to tune. `connect-serve-backpressure.test.ts` measures it: a 5000-chunk
159
+ producer against a handler that reads one chunk and stops gets exactly one
160
+ chunk out.
161
+
162
+ ### Typed-JSON RPC tier
163
+
164
+ ```ts
165
+ import { callPort, listenPort } from "@statewalker/webrun-rpc";
166
+
167
+ // Responder
168
+ const off = listenPort(port2, async ({ a, b }) => ({ sum: a + b }));
169
+
170
+ // Caller
171
+ const { sum } = await callPort(port1, { a: 2, b: 3 }, { timeout: 2000 });
172
+ ```
173
+
174
+ `callBidi` / `listenBidi` extend this to a streaming outer call, and `ioSend` /
175
+ `ioHandle` carry an async iterator across the port.
176
+
177
+ ### Carrying HTTP over a port
178
+
179
+ ```ts
180
+ import { fetchOverDuplex } from "@statewalker/webrun-http-streams";
181
+
182
+ const response = await fetchOverDuplex(call, new Request("http://local/api/todo"));
183
+ ```
184
+
185
+ ## Streams over a port
186
+
187
+ `duplexOverPort(port, options)` runs one `Duplex` over one port;
188
+ `serveDuplexOverPort(port, handler, options)` is its serving half. Each
189
+ direction is one `callPort` per chunk on its own channel (`"in"` for the
190
+ caller's input, `"out"` for the handler's output), and the reply to a chunk
191
+ *is* the confirmation that the consumer pulled past it.
192
+
193
+ **One stream per port.** A stream port carries exactly one invocation — open
194
+ one port per call. Nothing enforces this: invoking the same `duplexOverPort`
195
+ result twice on one port makes both invocations cross-talk on the same two
196
+ channel names.
197
+
198
+ ```js
199
+ import {
200
+ duplexOverPort,
201
+ multiplexPort,
202
+ serveDuplexOverPort,
203
+ structuredCodec,
204
+ } from "@statewalker/webrun-rpc";
205
+
206
+ const channel = new MessageChannel();
207
+ channel.port1.start();
208
+ channel.port2.start();
209
+
210
+ // The serving end: every stream port the peer opens runs one echo handler.
211
+ const server = multiplexPort(channel.port2, {
212
+ codec: structuredCodec,
213
+ side: "responder",
214
+ onPort: (port) => {
215
+ serveDuplexOverPort(port, async function* echo(input) {
216
+ for await (const chunk of input) yield chunk;
217
+ });
218
+ },
219
+ });
220
+
221
+ // The calling end: one port per stream.
222
+ const client = multiplexPort(channel.port1, {
223
+ codec: structuredCodec,
224
+ side: "initiator",
225
+ });
226
+
227
+ const streamPort = await client.openPort({ kind: "stream" });
228
+ const call = duplexOverPort(streamPort, { maxMessageSize: client.maxMessageSize });
229
+
230
+ const parts = [];
231
+ for await (const chunk of call([new TextEncoder().encode("ping")])) {
232
+ parts.push(new TextDecoder().decode(chunk));
233
+ }
234
+ console.log(parts.join("")); // "ping"
235
+
236
+ await client.close();
237
+ await server.close();
238
+ ```
239
+
240
+ ### `DuplexOverPortOptions`
241
+
242
+ | option | type | meaning |
243
+ | --- | --- | --- |
244
+ | `maxMessageSize` | `number` | Largest payload one chunk may carry, normally `PortMux.maxMessageSize`. Bodies are split to fit with `toChunks`. Unset means no limit and no splitting. |
245
+ | `timeout` | `number` | Inactivity timeout for the **whole stream**, in ms. See below. Unset — the default — means no timeout at all. |
246
+ | `log` | `(...args) => void` | Logging hook; defaults to a no-op. |
247
+
248
+ ### Flow control: a window of one
249
+
250
+ Within one direction the next chunk is never sent until the previous one has
251
+ been delivered **and** pulled past by the consumer. There is no credit window
252
+ and no buffer ceiling to tune, because there is nothing to tune: **one open
253
+ port holds at most one chunk.** Over `multiplexPort` that composes into a
254
+ whole-mux bound of `maxPorts × one chunk`, because `maxPorts` caps concurrent
255
+ ports. `transferPortMux` has no `maxPorts` (see
256
+ [Transferring ports](#transferring-ports)), so the per-port bound is the same
257
+ but the mux-wide one is yours to impose by bounding how many ports you open.
258
+
259
+ A peer that sends a second chunk before the first is confirmed has that call
260
+ refused and **that port closed** — the penalty is scoped to the offending port
261
+ and every other port on the mux is untouched.
262
+
263
+ The honest cost, from the design's own numbers: single-stream throughput is
264
+ `chunk ÷ RTT`. In-process that is negligible. Over a 50 ms WAN round trip a
265
+ 10 MiB body is **~43 s**, because it is 854 sequential round trips.
266
+ Concurrency does not come from pipelining one stream — it comes from running
267
+ many streams, which are genuinely independent because each owns its own port.
268
+
269
+ ### The timeout
270
+
271
+ There is **no timeout by default**, and that is deliberate: a per-chunk
272
+ deadline fails a consumer that is merely slow, which is a bug rather than a
273
+ policy. `callPort` gained `NO_TIMEOUT` for the same reason, and the stream tier
274
+ uses it for every chunk call.
275
+
276
+ The `timeout` option is an inactivity timeout for the whole stream: any chunk
277
+ in either direction resets it, and elapsing aborts the stream.
278
+
279
+ **Know what you are buying if you set it.** The clock is only reset once a
280
+ chunk call *returns*, and that reply is withheld until the consumer has pulled
281
+ past the value. The inactivity clock therefore cannot distinguish "the peer is
282
+ slow" from "the peer is dead": with an explicit `timeout`, a consumer slower
283
+ than it **is** failed. The default of none is why a slow consumer is safe out
284
+ of the box.
285
+
286
+ ### Cancellation
287
+
288
+ Layer 1's close is invisible to layer 2 (see
289
+ [Port multiplexing](#port-multiplexing)): a closed port drops its listeners
290
+ silently and is indistinguishable from a working port nobody is answering. So a
291
+ side that abandons a stream posts an out-of-band `STREAM_ABORT` notice on the
292
+ port — which is the only signal the peer can act on.
293
+
294
+ **Three routes post that notice; two do not.** The notice is posted from
295
+ exactly two places in the implementation — the teardown returned by
296
+ `serveDuplexOverPort`, and the caller's own `finally` as its generator unwinds
297
+ — and everything else that ends a stream simply aborts locally. Measured:
298
+
299
+ | what ends the stream | notice posted? | what the peer actually observes |
300
+ | --- | --- | --- |
301
+ | the caller stops iterating (`break`, `return`, `throw`) | **yes** | `STREAM_ABORT`; the handler unwinds through `iter.return()` |
302
+ | the caller's inactivity `timeout` elapses | **yes** — the abort rejects the caller's stream, and its `finally` posts on the way out | `STREAM_ABORT` |
303
+ | you call the teardown `serveDuplexOverPort` returned | **yes** | `STREAM_ABORT`; the caller's stream rejects with `the peer abandoned the stream` |
304
+ | the **serve side's** inactivity `timeout` elapses | **no** | nothing on the `out` half — a caller parked there waits forever. A caller still *sending* has its chunk calls answered `webrun-rpc: the stream is closed`, but `duplexOverPort` surfaces only the inbound half, so that rejection never reaches its consumer either |
305
+ | a **window violation** (a second unconfirmed chunk), on either side | **no** | the offender gets `response:error` on the channel it violated, naming the violation. Nothing else: the enforcer closes the port, and that close is exactly the layer 1 signal layer 2 cannot see. A peer parked on the other half waits forever |
306
+
307
+ So the notice is what makes a *cooperative* abandonment observable. It is not a
308
+ general liveness mechanism, and the two rows without one are a known gap rather
309
+ than a subtlety of the wording: **give the side that must not hang its own
310
+ `timeout`.** A caller with a `timeout` set detects both of the silent rows on
311
+ its own clock; a caller without one does not detect them at all.
312
+
313
+ **An abort unwinds a producing handler through `iter.return()`, not by
314
+ throwing into it.** A handler's `catch` never sees the abort reason; only its
315
+ `finally` runs. Put cleanup in `finally`.
316
+
317
+ ## API
318
+
319
+ ### Stream tier — exports
320
+
321
+ | Export | Kind | Purpose |
322
+ | --- | --- | --- |
323
+ | `duplexOverPort(port, options?)` | function | Returns a `Duplex` running one stream on `port`. |
324
+ | `serveDuplexOverPort(port, handler, options?)` | function | Installs `handler` as the serving half. Returns an idempotent teardown that abandons the stream and notifies the peer. |
325
+ | `STREAM_ABORT` | constant | The `type` of the out-of-band notice a side posts when it abandons a stream. Exported because tests and adapters assert on it. |
326
+ | `DuplexOverPortOptions` | type | `maxMessageSize`, `timeout`, `log` — see [above](#duplexoverportoptions). |
327
+ | `NO_TIMEOUT` | constant | Pass as `callPort`'s `timeout` to install no deadline at all. |
328
+
329
+ ### Connect/serve tier — exports
330
+
331
+ | Export | Kind | Purpose |
332
+ | --- | --- | --- |
333
+ | `connect(params)` | `Connect<PortParams>` | Resolves `{ call, close }`. One port per call, opened on the consumer's first pull. |
334
+ | `serve(params, handler)` | `Serve<PortParams>` | Registers a `Duplex` handler. Returns an idempotent teardown that abandons the live streams before releasing the mux. |
335
+ | `PortParams` | type | `{ mux: PortMuxFactory; timeout?: number }`. |
336
+ | `PortMuxFactory` | type | `(onPort?) => PortMux \| Promise<PortMux>`. Why a factory: [above](#why-a-factory-and-not-a-portmux). |
337
+ | `overPipe(pipe, options)` | `PortMuxFactory` | Ports over one pipe of bytes, via `multiplexPort`. Takes `codec`, `side`, `maxPorts`, `maxMessageSize`. |
338
+ | `overPorts(factory)` | `PortMuxFactory` | Pass-through, for a mux this package knows nothing about. |
339
+ | `OnPort` | type | `(port, meta?) => boolean \| undefined`. `false` rejects. |
340
+
341
+ `byteChannelFromMessagePort(port)` is still exported, and is now the only thing
342
+ in the package that touches `ByteChannel`: it wraps a port for driving
343
+ `emulateMux` yourself. Nothing here uses it. It goes away with `emulateMux` in
344
+ Plan C.
345
+
346
+ ### Typed-JSON RPC tier — exports
347
+
348
+ | Export | Purpose |
349
+ | --- | --- |
350
+ | `callPort(port, args, options?)` | One typed call, one typed answer. |
351
+ | `listenPort(port, handler, options?)` | Answer `callPort` requests. Returns an unsubscribe. |
352
+ | `callBidi(port, args)` / `listenBidi(port, handler)` | Streaming outer call in both directions. |
353
+ | `ioSend(...)` / `ioHandle(...)` | Ship an async iterator across the port. |
354
+ | `send(...)` / `recieve(...)` | The low-level message primitives underneath. |
355
+ | `CallPortOptions` | `timeout` (default 1000 ms; `NO_TIMEOUT`, or any value that is not a finite number above zero, installs no deadline), `channelName`, `log`, `newCallId`, `signal`. |
356
+ | `CallBidiOptions` / `CallBidiArgs` | `bidiTimeout` for the outer stream. |
357
+ | `ListenPortOptions`, `PortHandler`, `BidiHandler`, `IoSendOptions`, `RecieveOptions`, `SendOptions` | Supporting types. |
358
+
359
+ ### Cancellation and close signalling
360
+
361
+ | Export | Purpose |
362
+ | --- | --- |
363
+ | `postCancelChannel(...)` / `listenCancelChannel(...)` | Out-of-band cancellation for an in-flight call. |
364
+ | `CANCEL_CHANNEL_TYPE` | The reserved message type used for it. |
365
+ | `setPortCloseSignal(port, signal)` / `getPortCloseSignal(port)` | Attach an `AbortSignal` that marks a port as closed, so pending calls reject instead of hanging. |
366
+
367
+ ## Port multiplexing
368
+
369
+ `multiplexPort` turns one message port into many. Each virtual port is itself a
370
+ `MessageTarget` — the same shape as a `MessagePort` — so whatever runs on top
371
+ cannot tell a virtual port from a real one, and a multiplexer composes over
372
+ another multiplexer's port.
373
+
374
+ **A port sends and receives messages. That is all.** No backpressure,
375
+ acknowledgements, credit or buffering ceiling — deliberately, matching
376
+ `MessagePort` semantics. Waiting strategies belong above. The one safety
377
+ property it does hold: **a message for a port with no consumer is dropped, never
378
+ queued**, so a peer flooding an unaccepted port cannot grow memory here.
379
+
380
+ ### `multiplexPort(port, options): PortMux`
381
+
382
+ The default implementation, which emulates multiplexing over a single port. A
383
+ transport that already multiplexes natively supplies its own `PortMux` instead.
384
+
385
+ | option | type | meaning |
386
+ | --- | --- | --- |
387
+ | `codec` | `PortCodec` | How envelopes reach the wire. Required. |
388
+ | `onPort` | `(port, meta?) => boolean \| undefined` | Called when the peer opens a port. Return `false` to reject. **Without it, inbound ports are rejected.** |
389
+ | `side` | `"initiator" \| "responder"` | Id parity — initiator allocates even, responder odd, so both ends may open concurrently. Defaults to `"initiator"`. |
390
+ | `maxPorts` | `number` | Ceiling on **concurrently** open ports. Defaults to `1024`. Closing a port frees its slot immediately, so a long-lived mux can open unboundedly many over its lifetime — this bounds the id table, never the total. It also never delays a message. |
391
+ | `maxMessageSize` | `number` | Largest **payload** a chunk may carry, if the transport has a limit. Reported, not enforced. It bounds the payload, **not the frame**: the layer above chunks the payload to it and the envelope framing is added on top afterwards — measured at 123–128 bytes over `msgpackCodec` (the 128 at a 64 KiB cap), with a modelled ceiling of 134. **Set this ~256 bytes below the transport's real limit**, or a full-size chunk overruns it (a 12 KiB setting was measured producing 12,413-byte frames). |
392
+
393
+ ### `PortMux`
394
+
395
+ | member | meaning |
396
+ | --- | --- |
397
+ | `openPort(meta?)` | Allocate a port, announce it, and return the local end. **Asynchronous** — a natively multiplexed transport cannot produce a port synchronously, and the two must share a shape. Does not wait for the peer to accept. **Rejects** with `RangeError` past `maxPorts`, and rejects on a closed mux — it does not throw synchronously, so a bare `try`/`catch` around the call will not catch either. |
398
+ | `close()` | Close every virtual port, then release the underlying port. |
399
+ | `maxMessageSize` | See above. |
400
+
401
+ `openPort` returning a promise that resolves before acceptance is deliberate:
402
+ it keeps layer 1 free of round trips. Messages posted before the peer accepts
403
+ are sent, and dropped at the far end if it rejects; the port becomes inert.
404
+ Guard failures — `maxPorts` exceeded, the mux already closed — reject the
405
+ returned promise rather than throwing synchronously, so a caller wrapping a
406
+ bare `openPort(...)` call in `try`/`catch` will not catch them.
407
+
408
+ **No close is observable at this layer — not a rejection, and not an orderly close
409
+ either.** `MessageTarget` has no close event and the port you hold exposes no
410
+ queryable state, so when a port closes its listeners are simply cleared and
411
+ `postMessage` becomes a silent no-op. You get no event, no error, and no callback,
412
+ and a `close` envelope's `reason` is discarded rather than delivered. A closed port
413
+ is indistinguishable from a working one that nobody is answering.
414
+
415
+ This is deliberate — a real `MessagePort` behaves the same way, and a close event
416
+ would make this something other than a port — but it means **the layer above must
417
+ carry its own end-of-stream signal** as an ordinary message, sent before the port
418
+ closes, rather than relying on the close being seen.
419
+
420
+ ### `structuredCodec`
421
+
422
+ For ports whose messages are structured values. Envelopes pass through
423
+ unencoded, so nothing is serialised and `ArrayBuffer`s move zero-copy through the
424
+ transfer list.
425
+
426
+ A byte transport needs a codec that encodes; that one ships with
427
+ `@statewalker/webrun-msgpack`, so this package keeps no dependencies.
428
+
429
+ ### `PortEnvelope`
430
+
431
+ What crosses the wire: `{ type: "open", id, meta? }`, `{ type: "message", id,
432
+ payload }`, `{ type: "close", id, reason? }`.
433
+
434
+ `reason` is opaque: layer 1 never inspects it — and, as implemented, never surfaces
435
+ it either. Setting one accomplishes nothing observable at this layer today; it
436
+ exists so the wire format does not have to change when a layer above starts
437
+ carrying it.
438
+
439
+ ## Transferring ports
440
+
441
+ `transferPortMux(target, options)` is a second `PortMux` with the same
442
+ `openPort` / `close` / `maxMessageSize` shape, so the stream tier above it is
443
+ identical — but its ports are **real, transferred `MessagePort`s**. Each
444
+ `openPort` creates a `MessageChannel`, transfers one end to the peer over
445
+ `target`, and returns the other. There is no id table, no `maxPorts` and no
446
+ envelope overhead per message, because the platform does the multiplexing.
447
+
448
+ ```ts
449
+ import { transferPortMux } from "@statewalker/webrun-rpc";
450
+
451
+ const mux = transferPortMux(worker, {
452
+ onPort: (port, meta) => {
453
+ // `meta` is `unknown` — layer 1 never inspects it, so you narrow it.
454
+ if ((meta as { kind?: string })?.kind !== "stream") return false; // reject
455
+ serveDuplexOverPort(port, handler);
456
+ },
457
+ });
458
+ ```
459
+
460
+ | option | type | meaning |
461
+ | --- | --- | --- |
462
+ | `onPort` | `(port, meta?) => boolean \| undefined` | Called when the peer transfers a port in. Return `false` to reject it — the port is closed and nothing further arrives on it. Any other return value, `undefined` included, accepts. **Without it, inbound ports are rejected**, matching `multiplexPort`. |
463
+ | `maxMessageSize` | `number` | Reported to the layer above, never enforced. A `MessagePort` normally has no limit. |
464
+
465
+ `PORT_TRANSFER` is the `type` of the envelope that carries a port to the peer.
466
+ A message with that `type` and no attached port is malformed and is dropped, so
467
+ a shared parent port is not corrupted.
468
+
469
+ **It needs structured clone with transferables**, so it exists in browsers,
470
+ workers and iframes and nowhere else — not over a byte transport. The caller
471
+ selects it explicitly rather than by capability sniffing: use `multiplexPort`
472
+ where the transport is one pipe of bytes.
473
+
474
+ What it buys over emulation: a transferred port can cross an origin or a worker
475
+ boundary and be handed to code that never saw `target`, which is what a relay
476
+ handing a live connection to a third party needs. An emulated port id is
477
+ meaningless outside its own mux.
478
+
479
+ `target` must be a full `MessageTarget`. Reaching a send-only `MessageSink` —
480
+ a `ServiceWorkerClient`, say — is a real use of port transfer but needs a
481
+ different entry point, and is not part of this interface.
482
+
483
+ **Caveat: the issued-port set never shrinks.** Every port `transferPortMux`
484
+ opens or accepts is retained until `close()`, and nothing removes a port from
485
+ that set when it closes. There is no `maxPorts` here for it to exhaust, so
486
+ nothing fails — the set just grows, holding dead `MessagePort` handles for as
487
+ long as the mux lives. Bounding it needs a per-port `close`-event listener, a
488
+ newer platform surface this implementation otherwise avoids; it is a known,
489
+ deliberately deferred gap rather than an oversight. In practice: **scope the
490
+ mux to the lifetime of the thing it multiplexes** — a worker, an iframe, a
491
+ connection — rather than making one process-wide mux and opening streams
492
+ through it forever.
493
+
494
+ ## Message passing
495
+
496
+ | Export | Kind | Purpose |
497
+ | --- | --- | --- |
498
+ | `MessageTarget` | interface | Full-duplex structural view of a message endpoint — a `MessagePort`, a `Worker`, or a ServiceWorker bridge. Extends `MessageSource` and `MessageSink`. |
499
+ | `MessageSource` | interface | `addEventListener`/`removeEventListener` for `"message"`, plus optional `start()`. |
500
+ | `MessageSink` | interface | `postMessage(message, transfer?)`. |
501
+ | `MessageListener` | type | `(event: MessageEvent) => void \| Promise<void>`. |
502
+
503
+ These are types only — no runtime code — so pulling in just the RPC tier costs
504
+ nothing extra. A `MessagePort` satisfies `MessageTarget` structurally; no
505
+ adapter is needed.
506
+
507
+ ## Conformance
508
+
509
+ The unmodified L0–L6 suite of
510
+ [`@statewalker/webrun-streams-conformance`](../webrun-streams-conformance) runs
511
+ **twice**, 11 tests each, against two entry points into the same stack:
512
+
513
+ | run | stack |
514
+ | --- | --- |
515
+ | `webrun-rpc (MessageChannel pair)` | `connect` / `serve` — the adapter, driving both pieces below |
516
+ | `webrun-rpc (multiplexPort + duplexOverPort)` | the two pieces wired by hand in the test |
517
+
518
+ The suite itself has never been changed to accommodate either run.
519
+
520
+ ```sh
521
+ pnpm --filter @statewalker/webrun-rpc test
522
+ ```
523
+
524
+ **L6 does not prove flow control on either run.** `PairTuning` is a credit
525
+ window (`mtu`, `maxStreamBuffer`) and this stack has none to size. The
526
+ `connect`/`serve` pair translates `mtu` to `maxMessageSize`, so its L6 at least
527
+ runs the 256 KiB body as 64 frames rather than one, and drops
528
+ `maxStreamBuffer`; the hand-wired pair ignores the tuning outright, and with
529
+ `maxMessageSize` unset its L6 body crosses as exactly one chunk in each
530
+ direction. Both greens say the body round-trips. The bounded-memory property
531
+ is measured in `tests/connect-serve-backpressure.test.ts`, which counts how far
532
+ a 5000-chunk producer gets against a handler that reads one chunk and stops —
533
+ one chunk here, 5000 against the `emulateMux` implementation this replaced.
534
+ The rest of the flow-control coverage is in
535
+ `tests/duplex-over-port-timeout.test.ts` and
536
+ `tests/duplex-over-port-hostile.test.ts`.
537
+
538
+ ## Dependencies
539
+
540
+ | Dependency | Kind | Why |
541
+ | --- | --- | --- |
542
+ | [`@statewalker/webrun-streams`](../webrun-streams) | runtime | The `Duplex` / `Connect` / `Serve` seam, the iterator primitives underneath the stream tier, and — for `byteChannelFromMessagePort` alone — `ByteChannel` and `emulateMux`. |
543
+
544
+ No runtime dependencies outside the workspace, no peer dependencies. ESM only
545
+ (`"type": "module"`).
546
+
547
+ ## License
548
+
549
+ MIT © statewalker — see [LICENSE](../../LICENSE).
@@ -0,0 +1,15 @@
1
+ import type { ByteChannel } from "@statewalker/webrun-streams";
2
+ import type { MessageTarget } from "./message-target.js";
3
+ /**
4
+ * Wrap any `MessageTarget` — a real `MessagePort`, a worker, or a virtual
5
+ * port over some other transport — as a `ByteChannel`. Outbound bytes are emitted via
6
+ * `port.postMessage(uint8Array)` (the structured-clone path); inbound bytes
7
+ * are taken from `message` events whose `data` is a `Uint8Array` (or
8
+ * coerceable byte-like value).
9
+ *
10
+ * The port must already be started (`port.start()` if manually constructed).
11
+ * This adapter assumes the port carries only byte payloads — non-byte messages
12
+ * are ignored.
13
+ */
14
+ export declare function byteChannelFromMessagePort(port: MessageTarget): ByteChannel;
15
+ //# sourceMappingURL=byte-channel.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"byte-channel.d.ts","sourceRoot":"","sources":["../src/byte-channel.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,6BAA6B,CAAC;AAC/D,OAAO,KAAK,EAAmB,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAE1E;;;;;;;;;;GAUG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,aAAa,GAAG,WAAW,CA8F3E"}
@@ -0,0 +1,25 @@
1
+ import { type CallPortOptions } from "./call-port.js";
2
+ import type { MessageTarget } from "./message-target.js";
3
+ export interface CallBidiOptions extends CallPortOptions {
4
+ /** Timeout for the outer stream (default: `Number.MAX_SAFE_INTEGER` / max int). */
5
+ bidiTimeout?: number;
6
+ }
7
+ export interface CallBidiArgs {
8
+ options?: CallBidiOptions;
9
+ [key: string]: unknown;
10
+ }
11
+ /**
12
+ * Initiates a full-duplex stream call: ships `input` values to the peer and
13
+ * yields the values returned by `listenBidi`'s handler.
14
+ *
15
+ * Internally allocates a fresh sub-channel name, announces it to the peer
16
+ * via `callPort`, and then runs {@link ioSend} on that sub-channel.
17
+ *
18
+ * If the outer `callPort` rejects (e.g., the peer's handler threw and
19
+ * `listenPort` surfaced the error as `response:error`), the inner `ioSend`'s
20
+ * recieveIterator is force-closed via an internal cancel signal so the
21
+ * consumer doesn't hang waiting for chunks that will never come. The outer
22
+ * error is then re-thrown to the caller.
23
+ */
24
+ export declare function callBidi<TIn, TOut>(port: MessageTarget, input: AsyncIterable<TIn> | Iterable<TIn>, { options, ...params }?: CallBidiArgs): AsyncGenerator<TOut>;
25
+ //# sourceMappingURL=call-bidi.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"call-bidi.d.ts","sourceRoot":"","sources":["../src/call-bidi.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,eAAe,EAAY,MAAM,gBAAgB,CAAC;AAEhE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD,MAAM,WAAW,eAAgB,SAAQ,eAAe;IACtD,mFAAmF;IACnF,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,CAAC,EAAE,eAAe,CAAC;IAC1B,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAuB,QAAQ,CAAC,GAAG,EAAE,IAAI,EACvC,IAAI,EAAE,aAAa,EACnB,KAAK,EAAE,aAAa,CAAC,GAAG,CAAC,GAAG,QAAQ,CAAC,GAAG,CAAC,EACzC,EAAE,OAAY,EAAE,GAAG,MAAM,EAAE,GAAE,YAAiB,GAC7C,cAAc,CAAC,IAAI,CAAC,CA4BtB"}
@@ -0,0 +1,37 @@
1
+ import type { MessageTarget } from "./message-target.js";
2
+ /**
3
+ * Pass as `timeout` to install no deadline at all. Used by the stream tier,
4
+ * where the deadline belongs to the stream rather than to one chunk (spec D8):
5
+ * a slow consumer is throttled, never failed.
6
+ */
7
+ export declare const NO_TIMEOUT: number;
8
+ export interface CallPortOptions {
9
+ /**
10
+ * Timeout in ms after which the call rejects (default 1000). A value that is
11
+ * not a finite number greater than zero — {@link NO_TIMEOUT}, or 0 — installs
12
+ * no deadline, and the call then settles only on a reply, an abort, or the
13
+ * port's close signal.
14
+ */
15
+ timeout?: number;
16
+ /** Channel name filter — peers with a different `channelName` ignore the message. */
17
+ channelName?: string;
18
+ /** Logging function; defaults to a no-op. */
19
+ log?: (...args: unknown[]) => void;
20
+ /** Override the call ID generator (default: `call-<timestamp>-<random>`). */
21
+ newCallId?: () => string;
22
+ /**
23
+ * Optional cancellation signal. Firing it rejects the pending call
24
+ * immediately (without waiting for the timeout) and cleans up the message
25
+ * listener. Use the signal's `reason` for the rejection if present.
26
+ */
27
+ signal?: AbortSignal;
28
+ }
29
+ /**
30
+ * Asynchronous request/response over any `MessageTarget`.
31
+ *
32
+ * Sends `params` to the peer listening with `listenPort`, waits up to
33
+ * `timeout` ms for a matching reply, and either resolves with the result or
34
+ * rejects with the deserialised error.
35
+ */
36
+ export declare function callPort<TResult = unknown, TParams = unknown>(port: MessageTarget, params: TParams, { timeout, channelName, log, newCallId, signal, }?: CallPortOptions): Promise<TResult>;
37
+ //# sourceMappingURL=call-port.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"call-port.d.ts","sourceRoot":"","sources":["../src/call-port.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD;;;;GAIG;AACH,eAAO,MAAM,UAAU,QAA2B,CAAC;AAEnD,MAAM,WAAW,eAAe;IAC9B;;;;;OAKG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,qFAAqF;IACrF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6CAA6C;IAC7C,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,CAAC;IACnC,6EAA6E;IAC7E,SAAS,CAAC,EAAE,MAAM,MAAM,CAAC;IACzB;;;;OAIG;IACH,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAMD;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,OAAO,GAAG,OAAO,EAAE,OAAO,GAAG,OAAO,EAC3D,IAAI,EAAE,aAAa,EACnB,MAAM,EAAE,OAAO,EACf,EACE,OAAc,EACd,WAAgB,EAChB,GAAc,EACd,SAA4E,EAC5E,MAAM,GACP,GAAE,eAAoB,GACtB,OAAO,CAAC,OAAO,CAAC,CA+ClB"}
@@ -0,0 +1,15 @@
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
+ export declare const CANCEL_CHANNEL_TYPE = "cancel-channel";
13
+ export declare function postCancelChannel(port: MessageTarget, channelName: string): void;
14
+ export declare function listenCancelChannel(port: MessageTarget, channelName: string, onCancel: () => void): () => void;
15
+ //# sourceMappingURL=cancel-channel.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cancel-channel.d.ts","sourceRoot":"","sources":["../src/cancel-channel.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD,eAAO,MAAM,mBAAmB,mBAAmB,CAAC;AAOpD,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,GAAG,IAAI,CAOhF;AAED,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,aAAa,EACnB,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,MAAM,IAAI,GACnB,MAAM,IAAI,CASZ"}