@statewalker/webrun-rpc 0.4.2 → 0.5.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.
package/README.md CHANGED
@@ -1,90 +1,115 @@
1
1
  # @statewalker/webrun-rpc
2
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).
3
+ ## What it is
4
+
5
+ Ports and RPC over them. The package has four layers, all exported from the root: **port
6
+ multiplexers** (`multiplexPort`, `transferPortMux`) that turn one `MessageTarget` into many virtual
7
+ ones; a **stream tier** (`duplexOverPort` / `serveDuplexOverPort`) that runs one
8
+ `@statewalker/webrun-streams` `Duplex` over one port with backpressure built into the protocol; a
9
+ **`connect` / `serve` adapter** that plugs a source of ports into the `webrun-streams` `Connect` /
10
+ `Serve` shape; and a **typed-JSON RPC tier** (`callPort` / `listenPort`, `callBidi` / `listenBidi`,
11
+ `ioSend` / `ioHandle`, `send` / `recieve`) for request/response and iterator exchange over any
12
+ `MessageTarget`.
43
13
 
44
14
  ## Why it exists
45
15
 
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.
16
+ `MessagePort` is the browser's universal in-process seam: Workers, SharedWorkers, ServiceWorkers,
17
+ iframes, a `MessageChannel` between two modules in one tab. It is also the most primitive one.
18
+ `postMessage` fires and forgets. There is no request, no correlation, no backpressure, no
19
+ half-close, and an exception on the far side never arrives.
51
20
 
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.
21
+ - The **stream tier** makes a port behave like any other `webrun-streams` transport, so a back-end
22
+ running in a worker can be tested in-process and later moved behind a real socket unchanged.
23
+ - The **multiplexer** exists because one port is usually all you are handed. One `MessageChannel`
24
+ per logical stream does not scale, and over a WebSocket or a data channel there is no second
25
+ channel to be had.
26
+ - The **RPC tier** exists because much port traffic is not a byte stream at all. It is one typed
27
+ call with one typed answer, and a `Duplex` is more machinery than that needs. It types against
28
+ `MessageTarget`, not `MessagePort`, so it runs directly over a virtual port too.
61
29
 
62
- ## Install
30
+ ## How to use
63
31
 
64
32
  ```sh
65
- npm install @statewalker/webrun-rpc
33
+ pnpm add @statewalker/webrun-rpc
66
34
  ```
67
35
 
68
- No peer dependencies. `MessagePort` and `MessageChannel` are platform globals in
69
- browsers and in Node ≥ 15 (`node:worker_threads`).
36
+ No peer dependencies. ESM only. One entry point, `.`. Runs in browsers, workers and Node: the
37
+ examples use the global `MessageChannel`, which Node also provides. `transferPortMux` needs
38
+ structured clone with transferables, so it works only where real `MessagePort`s can be transferred
39
+ (browsers, workers, iframes, Node `worker_threads`).
70
40
 
71
- ## Getting started
41
+ ### Choosing a source of ports
72
42
 
