@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 +341 -426
- package/dist/duplex-over-port.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +61 -24
- package/dist/multiplex-port.d.ts +5 -0
- package/dist/multiplex-port.d.ts.map +1 -1
- package/dist/virtual-port.d.ts +6 -2
- package/dist/virtual-port.d.ts.map +1 -1
- package/package.json +8 -5
- package/src/duplex-over-port.ts +27 -0
- package/src/index.ts +1 -1
- package/src/multiplex-port.ts +9 -3
- package/src/virtual-port.ts +21 -3
package/README.md
CHANGED
|
@@ -1,90 +1,115 @@
|
|
|
1
1
|
# @statewalker/webrun-rpc
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
##
|
|
30
|
+
## How to use
|
|
63
31
|
|
|
64
32
|
```sh
|
|
65
|
-
|
|
33
|
+
pnpm add @statewalker/webrun-rpc
|
|
66
34
|
```
|
|
67
35
|
|
|
68
|
-
No peer dependencies.
|
|
69
|
-
|
|
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
|
-
|
|
41
|
+
### Choosing a source of ports
|
|
72
42
|
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
`
|
|
80
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
|
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
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
-
|
|
191
|
+
const { port1, port2 } = new MessageChannel();
|
|
368
192
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
209
|
+
await client.close();
|
|
210
|
+
await server.close();
|
|
211
|
+
```
|
|
381
212
|
|
|
382
|
-
|
|
383
|
-
transport that already multiplexes natively supplies its own `PortMux` instead.
|
|
213
|
+
### One typed call
|
|
384
214
|
|
|
385
|
-
|
|
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
|
-
|
|
218
|
+
const { port1, port2 } = new MessageChannel();
|
|
219
|
+
port1.start();
|
|
220
|
+
port2.start();
|
|
394
221
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
402
|
-
|
|
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
|
-
|
|
409
|
-
|
|
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
|
-
|
|
416
|
-
|
|
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
|
-
###
|
|
235
|
+
### A streaming call
|
|
421
236
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
transfer list.
|
|
237
|
+
```ts
|
|
238
|
+
import { callBidi, listenBidi } from "@statewalker/webrun-rpc";
|
|
425
239
|
|
|
426
|
-
|
|
427
|
-
|
|
240
|
+
const { port1, port2 } = new MessageChannel();
|
|
241
|
+
port1.start();
|
|
242
|
+
port2.start();
|
|
428
243
|
|
|
429
|
-
|
|
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
|
-
|
|
432
|
-
|
|
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
|
-
|
|
435
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
449
|
-
import { transferPortMux } from "@statewalker/webrun-rpc";
|
|
261
|
+
### Layers
|
|
450
262
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
|
288
|
+
| option | meaning |
|
|
514
289
|
| --- | --- |
|
|
515
|
-
| `
|
|
516
|
-
| `
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
`
|
|
527
|
-
|
|
528
|
-
`
|
|
529
|
-
`
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
545
|
-
(`
|
|
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
|
|
464
|
+
MIT
|