@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,18 @@
|
|
|
1
|
+
export type MessageListener = (event: MessageEvent) => void | Promise<void>;
|
|
2
|
+
|
|
3
|
+
/** An object we can listen for `"message"` events on. */
|
|
4
|
+
export interface MessageSource {
|
|
5
|
+
addEventListener(type: "message", listener: MessageListener): void;
|
|
6
|
+
removeEventListener(type: "message", listener: MessageListener): void;
|
|
7
|
+
start?(): void | Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** An object we can post messages to (with optional transferable list). */
|
|
11
|
+
export interface MessageSink {
|
|
12
|
+
postMessage(message: unknown, transfer?: Transferable[]): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Full-duplex message target: both sends and receives. */
|
|
16
|
+
export interface MessageTarget extends MessageSource, MessageSink {
|
|
17
|
+
close?(): void | Promise<void>;
|
|
18
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { MessageTarget } from "./message-target.js";
|
|
2
|
+
import type { PortEnvelope, PortMux, PortMuxOptions } from "./port-types.js";
|
|
3
|
+
import { newVirtualPort, type VirtualPortHandle } from "./virtual-port.js";
|
|
4
|
+
|
|
5
|
+
/** Ceiling on concurrently open virtual ports. Bounds the id table only. */
|
|
6
|
+
export const DEFAULT_MAX_PORTS = 1024;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The default `PortMux`: emulates multiplexing over a single port.
|
|
10
|
+
*
|
|
11
|
+
* A transport that already multiplexes natively supplies its own `PortMux`
|
|
12
|
+
* instead — this implementation is for transports that offer one pipe.
|
|
13
|
+
*/
|
|
14
|
+
export function multiplexPort(port: MessageTarget, options: PortMuxOptions): PortMux {
|
|
15
|
+
const {
|
|
16
|
+
codec,
|
|
17
|
+
onPort,
|
|
18
|
+
side = "initiator",
|
|
19
|
+
maxPorts = DEFAULT_MAX_PORTS,
|
|
20
|
+
maxMessageSize,
|
|
21
|
+
} = options;
|
|
22
|
+
|
|
23
|
+
const open = new Map<number, VirtualPortHandle>();
|
|
24
|
+
let nextId = side === "initiator" ? 0 : 1;
|
|
25
|
+
let muxClosed = false;
|
|
26
|
+
|
|
27
|
+
const post = (envelope: PortEnvelope, transfer?: Transferable[]): void => {
|
|
28
|
+
if (muxClosed) return;
|
|
29
|
+
try {
|
|
30
|
+
codec.post(port, envelope, transfer);
|
|
31
|
+
} catch {
|
|
32
|
+
// The underlying port is gone. There is nothing useful to do here and
|
|
33
|
+
// no flow control to unwind — layer 1 holds no state on its behalf.
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const attach = (id: number): VirtualPortHandle => {
|
|
38
|
+
let self!: VirtualPortHandle;
|
|
39
|
+
self = newVirtualPort(
|
|
40
|
+
(payload, transfer) => post({ type: "message", id, payload }, transfer),
|
|
41
|
+
(reason) => {
|
|
42
|
+
post({ type: "close", id, reason });
|
|
43
|
+
open.delete(id);
|
|
44
|
+
self.markClosed();
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
open.set(id, self);
|
|
48
|
+
return self;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const handleEnvelope = (envelope: PortEnvelope): void => {
|
|
52
|
+
if (muxClosed) return;
|
|
53
|
+
|
|
54
|
+
if (envelope.type === "open") {
|
|
55
|
+
// A duplicate id is a peer bug; ignoring it is safer than replacing a
|
|
56
|
+
// live port out from under its consumer.
|
|
57
|
+
if (open.has(envelope.id)) return;
|
|
58
|
+
if (open.size >= maxPorts) {
|
|
59
|
+
post({ type: "close", id: envelope.id, reason: "max-ports" });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const handle = attach(envelope.id);
|
|
63
|
+
let accepted = false;
|
|
64
|
+
if (onPort) {
|
|
65
|
+
try {
|
|
66
|
+
accepted = onPort(handle.port, envelope.meta) !== false;
|
|
67
|
+
} catch {
|
|
68
|
+
accepted = false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (!accepted) {
|
|
72
|
+
open.delete(envelope.id);
|
|
73
|
+
handle.markClosed();
|
|
74
|
+
post({ type: "close", id: envelope.id, reason: "rejected" });
|
|
75
|
+
}
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const handle = open.get(envelope.id);
|
|
80
|
+
|
|
81
|
+
if (envelope.type === "message") {
|
|
82
|
+
// Drop, never queue. An id that was rejected, never opened, or already
|
|
83
|
+
// closed has no consumer, and holding its traffic would be exactly the
|
|
84
|
+
// buffering layer 1 refuses to do.
|
|
85
|
+
if (!handle) return;
|
|
86
|
+
handle.deliver(envelope.payload);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!handle) return;
|
|
91
|
+
open.delete(envelope.id);
|
|
92
|
+
handle.markClosed();
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const listener = (event: MessageEvent): void => {
|
|
96
|
+
const envelope = codec.read(event);
|
|
97
|
+
if (envelope) handleEnvelope(envelope);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
port.addEventListener("message", listener);
|
|
101
|
+
void port.start?.();
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
maxMessageSize,
|
|
105
|
+
|
|
106
|
+
async openPort(meta?: unknown): Promise<MessageTarget> {
|
|
107
|
+
if (muxClosed) throw new Error("webrun-rpc: the multiplexer is closed");
|
|
108
|
+
if (open.size >= maxPorts) {
|
|
109
|
+
throw new RangeError(`webrun-rpc: maxPorts (${maxPorts}) reached`);
|
|
110
|
+
}
|
|
111
|
+
// A hostile or misconfigured peer may have opened using our own
|
|
112
|
+
// parity. Skipping a claimed id costs one line and keeps a local open
|
|
113
|
+
// from silently taking over a handle the peer is already using.
|
|
114
|
+
while (open.has(nextId)) nextId += 2;
|
|
115
|
+
const id = nextId;
|
|
116
|
+
nextId += 2;
|
|
117
|
+
const handle = attach(id);
|
|
118
|
+
post({ type: "open", id, meta });
|
|
119
|
+
return handle.port;
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
async close(): Promise<void> {
|
|
123
|
+
if (muxClosed) return;
|
|
124
|
+
for (const [id, handle] of [...open]) {
|
|
125
|
+
post({ type: "close", id });
|
|
126
|
+
open.delete(id);
|
|
127
|
+
handle.markClosed();
|
|
128
|
+
}
|
|
129
|
+
muxClosed = true;
|
|
130
|
+
port.removeEventListener("message", listener);
|
|
131
|
+
await port.close?.();
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { MessageTarget } from "./message-target.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a multiplexer exchanges over the underlying port.
|
|
5
|
+
*
|
|
6
|
+
* Three types and nothing more. There is no DATA/ACK split, no credit and no
|
|
7
|
+
* error type: `close` carries an opaque `reason` that layer 1 never inspects,
|
|
8
|
+
* because stream semantics belong above this layer.
|
|
9
|
+
*/
|
|
10
|
+
export type PortEnvelope =
|
|
11
|
+
| { type: "open"; id: number; meta?: unknown }
|
|
12
|
+
| { type: "message"; id: number; payload: unknown }
|
|
13
|
+
| { type: "close"; id: number; reason?: unknown };
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* How an envelope reaches the wire.
|
|
17
|
+
*
|
|
18
|
+
* This is the only place that knows the wire format. A port whose messages are
|
|
19
|
+
* structured values passes envelopes through untouched; a port whose messages
|
|
20
|
+
* are bytes encodes them. A transport with different constraints adds a codec,
|
|
21
|
+
* not a multiplexer.
|
|
22
|
+
*/
|
|
23
|
+
export interface PortCodec {
|
|
24
|
+
/** Place one envelope on the underlying port. */
|
|
25
|
+
post(port: MessageTarget, envelope: PortEnvelope, transfer?: Transferable[]): void;
|
|
26
|
+
/** Recover an envelope from a message event, or `undefined` to ignore it. */
|
|
27
|
+
read(event: MessageEvent): PortEnvelope | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PortMuxOptions {
|
|
31
|
+
/** How envelopes are placed on the underlying port. */
|
|
32
|
+
codec: PortCodec;
|
|
33
|
+
/**
|
|
34
|
+
* Called when the peer opens a port. Return `false` to reject it: a `close`
|
|
35
|
+
* goes back and every later message for that id is dropped. Any other return
|
|
36
|
+
* value — including `undefined` — accepts.
|
|
37
|
+
*
|
|
38
|
+
* With no `onPort` at all, inbound ports are rejected. A port nobody holds
|
|
39
|
+
* has no consumer, and accepting one would mean dropping its traffic
|
|
40
|
+
* silently rather than telling the peer.
|
|
41
|
+
*/
|
|
42
|
+
onPort?: (port: MessageTarget, meta?: unknown) => boolean | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* Id parity. The initiator allocates even ids, the responder odd, so both
|
|
45
|
+
* ends may open concurrently with no negotiation. Defaults to `"initiator"`.
|
|
46
|
+
*/
|
|
47
|
+
side?: "initiator" | "responder";
|
|
48
|
+
/**
|
|
49
|
+
* Ceiling on concurrently open virtual ports. Bounds the id table only — it
|
|
50
|
+
* never inspects, counts or delays a payload.
|
|
51
|
+
*/
|
|
52
|
+
maxPorts?: number;
|
|
53
|
+
/**
|
|
54
|
+
* Largest **payload** a port on this mux can carry, if the transport imposes
|
|
55
|
+
* a limit. Layer 1 does not enforce it; it reports it so layer 2 can chunk.
|
|
56
|
+
*
|
|
57
|
+
* It bounds the payload, **not the frame**. `duplexOverPort` applies
|
|
58
|
+
* `toChunks(maxMessageSize)` and the envelope framing — the chunk wrapper,
|
|
59
|
+
* `callPort`'s request, this mux's own envelope, then the codec — is added on
|
|
60
|
+
* top afterwards. Measured over `msgpackCodec` that framing is 123-128 bytes
|
|
61
|
+
* (modelled ceiling 134), and it is not constant: the call id's length varies
|
|
62
|
+
* per chunk, the port id's integer width adds up to 4, and the payload's
|
|
63
|
+
* length header widens at 64 KiB.
|
|
64
|
+
*
|
|
65
|
+
* So set this **at least 256 bytes below** the transport's real limit. Set to
|
|
66
|
+
* the limit exactly, a full-size chunk overruns it — which on LiveKit
|
|
67
|
+
* delivered a body as zero bytes with no error on either side.
|
|
68
|
+
*/
|
|
69
|
+
maxMessageSize?: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One port in, many ports out. */
|
|
73
|
+
export interface PortMux {
|
|
74
|
+
/** Allocate a port, announce it, and return the local end. */
|
|
75
|
+
openPort(meta?: unknown): Promise<MessageTarget>;
|
|
76
|
+
/** Close every virtual port, then release the underlying port. */
|
|
77
|
+
close(): Promise<void>;
|
|
78
|
+
/** See {@link PortMuxOptions.maxMessageSize}. */
|
|
79
|
+
readonly maxMessageSize?: number;
|
|
80
|
+
}
|
package/src/recieve.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { type IteratorChunk, recieveIterator } from "@statewalker/webrun-streams";
|
|
2
|
+
import { type ListenPortOptions, listenPort } from "./listen-port.js";
|
|
3
|
+
import type { MessageTarget } from "./message-target.js";
|
|
4
|
+
|
|
5
|
+
export interface RecieveOptions extends ListenPortOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Optional cancel signal. When fired, the currently-active
|
|
8
|
+
* `recieveIterator` is force-closed (its producer is signalled `done`
|
|
9
|
+
* with an `AbortError`) so any pending `.next()` resolves immediately
|
|
10
|
+
* and the consumer's iteration loop can unwind. Required to make
|
|
11
|
+
* consumer-side `iter.return()` propagate through `callBidi`/`yield*`
|
|
12
|
+
* without waiting on `callPort` timeouts.
|
|
13
|
+
*/
|
|
14
|
+
signal?: AbortSignal;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Async generator over async generators. Each outer yield is one inbound
|
|
19
|
+
* stream reconstructed from chunk-envelopes delivered by the peer's
|
|
20
|
+
* {@link send}.
|
|
21
|
+
*
|
|
22
|
+
* The outer generator itself never ends: break out of the outer loop when
|
|
23
|
+
* you've handled the streams you care about.
|
|
24
|
+
*
|
|
25
|
+
* When the outer for-await is interrupted (`break`, `return`, throw), the
|
|
26
|
+
* `finally` here both removes the underlying `listenPort` and closes the
|
|
27
|
+
* most recently yielded `recieveIterator`. The latter is required because
|
|
28
|
+
* a `listenPort` handler invocation that's already in flight (awaiting
|
|
29
|
+
* `deliver(chunk)`) would otherwise hang forever — `iterator.return()`
|
|
30
|
+
* triggers `drainQueue` which resolves all pending producer Promises.
|
|
31
|
+
*
|
|
32
|
+
* If `options.signal` is provided and fires while the consumer is awaiting
|
|
33
|
+
* a chunk, the active `recieveIterator` is force-delivered an end-of-stream
|
|
34
|
+
* marker so the consumer wakes up immediately rather than waiting for the
|
|
35
|
+
* next inbound chunk (or `callPort` timeout).
|
|
36
|
+
*/
|
|
37
|
+
export async function* recieve<T>(
|
|
38
|
+
port: MessageTarget,
|
|
39
|
+
options: RecieveOptions = {},
|
|
40
|
+
): AsyncGenerator<AsyncGenerator<T>> {
|
|
41
|
+
const { signal, ...listenOptions } = options;
|
|
42
|
+
let onMessage: ((chunk: IteratorChunk<T>) => Promise<boolean>) | undefined;
|
|
43
|
+
let currentIter: AsyncGenerator<T> | undefined;
|
|
44
|
+
let forceClose: (() => void) | undefined;
|
|
45
|
+
|
|
46
|
+
const close = listenPort<IteratorChunk<T>, void>(
|
|
47
|
+
port,
|
|
48
|
+
async ({ done, value, error }) => {
|
|
49
|
+
await onMessage?.({ done, value, error });
|
|
50
|
+
},
|
|
51
|
+
listenOptions,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const onAbort = () => {
|
|
55
|
+
forceClose?.();
|
|
56
|
+
};
|
|
57
|
+
if (signal) {
|
|
58
|
+
if (signal.aborted) {
|
|
59
|
+
// Bail out immediately — close listener and yield nothing.
|
|
60
|
+
close();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
signal.addEventListener("abort", onAbort);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
while (true) {
|
|
68
|
+
currentIter = recieveIterator<T>((deliver) => {
|
|
69
|
+
onMessage = deliver;
|
|
70
|
+
forceClose = () => {
|
|
71
|
+
void deliver({ done: true });
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
yield currentIter;
|
|
75
|
+
}
|
|
76
|
+
} finally {
|
|
77
|
+
close();
|
|
78
|
+
signal?.removeEventListener("abort", onAbort);
|
|
79
|
+
onMessage = undefined;
|
|
80
|
+
forceClose = undefined;
|
|
81
|
+
if (currentIter) {
|
|
82
|
+
try {
|
|
83
|
+
await currentIter.return?.(undefined as never);
|
|
84
|
+
} catch {
|
|
85
|
+
/* best effort */
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/send.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { sendIterator } from "@statewalker/webrun-streams";
|
|
2
|
+
import { type CallPortOptions, callPort } from "./call-port.js";
|
|
3
|
+
import type { MessageTarget } from "./message-target.js";
|
|
4
|
+
import { throughAbort } from "./through-abort.js";
|
|
5
|
+
|
|
6
|
+
export interface SendOptions extends CallPortOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Optional cancellation signal. When fired, `send` aborts cleanly:
|
|
9
|
+
* - the current iteration is interrupted (by calling `return()` on the
|
|
10
|
+
* underlying iterator, releasing any pending work in the producer);
|
|
11
|
+
* - no further `callPort` round-trips are issued;
|
|
12
|
+
* - the final `{done: true}` marker is **not** sent (the peer told us
|
|
13
|
+
* it's gone — there's no one to receive it).
|
|
14
|
+
*
|
|
15
|
+
* `send` resolves normally on abort; the caller can distinguish abort
|
|
16
|
+
* from normal completion by checking `options.signal.aborted` after.
|
|
17
|
+
*/
|
|
18
|
+
signal?: AbortSignal;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Send every value produced by `output` to `port`, one `callPort` round-trip
|
|
23
|
+
* per chunk. Resolves once the peer has acknowledged the final `{ done: true }`
|
|
24
|
+
* envelope, or once `options.signal` aborts (whichever comes first).
|
|
25
|
+
*/
|
|
26
|
+
export async function send<T>(
|
|
27
|
+
port: MessageTarget,
|
|
28
|
+
output: AsyncIterable<T> | Iterable<T>,
|
|
29
|
+
options: SendOptions = {},
|
|
30
|
+
): Promise<void> {
|
|
31
|
+
const { signal, ...callOptions } = options;
|
|
32
|
+
if (!signal) {
|
|
33
|
+
await sendIterator<T>(async ({ done, value, error }) => {
|
|
34
|
+
await callPort(port, { done, value, error }, callOptions);
|
|
35
|
+
}, output);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const stream = throughAbort(output, signal);
|
|
40
|
+
let abortedBeforeDone = false;
|
|
41
|
+
try {
|
|
42
|
+
await sendIterator<T>(async ({ done, value, error }) => {
|
|
43
|
+
if (signal.aborted) {
|
|
44
|
+
// Skip everything — including the final {done:true}. The peer
|
|
45
|
+
// initiated the cancel and isn't listening anymore.
|
|
46
|
+
abortedBeforeDone = true;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
// Pass the signal so an in-flight callPort short-circuits when abort
|
|
50
|
+
// fires — otherwise it would hang on the per-chunk timeout.
|
|
51
|
+
await callPort(port, { done, value, error }, { ...callOptions, signal });
|
|
52
|
+
}, stream);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
// If we aborted, the chunkSender may have surfaced a callPort error from
|
|
55
|
+
// the very last in-flight round-trip. Swallow it — abort is the expected
|
|
56
|
+
// outcome here.
|
|
57
|
+
if (signal.aborted || abortedBeforeDone) return;
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { PortCodec, PortEnvelope } from "./port-types.js";
|
|
2
|
+
|
|
3
|
+
function isEnvelope(value: unknown): value is PortEnvelope {
|
|
4
|
+
if (typeof value !== "object" || value === null) return false;
|
|
5
|
+
const candidate = value as { type?: unknown; id?: unknown };
|
|
6
|
+
if (typeof candidate.id !== "number") return false;
|
|
7
|
+
if (!Number.isInteger(candidate.id) || candidate.id < 0) return false;
|
|
8
|
+
return candidate.type === "open" || candidate.type === "message" || candidate.type === "close";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* For ports whose messages are structured values — a real `MessagePort`, a
|
|
13
|
+
* worker, an iframe.
|
|
14
|
+
*
|
|
15
|
+
* Envelopes are posted as-is, so nothing is encoded, `ArrayBuffer`s move
|
|
16
|
+
* zero-copy through the transfer list, and structured clone does the work the
|
|
17
|
+
* platform already does well. This is a performance choice only: layer 2 may
|
|
18
|
+
* not send anything a byte codec could not also carry.
|
|
19
|
+
*/
|
|
20
|
+
export const structuredCodec: PortCodec = {
|
|
21
|
+
post(port, envelope, transfer) {
|
|
22
|
+
// An empty transfer list is not the same as no transfer list — some
|
|
23
|
+
// implementations reject the former.
|
|
24
|
+
if (transfer && transfer.length > 0) port.postMessage(envelope, transfer);
|
|
25
|
+
else port.postMessage(envelope);
|
|
26
|
+
},
|
|
27
|
+
read(event) {
|
|
28
|
+
return isEnvelope(event.data) ? event.data : undefined;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wraps an async iterable so that an `AbortSignal` firing causes the wrapper
|
|
3
|
+
* to return cleanly, forwarding `return()` to the underlying iterator so the
|
|
4
|
+
* producer (e.g., a user-supplied generator) sees its own `finally` blocks
|
|
5
|
+
* run immediately rather than waiting for the next yield.
|
|
6
|
+
*/
|
|
7
|
+
export async function* throughAbort<T>(
|
|
8
|
+
input: AsyncIterable<T> | Iterable<T>,
|
|
9
|
+
signal: AbortSignal,
|
|
10
|
+
): AsyncGenerator<T> {
|
|
11
|
+
const iter = (input as AsyncIterable<T>)[Symbol.asyncIterator]
|
|
12
|
+
? (input as AsyncIterable<T>)[Symbol.asyncIterator]()
|
|
13
|
+
: ((input as Iterable<T>)[Symbol.iterator]() as unknown as AsyncIterator<T>);
|
|
14
|
+
const onAbort = () => {
|
|
15
|
+
void iter.return?.(undefined as never);
|
|
16
|
+
};
|
|
17
|
+
if (signal.aborted) {
|
|
18
|
+
void iter.return?.(undefined as never);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
22
|
+
try {
|
|
23
|
+
while (true) {
|
|
24
|
+
const r = await iter.next();
|
|
25
|
+
if (r.done) return;
|
|
26
|
+
if (signal.aborted) return;
|
|
27
|
+
yield r.value;
|
|
28
|
+
}
|
|
29
|
+
} finally {
|
|
30
|
+
signal.removeEventListener("abort", onAbort);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { MessageTarget } from "./message-target.js";
|
|
2
|
+
import type { PortMux } from "./port-types.js";
|
|
3
|
+
|
|
4
|
+
/** The `type` of the envelope that carries a transferred port to the peer. */
|
|
5
|
+
export const PORT_TRANSFER = "webrun-rpc:port-transfer";
|
|
6
|
+
|
|
7
|
+
export interface TransferPortMuxOptions {
|
|
8
|
+
/**
|
|
9
|
+
* Called when the peer transfers a port in. Return `false` to reject it: the
|
|
10
|
+
* port is closed and nothing further arrives on it. Any other return value —
|
|
11
|
+
* including `undefined` — accepts.
|
|
12
|
+
*
|
|
13
|
+
* With no `onPort` at all, inbound ports are rejected, matching
|
|
14
|
+
* `multiplexPort`: a port nobody holds has no consumer.
|
|
15
|
+
*/
|
|
16
|
+
onPort?: (port: MessageTarget, meta?: unknown) => boolean | undefined;
|
|
17
|
+
/** Reported to layer 2, never enforced. A `MessagePort` normally has none. */
|
|
18
|
+
maxMessageSize?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A `PortMux` whose ports are real, transferred `MessagePort`s (spec D23).
|
|
23
|
+
*
|
|
24
|
+
* `openPort` creates a `MessageChannel`, transfers one end to the peer over
|
|
25
|
+
* `target`, and returns the other. There is no id table, no `maxPorts` and no
|
|
26
|
+
* envelope overhead per message, because the platform does the multiplexing.
|
|
27
|
+
*
|
|
28
|
+
* **It needs structured clone with transferables**, so it exists in browsers,
|
|
29
|
+
* workers and iframes and nowhere else that lacks them. A caller selects it
|
|
30
|
+
* explicitly rather than by capability sniffing (spec D21): use
|
|
31
|
+
* `multiplexPort` where the transport is one pipe of bytes.
|
|
32
|
+
*
|
|
33
|
+
* What it buys over emulation: a transferred port can cross an origin or a
|
|
34
|
+
* worker boundary and be handed to code that never saw `target`, where an
|
|
35
|
+
* emulated port id is meaningless outside its own mux.
|
|
36
|
+
*
|
|
37
|
+
* `target` must be a full `MessageTarget`. Reaching a send-only `MessageSink`
|
|
38
|
+
* — a `ServiceWorkerClient`, say — is a real use of port transfer but needs a
|
|
39
|
+
* different entry point, and is not part of this interface.
|
|
40
|
+
*/
|
|
41
|
+
export function transferPortMux(
|
|
42
|
+
target: MessageTarget,
|
|
43
|
+
options: TransferPortMuxOptions = {},
|
|
44
|
+
): PortMux {
|
|
45
|
+
const { onPort, maxMessageSize } = options;
|
|
46
|
+
const issued = new Set<MessagePort>();
|
|
47
|
+
let closed = false;
|
|
48
|
+
|
|
49
|
+
const listener = (event: MessageEvent): void => {
|
|
50
|
+
if (closed) return;
|
|
51
|
+
const data = event.data as { type?: unknown; meta?: unknown } | undefined;
|
|
52
|
+
if (!data || typeof data !== "object" || data.type !== PORT_TRANSFER) return;
|
|
53
|
+
const port = event.ports?.[0];
|
|
54
|
+
// The right `type` with no port attached is a malformed message, not a
|
|
55
|
+
// transfer. Dropping it keeps a shared parent port uncorrupted.
|
|
56
|
+
if (!port) return;
|
|
57
|
+
port.start();
|
|
58
|
+
let accepted = false;
|
|
59
|
+
if (onPort) {
|
|
60
|
+
try {
|
|
61
|
+
accepted = onPort(port, data.meta) !== false;
|
|
62
|
+
} catch {
|
|
63
|
+
accepted = false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (!accepted) {
|
|
67
|
+
port.close();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
issued.add(port);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
target.addEventListener("message", listener);
|
|
74
|
+
void target.start?.();
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
maxMessageSize,
|
|
78
|
+
|
|
79
|
+
async openPort(meta?: unknown): Promise<MessageTarget> {
|
|
80
|
+
if (closed) throw new Error("webrun-rpc: the multiplexer is closed");
|
|
81
|
+
const channel = new MessageChannel();
|
|
82
|
+
channel.port1.start();
|
|
83
|
+
// The transferred end is not referenced from the message itself, so it
|
|
84
|
+
// arrives in `event.ports` on the peer — the platform's own hand-off,
|
|
85
|
+
// identical in Node and in browsers.
|
|
86
|
+
target.postMessage({ type: PORT_TRANSFER, meta }, [channel.port2]);
|
|
87
|
+
issued.add(channel.port1);
|
|
88
|
+
return channel.port1;
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
async close(): Promise<void> {
|
|
92
|
+
if (closed) return;
|
|
93
|
+
closed = true;
|
|
94
|
+
target.removeEventListener("message", listener);
|
|
95
|
+
for (const port of issued) {
|
|
96
|
+
try {
|
|
97
|
+
port.close();
|
|
98
|
+
} catch {
|
|
99
|
+
/* already gone */
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
issued.clear();
|
|
103
|
+
await target.close?.();
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { MessageListener, MessageTarget } from "./message-target.js";
|
|
2
|
+
|
|
3
|
+
export interface VirtualPortHandle {
|
|
4
|
+
/** The consumer-facing end. Indistinguishable from a real `MessagePort`. */
|
|
5
|
+
port: MessageTarget;
|
|
6
|
+
/** Multiplexer-only: hand an inbound payload to the consumer's listeners. */
|
|
7
|
+
deliver(payload: unknown): void;
|
|
8
|
+
/** Multiplexer-only: the port is finished; drop listeners and go inert. */
|
|
9
|
+
markClosed(): void;
|
|
10
|
+
isClosed(): boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One virtual port.
|
|
15
|
+
*
|
|
16
|
+
* `deliver` and `markClosed` are deliberately not on `port`: the consumer holds
|
|
17
|
+
* only a `MessageTarget`, so it cannot forge inbound traffic or close the port
|
|
18
|
+
* out from under the multiplexer's bookkeeping.
|
|
19
|
+
*/
|
|
20
|
+
export function newVirtualPort(
|
|
21
|
+
send: (payload: unknown, transfer?: Transferable[]) => void,
|
|
22
|
+
requestClose: (reason?: unknown) => void,
|
|
23
|
+
): VirtualPortHandle {
|
|
24
|
+
const listeners = new Set<MessageListener>();
|
|
25
|
+
let closed = false;
|
|
26
|
+
|
|
27
|
+
const port: MessageTarget = {
|
|
28
|
+
addEventListener(_type, listener) {
|
|
29
|
+
listeners.add(listener);
|
|
30
|
+
},
|
|
31
|
+
removeEventListener(_type, listener) {
|
|
32
|
+
listeners.delete(listener);
|
|
33
|
+
},
|
|
34
|
+
postMessage(message, transfer) {
|
|
35
|
+
if (closed) return;
|
|
36
|
+
send(message, transfer);
|
|
37
|
+
},
|
|
38
|
+
close() {
|
|
39
|
+
if (closed) return;
|
|
40
|
+
closed = true;
|
|
41
|
+
const notify = requestClose;
|
|
42
|
+
listeners.clear();
|
|
43
|
+
notify();
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
port,
|
|
49
|
+
deliver(payload) {
|
|
50
|
+
if (closed) return;
|
|
51
|
+
const event = new MessageEvent("message", { data: payload });
|
|
52
|
+
// Copy first: a listener may add or remove listeners while running.
|
|
53
|
+
for (const listener of [...listeners]) {
|
|
54
|
+
try {
|
|
55
|
+
void listener(event);
|
|
56
|
+
} catch {
|
|
57
|
+
// One consumer's fault is not the multiplexer's. Swallowing here
|
|
58
|
+
// keeps a thrown listener from killing the inbound loop and every
|
|
59
|
+
// other port with it.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
markClosed() {
|
|
64
|
+
closed = true;
|
|
65
|
+
listeners.clear();
|
|
66
|
+
},
|
|
67
|
+
isClosed() {
|
|
68
|
+
return closed;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|