73
- ### Connect and serve
43
+ `connect` and `serve` take a **port factory** (`PortMuxFactory`), not a port. They ask it for one
44
+ port per call and run one stream on it. How ports are made belongs to whoever knows the transport:
74
45
 
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.
46
+ | Transport | Factory | Id table? |
47
+ | --- | --- | --- |
48
+ | one pipe (`MessagePort`, worker, WebSocket, data channel) | `overPipe(pipe, { codec })` | yes: there is no second port to be had |
49
+ | a transferable boundary | `overPorts((onPort) => transferPortMux(target, { onPort }))` | no: the platform moves real ports |
50
+ | a transport that multiplexes already | `overPorts((onPort) => yourMux({ onPort }))` | no: the transport did the work |
78
51
 
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:
52
+ `codec` is `structuredCodec` (exported here) where messages are structured values, or
53
+ `msgpackCodec` from `@statewalker/webrun-msgpack` where they are bytes.
82
54
 
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 |
55
+ ### Exports
56
+
57
+ Stream tier and adapter:
58
+
59
+ | Export | Purpose |
60
+ | --- | --- |
61
+ | `connect(params: PortParams)` | Resolves `{ call, close }`. One port per call, opened on the consumer's first pull. |
62
+ | `serve(params: PortParams, handler)` | Registers a `Duplex` handler. Resolves an idempotent teardown that abandons live streams, then closes the mux. |
63
+ | `PortParams` | `{ mux: PortMuxFactory; timeout?: number }`. |
64
+ | `PortMuxFactory`, `OnPort` | `(onPort?: OnPort) => PortMux \| Promise<PortMux>`; `(port, meta?) => boolean \| undefined`, `false` rejects. |
65
+ | `overPipe(pipe, options: OverPipeOptions)` | A factory over one pipe via `multiplexPort`. Options: `codec`, `side`, `maxPorts`, `maxMessageSize`. |
66
+ | `overPorts(factory)` | Pass-through, for a mux this package knows nothing about. |
67
+ | `duplexOverPort(port, options?)` | A `Duplex` running one stream on `port`. |
68
+ | `serveDuplexOverPort(port, handler, options?)` | The serving half. Returns an idempotent teardown that abandons the stream and notifies the peer. |
69
+ | `DuplexOverPortOptions` | `maxMessageSize`, `timeout`, `log`. |
70
+ | `STREAM_ABORT` | `"webrun-rpc:stream-abort"`, the `type` of the abandonment notice. |
71
+
72
+ Port multiplexing:
73
+
74
+ | Export | Purpose |
75
+ | --- | --- |
76
+ | `multiplexPort(port, options: PortMuxOptions)` | Emulated multiplexing over one port. |
77
+ | `transferPortMux(target, options?: TransferPortMuxOptions)` | Multiplexing with real transferred `MessagePort`s. |
78
+ | `PortMux` | `openPort(meta?)`, `close()`, `maxMessageSize`. |
79
+ | `PortCodec`, `PortEnvelope` | The wire seam: `post(port, envelope, transfer?)`, `read(event)`. |
80
+ | `structuredCodec` | Envelopes passed through unencoded. |
81
+ | `DEFAULT_MAX_PORTS` | `1024`. |
82
+ | `PORT_TRANSFER` | `"webrun-rpc:port-transfer"`, the `type` of the message that carries a port. |
83
+
84
+ Typed-JSON RPC:
85
+
86
+ | Export | Purpose |
87
+ | --- | --- |
88
+ | `callPort(port, params, options?)` | One typed call, one typed answer. |
89
+ | `listenPort(port, handler, options?)` | Answers `callPort`. Returns an unsubscribe. |
90
+ | `callBidi(port, input, args?)` / `listenBidi(port, action, accept?)` | Streaming call: send an iterable, receive an async generator. |
91
+ | `ioSend(port, output, options?)` / `ioHandle(port, handler, options?)` | The full-duplex iterator exchange `callBidi` / `listenBidi` run on. |
92
+ | `send(port, output, options?)` / `recieve(port, options?)` | One `callPort` per iterator chunk, and its receiving side. |
93
+ | `NO_TIMEOUT` | `Number.POSITIVE_INFINITY`; as `callPort`'s `timeout`, installs no deadline. |
94
+ | `CallPortOptions` | `timeout` (default 1000 ms), `channelName`, `log`, `newCallId`, `signal`. |
95
+ | `CallBidiOptions`, `CallBidiArgs` | Adds `bidiTimeout` (default 2147483647 ms) for the outer call. |
96
+ | `ListenPortOptions`, `PortHandler`, `BidiHandler`, `IoSendOptions`, `RecieveOptions`, `SendOptions` | Supporting types. |
97
+ | `postCancelChannel(port, channelName)` / `listenCancelChannel(port, channelName, onCancel)` / `CANCEL_CHANNEL_TYPE` | The `"cancel-channel"` message `ioSend` posts when its consumer stops early. |
98
+ | `getPortCloseSignal(port)` / `setPortCloseSignal(port, signal)` | An `AbortSignal` per port that marks its transport closed; `callPort` combines it with its own `signal`. |
99
+
100
+ Message passing (types only): `MessageTarget` (`MessageSource` + `MessageSink` + optional
101
+ `close()`), `MessageSource` (`addEventListener` / `removeEventListener` for `"message"`, optional
102
+ `start()`), `MessageSink` (`postMessage(message, transfer?)`), `MessageListener`. A `MessagePort`
103
+ satisfies `MessageTarget` structurally.
104
+
105
+ `byteChannelFromMessagePort(port)` wraps a `MessageTarget` as a `webrun-streams` `ByteChannel`
106
+ (bytes out through `postMessage`, `Uint8Array` / `ArrayBuffer` / views in, everything else
107
+ ignored), for driving `webrun-streams`' `emulateMux` yourself. Nothing else in this package uses
108
+ it.
109
+
110
+ ## Examples
111
+
112
+ ### `connect` and `serve` over a `MessageChannel`
88
113
 
