@statewalker/webrun-rpc 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +549 -0
- package/dist/byte-channel.d.ts +15 -0
- package/dist/byte-channel.d.ts.map +1 -0
- package/dist/call-bidi.d.ts +25 -0
- package/dist/call-bidi.d.ts.map +1 -0
- package/dist/call-port.d.ts +37 -0
- package/dist/call-port.d.ts.map +1 -0
- package/dist/cancel-channel.d.ts +15 -0
- package/dist/cancel-channel.d.ts.map +1 -0
- package/dist/close-signal.d.ts +36 -0
- package/dist/close-signal.d.ts.map +1 -0
- package/dist/connect-serve.d.ts +104 -0
- package/dist/connect-serve.d.ts.map +1 -0
- package/dist/duplex-over-port.d.ts +49 -0
- package/dist/duplex-over-port.d.ts.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1180 -0
- package/dist/io-handle.d.ts +17 -0
- package/dist/io-handle.d.ts.map +1 -0
- package/dist/io-send.d.ts +26 -0
- package/dist/io-send.d.ts.map +1 -0
- package/dist/listen-bidi.d.ts +11 -0
- package/dist/listen-bidi.d.ts.map +1 -0
- package/dist/listen-port.d.ts +16 -0
- package/dist/listen-port.d.ts.map +1 -0
- package/dist/message-target.d.ts +16 -0
- package/dist/message-target.d.ts.map +1 -0
- package/dist/multiplex-port.d.ts +12 -0
- package/dist/multiplex-port.d.ts.map +1 -0
- package/dist/port-types.d.ts +86 -0
- package/dist/port-types.d.ts.map +1 -0
- package/dist/recieve.d.ts +35 -0
- package/dist/recieve.d.ts.map +1 -0
- package/dist/send.d.ts +23 -0
- package/dist/send.d.ts.map +1 -0
- package/dist/structured-codec.d.ts +12 -0
- package/dist/structured-codec.d.ts.map +1 -0
- package/dist/through-abort.d.ts +8 -0
- package/dist/through-abort.d.ts.map +1 -0
- package/dist/transfer-port-mux.d.ts +39 -0
- package/dist/transfer-port-mux.d.ts.map +1 -0
- package/dist/virtual-port.d.ts +19 -0
- package/dist/virtual-port.d.ts.map +1 -0
- package/package.json +51 -0
- package/src/byte-channel.ts +109 -0
- package/src/call-bidi.ts +60 -0
- package/src/call-port.ts +119 -0
- package/src/cancel-channel.ts +42 -0
- package/src/close-signal.ts +43 -0
- package/src/connect-serve.ts +208 -0
- package/src/duplex-over-port.ts +471 -0
- package/src/index.ts +29 -0
- package/src/io-handle.ts +40 -0
- package/src/io-send.ts +70 -0
- package/src/listen-bidi.ts +31 -0
- package/src/listen-port.ts +47 -0
- package/src/message-target.ts +18 -0
- package/src/multiplex-port.ts +134 -0
- package/src/port-types.ts +80 -0
- package/src/recieve.ts +89 -0
- package/src/send.ts +60 -0
- package/src/structured-codec.ts +30 -0
- package/src/through-abort.ts +32 -0
- package/src/transfer-port-mux.ts +106 -0
- package/src/virtual-port.ts +71 -0
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ChunkReceiver,
|
|
3
|
+
type Duplex,
|
|
4
|
+
deserializeError,
|
|
5
|
+
recieveIterator,
|
|
6
|
+
type SerializedError,
|
|
7
|
+
sendIterator,
|
|
8
|
+
serializeError,
|
|
9
|
+
toChunks,
|
|
10
|
+
} from "@statewalker/webrun-streams";
|
|
11
|
+
import { callPort, NO_TIMEOUT } from "./call-port.js";
|
|
12
|
+
import { listenPort } from "./listen-port.js";
|
|
13
|
+
import type { MessageTarget } from "./message-target.js";
|
|
14
|
+
import { throughAbort } from "./through-abort.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The `type` of the out-of-band notice a side posts when it abandons a stream.
|
|
18
|
+
*
|
|
19
|
+
* Layer 1's `close` is not observable to layer 2 — a closed virtual port drops
|
|
20
|
+
* its listeners silently and is indistinguishable from a working port nobody
|
|
21
|
+
* is answering — so the peer would otherwise wait forever. Exported because
|
|
22
|
+
* tests and adapters assert on it.
|
|
23
|
+
*/
|
|
24
|
+
export const STREAM_ABORT = "webrun-rpc:stream-abort";
|
|
25
|
+
|
|
26
|
+
/** Caller's input travels on this channel; the handler listens on it. */
|
|
27
|
+
const CHANNEL_IN = "in";
|
|
28
|
+
/** The handler's output travels on this channel; the caller listens on it. */
|
|
29
|
+
const CHANNEL_OUT = "out";
|
|
30
|
+
|
|
31
|
+
/** One chunk on the wire: `IteratorChunk<Uint8Array>` with the error serialised. */
|
|
32
|
+
interface WireChunk {
|
|
33
|
+
done: boolean;
|
|
34
|
+
value?: Uint8Array;
|
|
35
|
+
error?: SerializedError;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface DuplexOverPortOptions {
|
|
39
|
+
/**
|
|
40
|
+
* Largest payload one chunk may carry, from `PortMux.maxMessageSize` (spec
|
|
41
|
+
* D10). Bodies are split to fit with `toChunks`. Unset means no limit and no
|
|
42
|
+
* splitting.
|
|
43
|
+
*/
|
|
44
|
+
maxMessageSize?: number;
|
|
45
|
+
/**
|
|
46
|
+
* Inactivity timeout for the whole stream, in ms: the clock is reset by any
|
|
47
|
+
* chunk in either direction, and elapsing aborts the stream. Unset — the
|
|
48
|
+
* default — means no timeout at all (spec D8): a slow consumer is throttled,
|
|
49
|
+
* never failed. Any finite default would reintroduce F5 at a different
|
|
50
|
+
* threshold.
|
|
51
|
+
*/
|
|
52
|
+
timeout?: number;
|
|
53
|
+
/** Logging function; defaults to a no-op. */
|
|
54
|
+
log?: (...args: unknown[]) => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* One port in, one `Duplex` out (spec D9).
|
|
59
|
+
*
|
|
60
|
+
* The returned `Duplex` runs a single stream on `port`: the caller's `input`
|
|
61
|
+
* is sent chunk by chunk with `callPort`, and the handler's output arrives the
|
|
62
|
+
* same way on the other channel. Within each direction the next chunk is never
|
|
63
|
+
* sent until the previous one has been delivered *and* pulled past by the
|
|
64
|
+
* consumer (spec D11) — the reply to a chunk call *is* the confirmation, and
|
|
65
|
+
* `listenPort` withholds it until then.
|
|
66
|
+
*
|
|
67
|
+
* A stream port carries exactly one invocation. To make several calls, open
|
|
68
|
+
* several ports: `mux.openPort({ kind: "stream" })` per call.
|
|
69
|
+
*/
|
|
70
|
+
export function duplexOverPort(port: MessageTarget, options: DuplexOverPortOptions = {}): Duplex {
|
|
71
|
+
return (input) => runCallerSide(port, input, options);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Installs `handler` as the serving side of one stream on `port`. Returns an
|
|
76
|
+
* idempotent teardown that abandons the stream and notifies the peer.
|
|
77
|
+
*/
|
|
78
|
+
export function serveDuplexOverPort(
|
|
79
|
+
port: MessageTarget,
|
|
80
|
+
handler: Duplex,
|
|
81
|
+
options: DuplexOverPortOptions = {},
|
|
82
|
+
): () => void {
|
|
83
|
+
const controller = new AbortController();
|
|
84
|
+
const notice = installAbortNotice(port, controller);
|
|
85
|
+
const clock = installStreamTimeout(controller, options.timeout);
|
|
86
|
+
const inbound = receiveChunks(port, CHANNEL_IN, controller, clock.touch);
|
|
87
|
+
let output: AsyncGenerator<Uint8Array>;
|
|
88
|
+
try {
|
|
89
|
+
output = handler(inbound.stream);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
// A handler that throws before returning a generator still owes the peer
|
|
92
|
+
// an end-of-stream, or its `callPort` never settles.
|
|
93
|
+
const pump = sendChunks(
|
|
94
|
+
port,
|
|
95
|
+
CHANNEL_OUT,
|
|
96
|
+
failing(err),
|
|
97
|
+
options,
|
|
98
|
+
controller.signal,
|
|
99
|
+
clock.touch,
|
|
100
|
+
);
|
|
101
|
+
void pump.catch(() => {});
|
|
102
|
+
disarmClockWhenBothSidesSettle(clock, pump, inbound.ended);
|
|
103
|
+
return teardownOnce(controller, notice, clock, inbound, undefined);
|
|
104
|
+
}
|
|
105
|
+
const pump = sendChunks(port, CHANNEL_OUT, output, options, controller.signal, clock.touch);
|
|
106
|
+
void pump.catch(() => {
|
|
107
|
+
// Reported to the peer inside sendChunks; nothing to surface locally.
|
|
108
|
+
});
|
|
109
|
+
disarmClockWhenBothSidesSettle(clock, pump, inbound.ended);
|
|
110
|
+
return teardownOnce(controller, notice, clock, inbound, output);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A stream that completes normally has nobody left to call the returned
|
|
115
|
+
* teardown — the caller only knows the stream ended, not that it must also
|
|
116
|
+
* dispose the serve-side handle. Without this, the last `touch()`'s timer
|
|
117
|
+
* stays armed for up to `timeout` ms after real completion, doing nothing
|
|
118
|
+
* but holding the event loop open.
|
|
119
|
+
*
|
|
120
|
+
* Only disarms once BOTH `pump` (our output) and `inboundEnded` (the peer's
|
|
121
|
+
* declared end of input, or an abort) have settled. Disarming on `pump`
|
|
122
|
+
* alone is not safe: under half-close the handler can finish producing
|
|
123
|
+
* output while the caller is still sending input, and a clock stopped then
|
|
124
|
+
* would leave that still-open half with no deadline at all — worse than the
|
|
125
|
+
* leak this fixes.
|
|
126
|
+
*/
|
|
127
|
+
function disarmClockWhenBothSidesSettle(
|
|
128
|
+
clock: { stop(): void },
|
|
129
|
+
pump: Promise<void>,
|
|
130
|
+
inboundEnded: Promise<void>,
|
|
131
|
+
): void {
|
|
132
|
+
void Promise.allSettled([pump, inboundEnded]).then(() => {
|
|
133
|
+
clock.stop();
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function* failing(err: unknown): AsyncGenerator<Uint8Array> {
|
|
138
|
+
if ((0 as number) === 0) throw err;
|
|
139
|
+
yield new Uint8Array(0);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function teardownOnce(
|
|
143
|
+
controller: AbortController,
|
|
144
|
+
notice: { post(): void; stop(): void },
|
|
145
|
+
clock: { touch(): void; stop(): void },
|
|
146
|
+
inbound: { stop(): void },
|
|
147
|
+
output: AsyncGenerator<Uint8Array> | undefined,
|
|
148
|
+
): () => void {
|
|
149
|
+
let torn = false;
|
|
150
|
+
return () => {
|
|
151
|
+
if (torn) return;
|
|
152
|
+
torn = true;
|
|
153
|
+
if (!controller.signal.aborted) controller.abort(new Error("webrun-rpc: stream torn down"));
|
|
154
|
+
notice.post();
|
|
155
|
+
notice.stop();
|
|
156
|
+
clock.stop();
|
|
157
|
+
inbound.stop();
|
|
158
|
+
void output?.return(undefined as never).catch(() => {});
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function runCallerSide(
|
|
163
|
+
port: MessageTarget,
|
|
164
|
+
input: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
|
|
165
|
+
options: DuplexOverPortOptions,
|
|
166
|
+
): AsyncGenerator<Uint8Array> {
|
|
167
|
+
const controller = new AbortController();
|
|
168
|
+
const notice = installAbortNotice(port, controller);
|
|
169
|
+
const clock = installStreamTimeout(controller, options.timeout);
|
|
170
|
+
const inbound = receiveChunks(port, CHANNEL_OUT, controller, clock.touch);
|
|
171
|
+
const pump = sendChunks(port, CHANNEL_IN, input, options, controller.signal, clock.touch);
|
|
172
|
+
void pump.catch(() => {
|
|
173
|
+
// The outbound half's failure surfaces to the peer, not to this consumer:
|
|
174
|
+
// the consumer's contract is the inbound half.
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return (async function* () {
|
|
178
|
+
try {
|
|
179
|
+
yield* inbound.stream;
|
|
180
|
+
} finally {
|
|
181
|
+
// Abort BEFORE awaiting the pump. `callPort` runs with NO_TIMEOUT here,
|
|
182
|
+
// so an un-aborted in-flight chunk call would never settle and this
|
|
183
|
+
// `finally` would deadlock — which is exactly the defect found in the
|
|
184
|
+
// -webrtc adapter, where the outbound half was awaited unconditionally.
|
|
185
|
+
if (!controller.signal.aborted) {
|
|
186
|
+
controller.abort(new Error("webrun-rpc: the caller abandoned the stream"));
|
|
187
|
+
}
|
|
188
|
+
notice.post();
|
|
189
|
+
notice.stop();
|
|
190
|
+
clock.stop();
|
|
191
|
+
inbound.stop();
|
|
192
|
+
await pump.catch(() => {});
|
|
193
|
+
try {
|
|
194
|
+
await port.close?.();
|
|
195
|
+
} catch {
|
|
196
|
+
/* the port may already be gone; nothing to unwind */
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
})();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Listens for the peer's abort notice, and can post our own. The notice is a
|
|
204
|
+
* plain message with no `channelName` and a `type` that is not `"request"`,
|
|
205
|
+
* so neither `callPort` nor `listenPort` reacts to it, and it carries no
|
|
206
|
+
* numeric `id`, so `structuredCodec` never mistakes it for a layer 1 envelope.
|
|
207
|
+
*/
|
|
208
|
+
function installAbortNotice(
|
|
209
|
+
port: MessageTarget,
|
|
210
|
+
controller: AbortController,
|
|
211
|
+
): { post(): void; stop(): void } {
|
|
212
|
+
const onMessage = (event: MessageEvent) => {
|
|
213
|
+
const data = event.data as { type?: unknown } | undefined;
|
|
214
|
+
if (!data || data.type !== STREAM_ABORT) return;
|
|
215
|
+
if (!controller.signal.aborted) {
|
|
216
|
+
controller.abort(new Error("webrun-rpc: the peer abandoned the stream"));
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
port.addEventListener("message", onMessage);
|
|
220
|
+
let posted = false;
|
|
221
|
+
return {
|
|
222
|
+
post() {
|
|
223
|
+
if (posted) return;
|
|
224
|
+
posted = true;
|
|
225
|
+
try {
|
|
226
|
+
port.postMessage({ type: STREAM_ABORT });
|
|
227
|
+
} catch {
|
|
228
|
+
/* the port is already gone — the peer needs no notice */
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
stop() {
|
|
232
|
+
port.removeEventListener("message", onMessage);
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The per-stream inactivity timeout (spec D8). Reset by any chunk in either
|
|
239
|
+
* direction; elapsing aborts the stream. Unset, zero or non-finite installs no
|
|
240
|
+
* timer at all, which is the default: a slow consumer is throttled, not failed.
|
|
241
|
+
*/
|
|
242
|
+
function installStreamTimeout(
|
|
243
|
+
controller: AbortController,
|
|
244
|
+
timeout: number | undefined,
|
|
245
|
+
): { touch(): void; stop(): void } {
|
|
246
|
+
if (timeout === undefined || !Number.isFinite(timeout) || timeout <= 0) {
|
|
247
|
+
return { touch() {}, stop() {} };
|
|
248
|
+
}
|
|
249
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
250
|
+
// Once `stop()` runs, it must stay stopped. Without this, a chunk that
|
|
251
|
+
// arrives after disarming (e.g. `touch()` called from a pump that hadn't
|
|
252
|
+
// yet noticed the stream ended) would silently re-arm a timer nobody is
|
|
253
|
+
// ever going to stop again — the same leak this task exists to close, just
|
|
254
|
+
// reachable from a different angle now that `stop()` can run mid-life
|
|
255
|
+
// (fix round 2), not only at final teardown.
|
|
256
|
+
let stopped = false;
|
|
257
|
+
const arm = () => {
|
|
258
|
+
timer = setTimeout(() => {
|
|
259
|
+
if (!controller.signal.aborted) {
|
|
260
|
+
controller.abort(new Error(`webrun-rpc: stream idle for ${timeout} ms`));
|
|
261
|
+
}
|
|
262
|
+
}, timeout);
|
|
263
|
+
};
|
|
264
|
+
arm();
|
|
265
|
+
return {
|
|
266
|
+
touch() {
|
|
267
|
+
if (stopped) return;
|
|
268
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
269
|
+
arm();
|
|
270
|
+
},
|
|
271
|
+
stop() {
|
|
272
|
+
stopped = true;
|
|
273
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
274
|
+
timer = undefined;
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The receiving half of one direction.
|
|
281
|
+
*
|
|
282
|
+
* The `listenPort` listener is installed **eagerly**, not lazily inside
|
|
283
|
+
* `recieveIterator`'s installer, because a handler that never drains its input
|
|
284
|
+
* would otherwise leave the peer's chunk calls with nobody to answer them. If
|
|
285
|
+
* the local consumer has not started iterating, an inbound chunk waits for it —
|
|
286
|
+
* which is the correct backpressure, and different from having no listener.
|
|
287
|
+
*/
|
|
288
|
+
function receiveChunks(
|
|
289
|
+
port: MessageTarget,
|
|
290
|
+
channelName: string,
|
|
291
|
+
controller: AbortController,
|
|
292
|
+
touch: () => void,
|
|
293
|
+
): { stream: AsyncGenerator<Uint8Array>; stop(): void; ended: Promise<void> } {
|
|
294
|
+
let deliver: ChunkReceiver<Uint8Array> | undefined;
|
|
295
|
+
const waiting: Array<() => void> = [];
|
|
296
|
+
let finished = false;
|
|
297
|
+
let outstanding = false;
|
|
298
|
+
let poison: Error | undefined;
|
|
299
|
+
// Resolves once no more chunks are coming on this channel — either the
|
|
300
|
+
// wire declared `done` (with or without an error) or the signal aborted.
|
|
301
|
+
// Used by `serveDuplexOverPort` to know when it is safe to disarm the
|
|
302
|
+
// inactivity clock on a stream nobody explicitly tears down.
|
|
303
|
+
let resolveEnded: () => void = () => {};
|
|
304
|
+
const ended = new Promise<void>((resolve) => {
|
|
305
|
+
resolveEnded = resolve;
|
|
306
|
+
});
|
|
307
|
+
// Set by `onAbort` the instant the signal fires, independent of whether a
|
|
308
|
+
// consumer has started iterating yet. `recieveIterator`'s installer below
|
|
309
|
+
// runs lazily, on the consumer's first `.next()` — if abort fires first,
|
|
310
|
+
// `deliver` is still undefined when `onAbort` runs, so its delivery below
|
|
311
|
+
// is a no-op. Recording the reason here lets the installer catch up and
|
|
312
|
+
// settle the generator immediately instead of leaving it to wait forever
|
|
313
|
+
// for a `deliver` call that already happened before it existed.
|
|
314
|
+
let aborted = false;
|
|
315
|
+
let abortReason: unknown;
|
|
316
|
+
|
|
317
|
+
const ready = (): Promise<void> =>
|
|
318
|
+
deliver || finished ? Promise.resolve() : new Promise<void>((r) => waiting.push(r));
|
|
319
|
+
|
|
320
|
+
const wake = () => {
|
|
321
|
+
for (const r of waiting.splice(0)) r();
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const off = listenPort<WireChunk, void>(
|
|
325
|
+
port,
|
|
326
|
+
async ({ done, value, error }) => {
|
|
327
|
+
touch();
|
|
328
|
+
if (poison) throw poison;
|
|
329
|
+
if (outstanding) {
|
|
330
|
+
// Spec D15: a second chunk before the first was confirmed is a
|
|
331
|
+
// protocol violation, not a resource question. A count of one needs
|
|
332
|
+
// no threshold and no byte accounting, and it bounds memory by
|
|
333
|
+
// construction: maxPorts x one chunk.
|
|
334
|
+
poison = new Error(
|
|
335
|
+
"webrun-rpc: peer sent a second unconfirmed chunk; the stream port is closed",
|
|
336
|
+
);
|
|
337
|
+
// Route through the existing abort machinery instead of duplicating
|
|
338
|
+
// it by hand. `onAbort` below (a) sets `aborted`/`abortReason`, so
|
|
339
|
+
// `recieveIterator`'s installer replays the error even if poison
|
|
340
|
+
// fires before the consumer's first `.next()` — otherwise `deliver`
|
|
341
|
+
// is still undefined here and the local stream hangs forever; and
|
|
342
|
+
// (b) since `controller.signal` is the very signal `sendChunks`
|
|
343
|
+
// passes into `callPort` for *both* directions of this stream,
|
|
344
|
+
// aborting it rejects any outbound call parked at `NO_TIMEOUT` —
|
|
345
|
+
// otherwise `port.close()` below makes that call unsettleable, the
|
|
346
|
+
// pump never returns, the handler's `finally` never runs, and
|
|
347
|
+
// `disarmClockWhenBothSidesSettle` never fires. `onAbort` also
|
|
348
|
+
// resolves `ended`, making a separate call here redundant.
|
|
349
|
+
controller.abort(poison);
|
|
350
|
+
// Close on the next macrotask, so listenPort still gets to post the
|
|
351
|
+
// refusal on this one — a virtual port goes inert the instant it
|
|
352
|
+
// closes, and a silent drop would leave the offender hanging rather
|
|
353
|
+
// than telling it what it did wrong.
|
|
354
|
+
setTimeout(() => {
|
|
355
|
+
try {
|
|
356
|
+
void port.close?.();
|
|
357
|
+
} catch {
|
|
358
|
+
/* already gone */
|
|
359
|
+
}
|
|
360
|
+
}, 0);
|
|
361
|
+
throw poison;
|
|
362
|
+
}
|
|
363
|
+
outstanding = true;
|
|
364
|
+
try {
|
|
365
|
+
await ready();
|
|
366
|
+
if (finished) throw poison ?? new Error("webrun-rpc: the stream is closed");
|
|
367
|
+
await deliver?.({
|
|
368
|
+
done,
|
|
369
|
+
value,
|
|
370
|
+
error: error ? deserializeError(error) : undefined,
|
|
371
|
+
});
|
|
372
|
+
} finally {
|
|
373
|
+
outstanding = false;
|
|
374
|
+
}
|
|
375
|
+
if (done) resolveEnded();
|
|
376
|
+
},
|
|
377
|
+
{ channelName },
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
// Every path that stops listening to this channel — the consumer walking
|
|
381
|
+
// away early (`break`/`return`/`throw` on the `for await`), an external
|
|
382
|
+
// `stop()`, or the abort branch below — means no more chunks are coming
|
|
383
|
+
// *as far as this side is concerned*, regardless of what the wire still
|
|
384
|
+
// has in flight. `off()` unregisters the port listener right here, so
|
|
385
|
+
// nothing is left to notice a later wire `done` even if one arrives.
|
|
386
|
+
// `ended` must resolve at the same moment, or `serveDuplexOverPort`'s
|
|
387
|
+
// `Promise.allSettled([pump, inbound.ended])` waits forever for a signal
|
|
388
|
+
// that can no longer come (round-2 fix — this was the dead path: the
|
|
389
|
+
// round-1 version only resolved `ended` from the wire's own `done` chunk
|
|
390
|
+
// or from `onAbort`, missing exactly the "consumer stopped pulling before
|
|
391
|
+
// the wire said done" case, e.g. a handler that reads one chunk and
|
|
392
|
+
// returns).
|
|
393
|
+
const detach = () => {
|
|
394
|
+
finished = true;
|
|
395
|
+
wake();
|
|
396
|
+
off();
|
|
397
|
+
controller.signal.removeEventListener("abort", onAbort);
|
|
398
|
+
resolveEnded();
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const onAbort = () => {
|
|
402
|
+
aborted = true;
|
|
403
|
+
abortReason = controller.signal.reason;
|
|
404
|
+
finished = true;
|
|
405
|
+
wake();
|
|
406
|
+
// No-op if the consumer hasn't started iterating yet — `deliver` is only
|
|
407
|
+
// assigned inside the installer below. That ordering is exactly what the
|
|
408
|
+
// `aborted` check in the installer exists to catch.
|
|
409
|
+
void deliver?.({ done: true, error: abortReason });
|
|
410
|
+
resolveEnded();
|
|
411
|
+
};
|
|
412
|
+
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
413
|
+
|
|
414
|
+
const stream = recieveIterator<Uint8Array>((d) => {
|
|
415
|
+
if (aborted) {
|
|
416
|
+
// The signal already fired before this installer ran. `onAbort`'s
|
|
417
|
+
// delivery above was a no-op because `deliver` didn't exist yet — settle
|
|
418
|
+
// the generator immediately instead of registering `deliver` and
|
|
419
|
+
// waiting for a chunk that will never arrive.
|
|
420
|
+
void d({ done: true, error: abortReason });
|
|
421
|
+
return detach;
|
|
422
|
+
}
|
|
423
|
+
deliver = d;
|
|
424
|
+
wake();
|
|
425
|
+
return detach;
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
return {
|
|
429
|
+
stream,
|
|
430
|
+
stop: detach,
|
|
431
|
+
ended,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* The sending half of one direction: one `callPort` per chunk, one call
|
|
437
|
+
* outstanding at a time (spec D11/D12), with no per-chunk deadline (spec D8).
|
|
438
|
+
*/
|
|
439
|
+
async function sendChunks(
|
|
440
|
+
port: MessageTarget,
|
|
441
|
+
channelName: string,
|
|
442
|
+
output: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
|
|
443
|
+
{ maxMessageSize, log }: DuplexOverPortOptions,
|
|
444
|
+
signal: AbortSignal,
|
|
445
|
+
touch: () => void,
|
|
446
|
+
): Promise<void> {
|
|
447
|
+
const framed = maxMessageSize ? toChunks(maxMessageSize)(output) : output;
|
|
448
|
+
const stream = throughAbort(framed, signal);
|
|
449
|
+
try {
|
|
450
|
+
await sendIterator<Uint8Array>(async ({ done, value, error }) => {
|
|
451
|
+
if (signal.aborted) return;
|
|
452
|
+
const chunk: WireChunk = {
|
|
453
|
+
done,
|
|
454
|
+
value,
|
|
455
|
+
error: error === undefined ? undefined : serializeError(error),
|
|
456
|
+
};
|
|
457
|
+
log?.("[duplexOverPort] send", { channelName, done, size: value?.byteLength });
|
|
458
|
+
await callPort<void, WireChunk>(port, chunk, {
|
|
459
|
+
channelName,
|
|
460
|
+
timeout: NO_TIMEOUT,
|
|
461
|
+
signal,
|
|
462
|
+
});
|
|
463
|
+
touch();
|
|
464
|
+
}, stream);
|
|
465
|
+
} catch (err) {
|
|
466
|
+
// An abort is the expected way this ends when the local side walks away;
|
|
467
|
+
// anything else is a genuine transport failure worth surfacing.
|
|
468
|
+
if (signal.aborted) return;
|
|
469
|
+
throw err;
|
|
470
|
+
}
|
|
471
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Typed-JSON RPC tier (callPort/callBidi/ioSend/ioHandle) over any
|
|
2
|
+
// MessageTarget — moved here from the deleted `webrun-streams-port` package.
|
|
3
|
+
export { byteChannelFromMessagePort } from "./byte-channel.js";
|
|
4
|
+
export * from "./call-bidi.js";
|
|
5
|
+
export * from "./call-port.js";
|
|
6
|
+
export * from "./cancel-channel.js";
|
|
7
|
+
export { getPortCloseSignal, setPortCloseSignal } from "./close-signal.js";
|
|
8
|
+
export {
|
|
9
|
+
connect,
|
|
10
|
+
type OnPort,
|
|
11
|
+
type OverPipeOptions,
|
|
12
|
+
overPipe,
|
|
13
|
+
overPorts,
|
|
14
|
+
type PortMuxFactory,
|
|
15
|
+
type PortParams,
|
|
16
|
+
serve,
|
|
17
|
+
} from "./connect-serve.js";
|
|
18
|
+
export * from "./duplex-over-port.js";
|
|
19
|
+
export * from "./io-handle.js";
|
|
20
|
+
export * from "./io-send.js";
|
|
21
|
+
export * from "./listen-bidi.js";
|
|
22
|
+
export * from "./listen-port.js";
|
|
23
|
+
export * from "./message-target.js";
|
|
24
|
+
export { DEFAULT_MAX_PORTS, multiplexPort } from "./multiplex-port.js";
|
|
25
|
+
export type { PortCodec, PortEnvelope, PortMux, PortMuxOptions } from "./port-types.js";
|
|
26
|
+
export * from "./recieve.js";
|
|
27
|
+
export * from "./send.js";
|
|
28
|
+
export { structuredCodec } from "./structured-codec.js";
|
|
29
|
+
export * from "./transfer-port-mux.js";
|
package/src/io-handle.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { listenCancelChannel } from "./cancel-channel.js";
|
|
2
|
+
import type { ListenPortOptions } from "./listen-port.js";
|
|
3
|
+
import type { MessageTarget } from "./message-target.js";
|
|
4
|
+
import { recieve } from "./recieve.js";
|
|
5
|
+
import { send } from "./send.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Server half of a full-duplex exchange over any `MessageTarget`.
|
|
9
|
+
*
|
|
10
|
+
* For each inbound stream, invokes `handler` with the stream, sends the
|
|
11
|
+
* handler's output back, and yields a counter. The generator never ends
|
|
12
|
+
* on its own — consumers break when they want to stop. Pairs with
|
|
13
|
+
* {@link ioSend}.
|
|
14
|
+
*
|
|
15
|
+
* If the peer (the consumer of our outbound send) posts a `cancel-channel`
|
|
16
|
+
* message on the same sub-channel, we abort `send` immediately. This makes
|
|
17
|
+
* `ioSend`'s `iter.return()` propagate cleanly without waiting for
|
|
18
|
+
* `callPort` timeouts.
|
|
19
|
+
*/
|
|
20
|
+
export async function* ioHandle<T, U = T>(
|
|
21
|
+
port: MessageTarget,
|
|
22
|
+
handler: (input: AsyncIterable<T>) => AsyncIterable<U> | Promise<AsyncIterable<U>>,
|
|
23
|
+
options: ListenPortOptions = {},
|
|
24
|
+
): AsyncGenerator<number> {
|
|
25
|
+
let counter = 0;
|
|
26
|
+
const channelName = options.channelName ?? "";
|
|
27
|
+
for await (const input of recieve<T>(port, options)) {
|
|
28
|
+
const sendAbort = new AbortController();
|
|
29
|
+
const unsubscribeCancel = channelName
|
|
30
|
+
? listenCancelChannel(port, channelName, () => sendAbort.abort())
|
|
31
|
+
: () => {};
|
|
32
|
+
try {
|
|
33
|
+
const output = await handler(input);
|
|
34
|
+
await send<U>(port, output, { ...options, signal: sendAbort.signal });
|
|
35
|
+
} finally {
|
|
36
|
+
unsubscribeCancel();
|
|
37
|
+
}
|
|
38
|
+
yield counter++;
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/io-send.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { postCancelChannel } from "./cancel-channel.js";
|
|
2
|
+
import type { ListenPortOptions } from "./listen-port.js";
|
|
3
|
+
import type { MessageTarget } from "./message-target.js";
|
|
4
|
+
import { type RecieveOptions, recieve } from "./recieve.js";
|
|
5
|
+
import { send } from "./send.js";
|
|
6
|
+
|
|
7
|
+
export interface IoSendOptions extends ListenPortOptions {
|
|
8
|
+
/**
|
|
9
|
+
* Optional cancel signal threaded into `recieve` so the consumer can force
|
|
10
|
+
* an in-flight `input.next()` to resolve immediately (otherwise an
|
|
11
|
+
* AsyncGenerator's `.return()` queues behind the pending `.next()` and
|
|
12
|
+
* never preempts a hanging await). Used by `callBidi` to abort the inner
|
|
13
|
+
* stream when the outer call rejects.
|
|
14
|
+
*/
|
|
15
|
+
cancelSignal?: AbortSignal;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Client half of a full-duplex exchange over any `MessageTarget`.
|
|
20
|
+
*
|
|
21
|
+
* Concurrently reads one inbound stream from the peer and writes `output`
|
|
22
|
+
* to it. Yields each value received from the peer. Completes once both
|
|
23
|
+
* directions finish. Pairs with {@link ioHandle}.
|
|
24
|
+
*
|
|
25
|
+
* If the consumer breaks out of the `for await` (via `iter.return()` or
|
|
26
|
+
* loop `break`), `ioSend` posts a `cancel-channel` message on the same
|
|
27
|
+
* sub-channel so the peer can abort its `send` immediately rather than
|
|
28
|
+
* waiting for `callPort` timeouts to fire.
|
|
29
|
+
*/
|
|
30
|
+
export async function* ioSend<T, U = T>(
|
|
31
|
+
port: MessageTarget,
|
|
32
|
+
output: AsyncIterable<U> | Iterable<U>,
|
|
33
|
+
options: IoSendOptions = {},
|
|
34
|
+
): AsyncGenerator<T> {
|
|
35
|
+
const { cancelSignal, ...recieveOptions } = options;
|
|
36
|
+
const channelName = recieveOptions.channelName ?? "";
|
|
37
|
+
let inputEndedNormally = false;
|
|
38
|
+
const sendAbort = new AbortController();
|
|
39
|
+
|
|
40
|
+
// When the outer cancel fires, also abort the outbound `send` so any
|
|
41
|
+
// in-flight `callPort` (e.g., the trailing `{done:true}` whose ack the
|
|
42
|
+
// peer will never produce after closing its listener) short-circuits
|
|
43
|
+
// instead of hanging on its own timeout.
|
|
44
|
+
if (cancelSignal) {
|
|
45
|
+
if (cancelSignal.aborted) sendAbort.abort();
|
|
46
|
+
else cancelSignal.addEventListener("abort", () => sendAbort.abort(), { once: true });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Combine outer cancel signal with recieve's force-close mechanism.
|
|
50
|
+
const recieveOpts: RecieveOptions = { ...recieveOptions, signal: cancelSignal };
|
|
51
|
+
|
|
52
|
+
for await (const input of recieve<T>(port, recieveOpts)) {
|
|
53
|
+
const sendPromise = send<U>(port, output, { ...recieveOptions, signal: sendAbort.signal });
|
|
54
|
+
try {
|
|
55
|
+
yield* input;
|
|
56
|
+
inputEndedNormally = true;
|
|
57
|
+
} finally {
|
|
58
|
+
if (!inputEndedNormally) {
|
|
59
|
+
postCancelChannel(port, channelName);
|
|
60
|
+
sendAbort.abort();
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
await sendPromise;
|
|
64
|
+
} catch {
|
|
65
|
+
/* swallow — consumer is done either way */
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { ioHandle } from "./io-handle.js";
|
|
2
|
+
import { listenPort } from "./listen-port.js";
|
|
3
|
+
import type { MessageTarget } from "./message-target.js";
|
|
4
|
+
|
|
5
|
+
export type BidiHandler<TIn, TOut> = (
|
|
6
|
+
input: AsyncIterable<TIn>,
|
|
7
|
+
params: Record<string, unknown>,
|
|
8
|
+
) => AsyncIterable<TOut> | Promise<AsyncIterable<TOut>>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Server half of {@link callBidi}: listens for stream-call requests on `port`
|
|
12
|
+
* and dispatches each accepted one to `action`.
|
|
13
|
+
*
|
|
14
|
+
* The optional `accept` predicate can inspect the incoming params and reject
|
|
15
|
+
* unwanted calls. Returns a cleanup function that removes the listener.
|
|
16
|
+
*/
|
|
17
|
+
export function listenBidi<TIn, TOut>(
|
|
18
|
+
port: MessageTarget,
|
|
19
|
+
action: BidiHandler<TIn, TOut>,
|
|
20
|
+
accept: (params: Record<string, unknown>) => boolean = () => true,
|
|
21
|
+
): () => void {
|
|
22
|
+
return listenPort(port, async (params: Record<string, unknown>) => {
|
|
23
|
+
if (!params || typeof params.channelName !== "string") return;
|
|
24
|
+
if (!accept(params)) return;
|
|
25
|
+
const handler = async (input: AsyncIterable<TIn>) => action(input, params);
|
|
26
|
+
for await (const _idx of ioHandle<TIn, TOut>(port, handler, params)) {
|
|
27
|
+
void _idx;
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { serializeError } from "@statewalker/webrun-streams";
|
|
2
|
+
import type { MessageTarget } from "./message-target.js";
|
|
3
|
+
|
|
4
|
+
export interface ListenPortOptions {
|
|
5
|
+
/** Channel name filter — ignore messages whose `channelName` doesn't match. */
|
|
6
|
+
channelName?: string;
|
|
7
|
+
/** Logging function; defaults to a no-op. */
|
|
8
|
+
log?: (...args: unknown[]) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type PortHandler<TParams = unknown, TResult = unknown> = (
|
|
12
|
+
params: TParams,
|
|
13
|
+
) => TResult | Promise<TResult>;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Installs `handler` as the server side of a `callPort` / `listenPort`
|
|
17
|
+
* request/response pair on `port`.
|
|
18
|
+
*
|
|
19
|
+
* Returns a cleanup function that removes the listener.
|
|
20
|
+
*/
|
|
21
|
+
export function listenPort<TParams = unknown, TResult = unknown>(
|
|
22
|
+
port: MessageTarget,
|
|
23
|
+
handler: PortHandler<TParams, TResult>,
|
|
24
|
+
{ channelName = "", log = () => {} }: ListenPortOptions = {},
|
|
25
|
+
): () => void {
|
|
26
|
+
const onMessage = async (event: MessageEvent) => {
|
|
27
|
+
const data = event.data as
|
|
28
|
+
| { type: string; channelName: string; callId: string; params: TParams }
|
|
29
|
+
| undefined;
|
|
30
|
+
if (!data || data.channelName !== channelName || data.type !== "request") return;
|
|
31
|
+
const { callId, params } = data;
|
|
32
|
+
log("[listenPort]", { channelName, callId, params });
|
|
33
|
+
let result: TResult | undefined;
|
|
34
|
+
let error: ReturnType<typeof serializeError> | undefined;
|
|
35
|
+
let type: "response:result" | "response:error";
|
|
36
|
+
try {
|
|
37
|
+
result = await handler(params);
|
|
38
|
+
type = "response:result";
|
|
39
|
+
} catch (e) {
|
|
40
|
+
error = serializeError(e);
|
|
41
|
+
type = "response:error";
|
|
42
|
+
}
|
|
43
|
+
port.postMessage({ callId, channelName, type, result, error });
|
|
44
|
+
};
|
|
45
|
+
port.addEventListener("message", onMessage);
|
|
46
|
+
return () => port.removeEventListener("message", onMessage);
|
|
47
|
+
}
|