89
114
  ```ts
90
115
  import { connect, overPipe, serve, structuredCodec } from "@statewalker/webrun-rpc";
@@ -117,85 +142,12 @@ Across a Worker boundary, transfer one end and keep the other:
117
142
  const { port1, port2 } = new MessageChannel();
118
143
  worker.postMessage({ port: port2 }, [port2]);
119
144
  const { call } = await connect({ mux: overPipe(port1, { codec: structuredCodec }) });
145
+ // inside the worker: serve({ mux: overPipe(port, { codec: structuredCodec, side: "responder" }) }, handler)
120
146
  ```
121
147
 
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
148
+ ### A stream over a virtual port, wired by hand
178
149
 
179
150
  ```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
151
  import {
200
152
  duplexOverPort,
201
153
  multiplexPort,
@@ -204,8 +156,6 @@ import {
204
156
  } from "@statewalker/webrun-rpc";
205
157
 
206
158
  const channel = new MessageChannel();
207
- channel.port1.start();
208
- channel.port2.start();
209
159
 
210
160
  // The serving end: every stream port the peer opens runs one echo handler.
211
161
  const server = multiplexPort(channel.port2, {
@@ -219,15 +169,11 @@ const server = multiplexPort(channel.port2, {
219
169
  });
220
170
 
221
171
  // The calling end: one port per stream.
222
- const client = multiplexPort(channel.port1, {
223
- codec: structuredCodec,
224
- side: "initiator",
225
- });
226
-
172
+ const client = multiplexPort(channel.port1, { codec: structuredCodec, side: "initiator" });
227
173
  const streamPort = await client.openPort({ kind: "stream" });
228
174
  const call = duplexOverPort(streamPort, { maxMessageSize: client.maxMessageSize });
229
175
 
230
- const parts = [];
176
+ const parts: string[] = [];
231
177
  for await (const chunk of call([new TextEncoder().encode("ping")])) {
232
178
  parts.push(new TextDecoder().decode(chunk));
233
179
  }
@@ -237,313 +183,282 @@ await client.close();
237
183
  await server.close();
238
184
  ```
239
185
 
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`.
186
+ ### Real transferred ports
316
187
 
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. |
188
+ ```ts
189
+ import { duplexOverPort, serveDuplexOverPort, transferPortMux } from "@statewalker/webrun-rpc";
366
190
 
367
- ## Port multiplexing
191
+ const { port1, port2 } = new MessageChannel();
368
192
 
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.
193
+ const server = transferPortMux(port2, {
194
+ onPort: (port, meta) => {
195
+ // `meta` is `unknown`: the mux never inspects it, so you narrow it.
196
+ if ((meta as { kind?: string } | undefined)?.kind !== "stream") return false; // reject
197
+ serveDuplexOverPort(port, async function* (input) {
198
+ for await (const chunk of input) yield chunk;
199
+ });
200
+ },
201
+ });
373
202
 
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.
203
+ const client = transferPortMux(port1);
204
+ const port = await client.openPort({ kind: "stream" });
205
+ for await (const chunk of duplexOverPort(port)([new Uint8Array([1, 2, 3])])) {
206
+ console.log(chunk); // Uint8Array [1, 2, 3]
207
+ }
379
208
 
380
- ### `multiplexPort(port, options): PortMux`
209
+ await client.close();
210
+ await server.close();
211
+ ```
381
212
 
382
- The default implementation, which emulates multiplexing over a single port. A
383
- transport that already multiplexes natively supplies its own `PortMux` instead.
213
+ ### One typed call
384
214
 
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). |
215
+ ```ts
216
+ import { callPort, listenPort } from "@statewalker/webrun-rpc";
392
217
 
393
- ### `PortMux`
218
+ const { port1, port2 } = new MessageChannel();
219
+ port1.start();
220
+ port2.start();
394
221
 
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. |
222
+ const off = listenPort<{ a: number; b: number }, { sum: number }>(port2, async ({ a, b }) => ({
223
+ sum: a + b,
224
+ }));
400
225
 
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.
226
+ const { sum } = await callPort<{ sum: number }>(port1, { a: 2, b: 3 }, { timeout: 2000 });
227
+ console.log(sum); // 5
407
228
 
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.
229
+ off();
230
+ ```
414
231
 
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.
232
+ A handler that throws rejects the caller with the deserialised error. A call with no reply rejects
233
+ after `timeout` with `Call timeout. CallId: "<id>".`
419
234
 
420
- ### `structuredCodec`
235
+ ### A streaming call
421
236
 
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.
237
+ ```ts
238
+ import { callBidi, listenBidi } from "@statewalker/webrun-rpc";
425
239
 
426
- A byte transport needs a codec that encodes; that one ships with
427
- `@statewalker/webrun-msgpack`, so this package keeps no dependencies.
240
+ const { port1, port2 } = new MessageChannel();
241
+ port1.start();
242
+ port2.start();
428
243
 
429
- ### `PortEnvelope`
244
+ const off = listenBidi<number, number>(port2, async function* (input, params) {
245
+ for await (const n of input) yield n * (params.factor as number);
246
+ });
430
247
 
431
- What crosses the wire: `{ type: "open", id, meta? }`, `{ type: "message", id,
432
- payload }`, `{ type: "close", id, reason? }`.
248
+ for await (const n of callBidi<number, number>(port1, [1, 2, 3], { factor: 10 })) {
249
+ console.log(n); // 10, 20, 30
250
+ }
433
251
 
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.
252
+ off();
253
+ ```
438
254
 
439
- ## Transferring ports
255
+ `callBidi`'s third argument is the parameter bag the handler receives; `options` inside it is
256
+ reserved for `CallBidiOptions`. The optional `accept(params)` predicate on `listenBidi` declines
257
+ calls.
440
258
 
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.
259
+ ## Internals
447
260
 
448
- ```ts
449
- import { transferPortMux } from "@statewalker/webrun-rpc";
261
+ ### Layers
450
262
 
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
- });
263
+ ```
264
+ your code ── Connect / Serve ─────────────────────────────┐
265
+ │ connect / serve │
266
+ stream tier ──────┤ duplexOverPort / serveDuplexOverPort │ layer 2
267
+ │ one callPort per chunk, "in" / "out" │
268
+ RPC tier ─────────┘ callPort / listenPort ┘
269
+ │ MessageTarget (a virtual port)
270
+ port mux ──────── multiplexPort (id table + PortCodec) ┐ layer 1
271
+ or transferPortMux (real MessagePorts) ┘
272
+ │ MessageTarget
273
+ transport ─────── MessagePort, worker, WebSocket, data channel, ...
458
274
  ```
459
275
 
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>`. |
276
+ Each virtual port is itself a `MessageTarget`, so a multiplexer composes over another
277
+ multiplexer's port, and the RPC tier runs over a virtual port with no byte-stream layer between.
502
278
 
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.
279
+ ### A port sends and receives messages, nothing more
506
280
 
507
- ## Conformance
281
+ `multiplexPort` has no backpressure, acknowledgements, credit or buffer ceiling. That matches
282
+ `MessagePort` semantics; waiting strategies belong above. The one safety property it holds: **a
283
+ message for a port with no consumer is dropped, never queued**, so a peer flooding an unaccepted
284
+ port cannot grow memory here.
508
285
 
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:
286
+ `multiplexPort` options:
512
287
 
513
- | run | stack |
288
+ | option | meaning |
514
289
  | --- | --- |
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.
290
+ | `codec` | Required. How envelopes reach the wire. |
291
+ | `onPort` | Called when the peer opens a port; return `false` to reject. A throw also rejects. **Without it, inbound ports are rejected**: a port nobody holds has no consumer. |
292
+ | `side` | `"initiator"` (default) allocates even ids, `"responder"` odd, so both ends can open concurrently with no negotiation. The two ends of one pipe must disagree. |
293
+ | `maxPorts` | Ceiling on concurrently open ports, default `DEFAULT_MAX_PORTS` (1024). Closing a port frees its slot. It bounds the id table, never the total over time, and never delays a message. A peer `open` past the limit is answered with `close` reason `"max-ports"`. |
294
+ | `maxMessageSize` | Largest payload a chunk may carry, if the transport has a limit. Reported to the stream tier, never enforced. See below. |
295
+
296
+ `openPort(meta?)` allocates a port, announces it and returns the local end without waiting for the
297
+ peer to accept. That keeps layer 1 free of round trips: messages posted before a rejection are
298
+ dropped at the far end and the port goes inert. It is asynchronous because a natively multiplexed
299
+ transport cannot produce a port synchronously and both must share one shape. Its guard failures
300
+ are rejections, not synchronous throws: `RangeError: webrun-rpc: maxPorts (<n>) reached` and
301
+ `Error: webrun-rpc: the multiplexer is closed`. A duplicate `open` id from the peer is ignored
302
+ rather than replacing a live port, and a local `openPort` skips ids the peer already claimed with
303
+ the wrong parity.
304
+
305
+ `PortEnvelope` is `{ type: "open", id, meta? }`, `{ type: "message", id, payload }` or
306
+ `{ type: "close", id, reason? }`. `structuredCodec.read` accepts only objects with a non-negative
307
+ integer `id` and one of those three types, so other traffic on a shared port is ignored.
308
+ `structuredCodec` passes envelopes through unencoded, so `ArrayBuffer`s move zero-copy through the
309
+ transfer list; it omits an empty transfer list because some implementations reject one.
310
+
311
+ ### No close is observable at layer 1
312
+
313
+ `MessageTarget` has no close event and a virtual port exposes no state. When a port closes, its
314
+ listeners are cleared and `postMessage` becomes a no-op. There is no event, no error, and the
315
+ `close` envelope's `reason` is discarded rather than delivered. A closed port is indistinguishable
316
+ from a working one that nobody answers. A real `MessagePort` behaves the same way, so **the layer
317
+ above carries its own end-of-stream signal** as an ordinary message.
318
+
319
+ ### Why `connect` / `serve` take a factory and not a `PortMux`
320
+
321
+ Every mux decides accept-or-reject synchronously, inside the `open` envelope, and tells the peer
322
+ on the spot. A mux handed over already built would have a window between its construction and its
323
+ handler being attached, and a port arriving in that window is rejected outright: measured, zero
324
+ messages delivered and the peer told `rejected`. A factory closes the window by construction
325
+ (`tests/port-factory-no-race.test.ts`). `connect` calls the factory with no handler, which is how a
326
+ caller declines inbound ports. Whichever side called the factory owns the mux and closes it.
327
+
328
+ `connect` opens the port on the consumer's first pull, not when `call` is invoked, so a stream
329
+ that is built and dropped costs the peer nothing. Every port it opens carries `meta`
330
+ `{ kind: "stream" }`, so a peer running other things over the same mux can tell stream ports apart.
331
+
332
+ ### Flow control is a window of one chunk
333
+
334
+ `duplexOverPort` sends each direction as one `callPort` per chunk on its own channel (`"in"` for
335
+ the caller's input, `"out"` for the handler's output). The reply to a chunk call is the
336
+ confirmation that the consumer pulled past it, and the next chunk is not sent until then. So **one
337
+ open port holds at most one chunk** in each direction, and there is no buffer size to tune. Over
338
+ `multiplexPort` that bounds the whole mux at `maxPorts × one chunk`. `transferPortMux` has no
339
+ `maxPorts`, so bounding how many ports you open is up to you.
340
+ `tests/connect-serve-backpressure.test.ts` measures it: a 5000-chunk producer against a handler
341
+ that reads one chunk and stops gets exactly one chunk out.
342
+
343
+ A peer that sends a second chunk before the first is confirmed gets `response:error` with
344
+ `webrun-rpc: peer sent a second unconfirmed chunk; the stream port is closed`, and that port is
345
+ closed. Other ports on the mux are untouched.
346
+
347
+ The cost: single-stream throughput is `chunk ÷ RTT`. In-process that is negligible; over a 50 ms
348
+ round trip, a 10 MiB body in 12 KiB chunks is 854 sequential round trips, about 43 s. Concurrency
349
+ comes from running many streams, one port each, not from pipelining one.
350
+
351
+ **One stream per port.** Nothing enforces it: invoking the same `duplexOverPort` result twice on
352
+ one port makes both invocations cross-talk on the same two channel names. Open a port per call.
353
+
354
+ ### `maxMessageSize` bounds the payload, not the frame
355
+
356
+ The stream tier splits payloads to `maxMessageSize` with `toChunks`; the chunk wrapper, the
357
+ `callPort` request, the mux envelope and the codec are added on top. Over `msgpackCodec` that
358
+ framing measured 123–128 bytes (modelled ceiling 134), varying per chunk with the call id's
359
+ length. **Set it at least 256 bytes below the transport's real limit.** Set to the limit exactly,
360
+ a full-size chunk overruns it; a 12 KiB setting produced 12,413-byte frames, and on LiveKit an
361
+ oversized message delivered a body as zero bytes with no error on either side. A transport with no
362
+ ceiling leaves it unset, and nothing is split.
363
+
364
+ ### There is no timeout by default
365
+
366
+ A per-chunk deadline fails a consumer that is merely slow, so the stream tier calls `callPort`
367
+ with `NO_TIMEOUT` for every chunk. The `timeout` option (on `duplexOverPort`, `serveDuplexOverPort`
368
+ and `connect` / `serve`) is an inactivity timeout for the whole stream: any chunk in either
369
+ direction resets it, and elapsing aborts the stream with `webrun-rpc: stream idle for <n> ms`.
370
+ Unset, zero or non-finite means none.
371
+
372
+ Know what you buy if you set it. The clock resets only when a chunk call returns, and that reply
373
+ is withheld until the consumer has pulled past the value. The clock cannot tell "slow" from "dead":
374
+ a consumer slower than the timeout **is** failed.
375
+
376
+ ### How an abandoned stream is reported
377
+
378
+ Because close is invisible at layer 1, a side that abandons a stream posts an out-of-band
379
+ `{ type: STREAM_ABORT }` message on the port. It is posted from exactly two places: the teardown
380
+ returned by `serveDuplexOverPort` (and so `serve`'s teardown), and the caller's `finally` as its
381
+ generator unwinds.
382
+
383
+ | what ends the stream | notice posted? | what the peer observes |
384
+ | --- | --- | --- |
385
+ | the caller stops iterating (`break`, `return`, `throw`) | yes | `STREAM_ABORT`; the handler unwinds through `iter.return()` |
386
+ | the caller's `timeout` elapses | yes, from the caller's `finally` | `STREAM_ABORT` |
387
+ | the serve-side teardown is called | yes | the caller's stream rejects with `webrun-rpc: the peer abandoned the stream` |
388
+ | the serve side's `timeout` elapses | **no** | nothing on `"out"`: a caller waiting there waits forever. A caller still sending gets `webrun-rpc: the stream is closed` on its chunk calls, but `duplexOverPort` surfaces only the inbound half, so its consumer never sees it |
389
+ | a window violation, on either side | **no** | the offender gets the `response:error` above; the enforcer closes the port, which the other half cannot see, so a peer waiting on the other half waits forever |
390
+
391
+ The notice makes cooperative abandonment observable. It is not a liveness mechanism: **give the
392
+ side that must not hang its own `timeout`.** A caller with a `timeout` detects both silent rows; a
393
+ caller without one does not.
394
+
395
+ An abort unwinds a producing handler through `iter.return()`, not by throwing into it. A handler's
396
+ `catch` never sees the abort reason; only `finally` runs. Put cleanup in `finally`.
397
+
398
+ ### `transferPortMux` moves real ports
399
+
400
+ Each `openPort` creates a `MessageChannel`, posts `{ type: PORT_TRANSFER, meta }` with one end in
401
+ the transfer list, and returns the other end. There is no id table, no `maxPorts` and no
402
+ per-message envelope, because the platform does the multiplexing. A transferred port can cross an
403
+ origin or worker boundary and be handed to code that never saw `target`, which an emulated port id
404
+ cannot. A `PORT_TRANSFER` message without an attached port is dropped. `onPort` returning `false`
405
+ (or throwing) closes the port; no `onPort` rejects every inbound port. The caller picks this mux
406
+ explicitly; nothing sniffs capabilities. `target` must be a full `MessageTarget`; a send-only
407
+ `MessageSink` such as a `ServiceWorkerClient` is not supported.
408
+
409
+ **The issued-port set never shrinks.** Every port opened or accepted is kept until `close()`, and
410
+ nothing removes a port when it closes. Bounding it needs a per-port `close` event listener, a newer
411
+ platform surface this implementation avoids. Nothing fails; the set just grows. Scope the mux to the
412
+ lifetime of the thing it multiplexes — a worker, an iframe, a connection — not to the process.
413
+
414
+ ### The typed-JSON RPC tier on the wire
415
+
416
+ `callPort` posts `{ type: "request", channelName, callId, params }` and waits for
417
+ `{ type: "response:result" | "response:error", channelName, callId, ... }`. `channelName`
418
+ (default `""`) lets several conversations share one port; `callId` defaults to
419
+ `` `call-${Date.now()}-${random}` ``. The default `timeout` is 1000 ms; any value that is not a
420
+ finite number above zero installs no deadline. Errors cross as `webrun-streams`' serialised form
421
+ (`message`, `stack`, and every own enumerable property) and are rebuilt as `Error`s.
422
+
423
+ `callPort` rejects early when its `signal` aborts, or when the port's close signal fires.
424
+ `getPortCloseSignal(port)` returns the `AbortSignal` registered with `setPortCloseSignal`, or
425
+ `undefined`. A raw `MessagePort` never reports its peer closing, so an adapter that does know when
426
+ its transport closed can register a signal and pending calls reject instead of waiting for their
427
+ timeout. Nothing in this package registers one.
428
+
429
+ `send` sends an iterable as one `callPort` per chunk and ends with `{ done: true }`; `recieve`
430
+ yields one async generator per inbound stream and never ends on its own. `ioSend` / `ioHandle`
431
+ pair them in both directions, and `ioSend` posts `{ type: "cancel-channel", channelName }` when
432
+ its consumer stops early so the producer stops without waiting for timeouts. `callBidi` picks a
433
+ random sub-channel, announces it with an outer `callPort` (timeout `bidiTimeout`), and runs
434
+ `ioSend` on it; if the outer call rejects, the inner stream is cancelled and the error rethrown.
435
+
436
+ ### Conformance
437
+
438
+ The unmodified L0–L6 suite of `@statewalker/webrun-streams-conformance` runs against two entry
439
+ points into the same stack: `connect` / `serve` over a `MessageChannel` pair
440
+ (`tests/conformance.test.ts`) and `multiplexPort` + `duplexOverPort` wired by hand
441
+ (`tests/conformance-new-stack.test.ts`).
519
442
 
520
443
  ```sh
521
444
  pnpm --filter @statewalker/webrun-rpc test
522
445
  ```
523
446
 
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`. |
447
+ L6 does not prove flow control on either run. The suite's `PairTuning` is a credit window (`mtu`,
448
+ `maxStreamBuffer`), and this stack has none to size. The `connect` / `serve` run maps `mtu` to
449
+ `maxMessageSize`, so its 256 KiB body crosses as 64 frames; the hand-wired run ignores tuning, so
450
+ its body crosses as one chunk each way. Bounded memory is measured in
451
+ `tests/connect-serve-backpressure.test.ts`, `tests/backpressure-both-directions.test.ts`,
452
+ `tests/duplex-over-port-timeout.test.ts` and `tests/duplex-over-port-hostile.test.ts`.
453
+
454
+ ### Dependencies
543
455
 
544
- No runtime dependencies outside the workspace, no peer dependencies. ESM only
545
- (`"type": "module"`).
456
+ One runtime dependency, `@statewalker/webrun-streams`: the `Duplex` / `Connect` / `Serve` types,
457
+ the iterator primitives under the stream and RPC tiers (`sendIterator`, `recieveIterator`,
458
+ `toChunks`), error serialisation, and the `ByteChannel` type for `byteChannelFromMessagePort`. No
459
+ peer dependencies. The byte-transport codec lives in `@statewalker/webrun-msgpack`, so this package
460
+ stays free of an encoder.
546
461
 
547
462
  ## License
548
463
 
549
- MIT © statewalker — see [LICENSE](../../LICENSE).
464
+ MIT