@statewalker/webrun-streams 0.2.3 → 0.3.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 +40 -152
- package/dist/duplex.d.ts +1 -1
- package/dist/flow-control.d.ts +7 -4
- package/dist/flow-control.d.ts.map +1 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -400
- package/package.json +2 -2
- package/src/duplex.ts +1 -1
- package/src/flow-control.ts +7 -4
- package/src/index.ts +0 -1
- package/dist/emulate-mux.d.ts +0 -39
- package/dist/emulate-mux.d.ts.map +0 -1
- package/dist/uint32.d.ts +0 -25
- package/dist/uint32.d.ts.map +0 -1
- package/src/emulate-mux.ts +0 -557
- package/src/uint32.ts +0 -34
package/src/emulate-mux.ts
DELETED
|
@@ -1,557 +0,0 @@
|
|
|
1
|
-
import { type Duplex, TransportClosedError } from "./duplex.js";
|
|
2
|
-
import { deserializeError, serializeError } from "./errors.js";
|
|
3
|
-
import {
|
|
4
|
-
type CreditGrantor,
|
|
5
|
-
type CreditLedger,
|
|
6
|
-
newCreditGrantor,
|
|
7
|
-
newCreditLedger,
|
|
8
|
-
} from "./flow-control.js";
|
|
9
|
-
import { decodeUint32, encodeUint32 } from "./uint32.js";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* The minimum a transport must expose to be wrapped by `emulateMux`. Inbound
|
|
13
|
-
* bytes are surfaced as an async iterable; outbound is imperative `send`. All
|
|
14
|
-
* five message-oriented transports (WebSocket, LiveKit data channel, PeerJS
|
|
15
|
-
* DataConnection, MessagePort, in-process pipe) match this shape after a thin
|
|
16
|
-
* wrapper.
|
|
17
|
-
*/
|
|
18
|
-
export type ByteChannel = {
|
|
19
|
-
send(bytes: Uint8Array): void;
|
|
20
|
-
recv: AsyncIterable<Uint8Array>;
|
|
21
|
-
closed: Promise<void>;
|
|
22
|
-
close(): void;
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
const TYPE_OPEN = 0x01;
|
|
26
|
-
const TYPE_DATA = 0x02;
|
|
27
|
-
const TYPE_ACK = 0x03;
|
|
28
|
-
const TYPE_END = 0x04;
|
|
29
|
-
const TYPE_ERROR = 0x05;
|
|
30
|
-
const TYPE_CLOSE = 0x06;
|
|
31
|
-
|
|
32
|
-
const DEFAULT_MAX_STREAMS = 256;
|
|
33
|
-
const DEFAULT_MTU = 64 * 1024;
|
|
34
|
-
const DEFAULT_MAX_STREAM_BUFFER = 8 * 1024 * 1024;
|
|
35
|
-
|
|
36
|
-
const textEncoder = /* @__PURE__ */ new TextEncoder();
|
|
37
|
-
const textDecoder = /* @__PURE__ */ new TextDecoder();
|
|
38
|
-
|
|
39
|
-
interface Stream {
|
|
40
|
-
id: number;
|
|
41
|
-
inbound: AsyncGenerator<Uint8Array>;
|
|
42
|
-
pushIn: (chunk: Uint8Array) => Promise<boolean>;
|
|
43
|
-
doneIn: (err?: Error) => Promise<boolean>;
|
|
44
|
-
/** Credit the peer has granted us for sending on this stream. */
|
|
45
|
-
outboundCredit: CreditLedger;
|
|
46
|
-
/** Tracks consumer drainage to decide when to grant the peer more credit. */
|
|
47
|
-
grantor: CreditGrantor;
|
|
48
|
-
closed: boolean;
|
|
49
|
-
/** Peer sent END (or the stream was torn down): nothing more arrives. */
|
|
50
|
-
inDone: boolean;
|
|
51
|
-
/** Our outbound pump sent END: nothing more will be sent. */
|
|
52
|
-
outDone: boolean;
|
|
53
|
-
/** Inbound bytes pushed but not yet taken by the consumer. */
|
|
54
|
-
queuedBytes: number;
|
|
55
|
-
/**
|
|
56
|
-
* Cancels the caller's outbound input, set by `pumpOutbound`.
|
|
57
|
-
*
|
|
58
|
-
* WHY THIS EXISTS. `pumpOutbound` is fire-and-forget and checks `s.closed`
|
|
59
|
-
* only AFTER a chunk arrives, so an input that yields nothing more leaves
|
|
60
|
-
* the pump parked on `input.next()` for ever — holding the producer and its
|
|
61
|
-
* `finally` — even though the consumer has cancelled and the slot is gone.
|
|
62
|
-
* Teardown has to reach back and return the input rather than wait for it.
|
|
63
|
-
*/
|
|
64
|
-
cancelInput?: () => void;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export interface EmulateMuxOptions {
|
|
68
|
-
maxStreams?: number;
|
|
69
|
-
mtu?: number;
|
|
70
|
-
/**
|
|
71
|
-
* Cap on inbound bytes one stream may hold for a consumer that has not
|
|
72
|
-
* drained them. Exceeding it tears down that stream alone.
|
|
73
|
-
*
|
|
74
|
-
* It is also the credit this side advertises to the peer, so it must be at
|
|
75
|
-
* least 1 — a window of 0 authorises nothing and hangs the peer forever —
|
|
76
|
-
* and it travels in a uint32, so a value above `MAX_UINT32` (4 GiB - 1) is
|
|
77
|
-
* advertised as `MAX_UINT32`.
|
|
78
|
-
*/
|
|
79
|
-
maxStreamBuffer?: number;
|
|
80
|
-
/**
|
|
81
|
-
* Stream-id allocation side. Initiator uses even ids (2, 4, …); responder
|
|
82
|
-
* uses odd ids (1, 3, …). Pick one per peer so allocations don't collide.
|
|
83
|
-
*/
|
|
84
|
-
side?: "initiator" | "responder";
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export function emulateMux(
|
|
88
|
-
channel: ByteChannel,
|
|
89
|
-
opts: EmulateMuxOptions = {},
|
|
90
|
-
): {
|
|
91
|
-
call: Duplex;
|
|
92
|
-
serve: (handler: Duplex) => () => Promise<void>;
|
|
93
|
-
close: () => Promise<void>;
|
|
94
|
-
} {
|
|
95
|
-
const maxStreams = opts.maxStreams ?? DEFAULT_MAX_STREAMS;
|
|
96
|
-
const mtu = opts.mtu ?? DEFAULT_MTU;
|
|
97
|
-
const maxStreamBuffer = opts.maxStreamBuffer ?? DEFAULT_MAX_STREAM_BUFFER;
|
|
98
|
-
// A window of 0 is not a small window, it is a permanent stall: the peer is
|
|
99
|
-
// authorised to send nothing and no event would ever grant it more. Refuse
|
|
100
|
-
// it at construction rather than deadlocking on the first call.
|
|
101
|
-
if (!(maxStreamBuffer >= 1)) {
|
|
102
|
-
throw new RangeError(`emulateMux: maxStreamBuffer must be at least 1, got ${maxStreamBuffer}`);
|
|
103
|
-
}
|
|
104
|
-
const side = opts.side ?? "initiator";
|
|
105
|
-
|
|
106
|
-
const streams = new Map<number, Stream>();
|
|
107
|
-
let nextLocalId = side === "initiator" ? 2 : 1;
|
|
108
|
-
let handler: Duplex | null = null;
|
|
109
|
-
let muxClosed = false;
|
|
110
|
-
|
|
111
|
-
const sendFrame = (id: number, type: number, payload?: Uint8Array): void => {
|
|
112
|
-
if (muxClosed) return;
|
|
113
|
-
const idEnc = encodeVarint(id);
|
|
114
|
-
const total = idEnc.length + 1 + (payload?.byteLength ?? 0);
|
|
115
|
-
const frame = new Uint8Array(total);
|
|
116
|
-
frame.set(idEnc, 0);
|
|
117
|
-
frame[idEnc.length] = type;
|
|
118
|
-
if (payload && payload.byteLength > 0) frame.set(payload, idEnc.length + 1);
|
|
119
|
-
try {
|
|
120
|
-
channel.send(frame);
|
|
121
|
-
} catch {
|
|
122
|
-
/* underlying transport closed; inbound loop will detect */
|
|
123
|
-
}
|
|
124
|
-
};
|
|
125
|
-
|
|
126
|
-
const teardownStream = (s: Stream, err?: Error): void => {
|
|
127
|
-
if (s.closed) return;
|
|
128
|
-
s.closed = true;
|
|
129
|
-
// A sender parked in `reserve()` unwinds here: the credit it is waiting
|
|
130
|
-
// for will never arrive, so fail the ledger rather than leave it queued.
|
|
131
|
-
s.outboundCredit.fail(err ?? new TransportClosedError("emulateMux: stream closed"));
|
|
132
|
-
void s.doneIn(err);
|
|
133
|
-
// Return the caller's input so its `finally` runs. Not awaited, and that
|
|
134
|
-
// is deliberate: `.return()` on a generator parked awaiting its own source
|
|
135
|
-
// is QUEUED behind that pending `next()`, so awaiting it here would make
|
|
136
|
-
// teardown hang on exactly the input we are trying to abandon.
|
|
137
|
-
s.cancelInput?.();
|
|
138
|
-
streams.delete(s.id);
|
|
139
|
-
};
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Graceful counterpart to `teardownStream`. A stream occupies a slot in
|
|
143
|
-
* `streams` until BOTH directions finish; releasing on inbound END alone
|
|
144
|
-
* would set `closed` while our own `pumpOutbound` is still sending, and it
|
|
145
|
-
* checks that flag to decide whether to keep going.
|
|
146
|
-
*
|
|
147
|
-
* Without this, only abnormal endings (cancel, ERROR, CLOSE, transport
|
|
148
|
-
* failure) ever freed a slot, so every normally-completed call leaked one
|
|
149
|
-
* until `maxStreams` began rejecting new calls.
|
|
150
|
-
*/
|
|
151
|
-
const releaseIfComplete = (s: Stream): void => {
|
|
152
|
-
if (s.closed || !s.inDone || !s.outDone) return;
|
|
153
|
-
s.closed = true;
|
|
154
|
-
streams.delete(s.id);
|
|
155
|
-
};
|
|
156
|
-
|
|
157
|
-
const failAll = (err: Error): void => {
|
|
158
|
-
if (muxClosed) return;
|
|
159
|
-
muxClosed = true;
|
|
160
|
-
for (const s of [...streams.values()]) teardownStream(s, err);
|
|
161
|
-
streams.clear();
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
const createStream = (id: number): Stream => {
|
|
165
|
-
const queue = makeInboundQueue();
|
|
166
|
-
const state: Stream = {
|
|
167
|
-
id,
|
|
168
|
-
inbound: queue.generator(() => {
|
|
169
|
-
if (!state.closed && !muxClosed) sendFrame(state.id, TYPE_CLOSE);
|
|
170
|
-
teardownStream(state);
|
|
171
|
-
}),
|
|
172
|
-
pushIn: queue.push,
|
|
173
|
-
doneIn: queue.done,
|
|
174
|
-
outboundCredit: newCreditLedger(0),
|
|
175
|
-
grantor: newCreditGrantor(maxStreamBuffer),
|
|
176
|
-
closed: false,
|
|
177
|
-
inDone: false,
|
|
178
|
-
outDone: false,
|
|
179
|
-
queuedBytes: 0,
|
|
180
|
-
};
|
|
181
|
-
return state;
|
|
182
|
-
};
|
|
183
|
-
|
|
184
|
-
const pumpOutbound = async (
|
|
185
|
-
s: Stream,
|
|
186
|
-
input: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
|
|
187
|
-
): Promise<void> => {
|
|
188
|
-
// Hand teardown a way to cancel this input. An async iterator's `return`
|
|
189
|
-
// is the only cancellation the protocol has, and the pump cannot poll for
|
|
190
|
-
// one: between two chunks it is parked inside `next()`, which for a
|
|
191
|
-
// long-lived session is where it spends its whole life.
|
|
192
|
-
//
|
|
193
|
-
// THE ITERATOR IS ACQUIRED ONCE AND DRIVEN BY HAND. `for await (… of
|
|
194
|
-
// input)` would call `[Symbol.asyncIterator]()` again; for a generator
|
|
195
|
-
// that returns the same object, but for any other iterable it returns a
|
|
196
|
-
// SECOND iterator — and then `cancelInput` would be cancelling something
|
|
197
|
-
// the loop is not reading.
|
|
198
|
-
const iterator: AsyncIterator<Uint8Array> | Iterator<Uint8Array> =
|
|
199
|
-
(input as AsyncIterable<Uint8Array>)[Symbol.asyncIterator]?.() ??
|
|
200
|
-
(input as Iterable<Uint8Array>)[Symbol.iterator]();
|
|
201
|
-
s.cancelInput = () => {
|
|
202
|
-
void (iterator as AsyncIterator<Uint8Array>).return?.(undefined);
|
|
203
|
-
};
|
|
204
|
-
try {
|
|
205
|
-
for (;;) {
|
|
206
|
-
const next = await iterator.next();
|
|
207
|
-
if (next.done === true) break;
|
|
208
|
-
const chunk = next.value;
|
|
209
|
-
if (s.closed || muxClosed) return;
|
|
210
|
-
if (chunk.byteLength === 0) continue;
|
|
211
|
-
let off = 0;
|
|
212
|
-
while (off < chunk.byteLength) {
|
|
213
|
-
if (s.closed || muxClosed) return;
|
|
214
|
-
// Reserve BEFORE framing. `reserve` returns what the peer's window
|
|
215
|
-
// can actually take, up to one mtu, so a piece is never larger than
|
|
216
|
-
// the credit that exists for it — which is what stops a sender whose
|
|
217
|
-
// mtu exceeds the peer's whole window from stalling forever.
|
|
218
|
-
const take = await s.outboundCredit.reserve(Math.min(mtu, chunk.byteLength - off));
|
|
219
|
-
if (s.closed || muxClosed) return;
|
|
220
|
-
const end = off + take;
|
|
221
|
-
sendFrame(s.id, TYPE_DATA, chunk.subarray(off, end));
|
|
222
|
-
off = end;
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
if (!s.closed && !muxClosed) {
|
|
226
|
-
sendFrame(s.id, TYPE_END);
|
|
227
|
-
s.outDone = true;
|
|
228
|
-
releaseIfComplete(s);
|
|
229
|
-
}
|
|
230
|
-
} catch (err) {
|
|
231
|
-
if (!s.closed && !muxClosed) {
|
|
232
|
-
const e = err instanceof Error ? err : new Error(String(err));
|
|
233
|
-
sendFrame(s.id, TYPE_ERROR, encodeError(e));
|
|
234
|
-
teardownStream(s, e);
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
};
|
|
238
|
-
|
|
239
|
-
const runHandler = async (s: Stream, h: Duplex): Promise<void> => {
|
|
240
|
-
let outbound: AsyncGenerator<Uint8Array>;
|
|
241
|
-
try {
|
|
242
|
-
outbound = h(s.inbound);
|
|
243
|
-
} catch (err) {
|
|
244
|
-
const e = err instanceof Error ? err : new Error(String(err));
|
|
245
|
-
sendFrame(s.id, TYPE_ERROR, encodeError(e));
|
|
246
|
-
teardownStream(s, e);
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
await pumpOutbound(s, outbound);
|
|
250
|
-
// The response is complete. If the handler never consumed the request
|
|
251
|
-
// body, nothing will ever drain the inbound queue, so no ACK is sent, the
|
|
252
|
-
// caller's pump parks forever awaiting one, and neither peer frees the
|
|
253
|
-
// slot — while the caller's `for await` completes normally, so nothing
|
|
254
|
-
// looks wrong from user code. Tell the peer we no longer want the body.
|
|
255
|
-
//
|
|
256
|
-
// This is why a handler must consume `input` within its generator's
|
|
257
|
-
// lifetime: draining it from a detached task is indistinguishable, from
|
|
258
|
-
// here, from not draining it at all.
|
|
259
|
-
if (!s.inDone && !s.closed && !muxClosed) {
|
|
260
|
-
sendFrame(s.id, TYPE_CLOSE);
|
|
261
|
-
teardownStream(s);
|
|
262
|
-
}
|
|
263
|
-
};
|
|
264
|
-
|
|
265
|
-
const handleFrame = (frame: Uint8Array): void => {
|
|
266
|
-
if (muxClosed) return;
|
|
267
|
-
if (frame.byteLength < 2) return;
|
|
268
|
-
|
|
269
|
-
// The id is the one field parsed from untrusted bytes before we know which
|
|
270
|
-
// stream a frame belongs to, and decodeVarint throws on a truncated or
|
|
271
|
-
// over-long encoding. Left unguarded that exception escapes handleFrame,
|
|
272
|
-
// reaches the inbound loop's catch, and calls failAll — so a single
|
|
273
|
-
// malformed frame tears down every stream on the connection.
|
|
274
|
-
//
|
|
275
|
-
// A ByteChannel is message-oriented, so frames are discrete and a corrupt
|
|
276
|
-
// one cannot desync the next. Dropping it is therefore safe, and is what
|
|
277
|
-
// keeps one bad frame from becoming a denial of service against every
|
|
278
|
-
// healthy stream sharing the mux.
|
|
279
|
-
let id: number;
|
|
280
|
-
let offset: number;
|
|
281
|
-
try {
|
|
282
|
-
({ value: id, offset } = decodeVarint(frame, 0));
|
|
283
|
-
} catch {
|
|
284
|
-
return;
|
|
285
|
-
}
|
|
286
|
-
if (offset >= frame.byteLength) return; // id consumed the whole frame: no type byte
|
|
287
|
-
const type = frame[offset];
|
|
288
|
-
const payload = frame.subarray(offset + 1);
|
|
289
|
-
|
|
290
|
-
if (type === TYPE_OPEN) {
|
|
291
|
-
// A duplicate OPEN for a LIVE stream is ignored. A replay of one that
|
|
292
|
-
// already finished is deliberately not guarded: every transport here is
|
|
293
|
-
// ordered and reliable, so a replay cannot occur by accident, and a
|
|
294
|
-
// hostile peer gains nothing by it — opening a fresh id invokes the same
|
|
295
|
-
// handler just as well. An earlier monotonic high-water mark did guard
|
|
296
|
-
// it, at the cost of letting one OPEN with a high id permanently refuse
|
|
297
|
-
// every later stream, including that peer's own legitimate traffic.
|
|
298
|
-
if (streams.has(id)) return;
|
|
299
|
-
if (streams.size >= maxStreams) {
|
|
300
|
-
sendFrame(
|
|
301
|
-
id,
|
|
302
|
-
TYPE_ERROR,
|
|
303
|
-
encodeError(new RangeError(`emulateMux: maxStreams=${maxStreams} exceeded`)),
|
|
304
|
-
);
|
|
305
|
-
return;
|
|
306
|
-
}
|
|
307
|
-
if (!handler) {
|
|
308
|
-
sendFrame(id, TYPE_ERROR, encodeError(new Error("emulateMux: no handler registered")));
|
|
309
|
-
return;
|
|
310
|
-
}
|
|
311
|
-
const s = createStream(id);
|
|
312
|
-
streams.set(id, s);
|
|
313
|
-
// The opener's advertisement is our whole sending allowance; our own
|
|
314
|
-
// goes back so the opener can start. Both ledgers begin at zero, so the
|
|
315
|
-
// advertisement and every later grant are the same operation — an
|
|
316
|
-
// increment — and the ACK handler needs no special first case.
|
|
317
|
-
const advertised = decodeUint32(payload);
|
|
318
|
-
if (advertised !== undefined) s.outboundCredit.grant(advertised);
|
|
319
|
-
sendFrame(id, TYPE_ACK, encodeUint32(maxStreamBuffer));
|
|
320
|
-
void runHandler(s, handler);
|
|
321
|
-
return;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
const s = streams.get(id);
|
|
325
|
-
if (!s) return;
|
|
326
|
-
|
|
327
|
-
switch (type) {
|
|
328
|
-
case TYPE_DATA: {
|
|
329
|
-
// Flow control is the peer spending only the credit we granted it —
|
|
330
|
-
// voluntary, and a hostile peer simply does not. Pushes are
|
|
331
|
-
// fire-and-forget by necessity (see the comment below), so nothing
|
|
332
|
-
// else bounds this queue: a peer that floods DATA at a handler which
|
|
333
|
-
// has not started draining retains every payload. Refuse past a
|
|
334
|
-
// per-stream cap and tear down THAT stream, never the whole mux.
|
|
335
|
-
s.queuedBytes += payload.byteLength;
|
|
336
|
-
if (s.queuedBytes > maxStreamBuffer) {
|
|
337
|
-
const err = new RangeError(
|
|
338
|
-
`emulateMux: stream ${id} buffered ${s.queuedBytes} bytes past maxStreamBuffer=${maxStreamBuffer} without being drained`,
|
|
339
|
-
);
|
|
340
|
-
sendFrame(id, TYPE_ERROR, encodeError(err));
|
|
341
|
-
teardownStream(s, err);
|
|
342
|
-
return;
|
|
343
|
-
}
|
|
344
|
-
const copy = payload.byteLength === 0 ? payload : new Uint8Array(payload);
|
|
345
|
-
// Push fire-and-forget; grant after the consumer drains. The inbound
|
|
346
|
-
// loop must NOT block on consumer drainage — our own sender is parked
|
|
347
|
-
// in `reserve()` waiting for the peer's grants, so blocking here
|
|
348
|
-
// causes a cross-direction deadlock where ACK frames can't be
|
|
349
|
-
// processed.
|
|
350
|
-
void s.pushIn(copy).then((handled) => {
|
|
351
|
-
s.queuedBytes -= copy.byteLength;
|
|
352
|
-
if (!handled || s.closed || muxClosed) return;
|
|
353
|
-
// Grant only for bytes the consumer actually took, batched while the
|
|
354
|
-
// receiver is behind and flushed the moment its queue empties.
|
|
355
|
-
const grant = s.grantor.consumed(copy.byteLength, s.queuedBytes === 0);
|
|
356
|
-
if (grant > 0) sendFrame(id, TYPE_ACK, encodeUint32(grant));
|
|
357
|
-
});
|
|
358
|
-
return;
|
|
359
|
-
}
|
|
360
|
-
case TYPE_ACK: {
|
|
361
|
-
const granted = decodeUint32(payload);
|
|
362
|
-
// A payload-less ACK is not from a credit-speaking peer; ignore it
|
|
363
|
-
// rather than granting an arbitrary amount.
|
|
364
|
-
if (granted !== undefined) s.outboundCredit.grant(granted);
|
|
365
|
-
return;
|
|
366
|
-
}
|
|
367
|
-
case TYPE_END: {
|
|
368
|
-
s.inDone = true;
|
|
369
|
-
void s.doneIn();
|
|
370
|
-
releaseIfComplete(s);
|
|
371
|
-
return;
|
|
372
|
-
}
|
|
373
|
-
case TYPE_ERROR: {
|
|
374
|
-
teardownStream(s, decodeError(payload));
|
|
375
|
-
return;
|
|
376
|
-
}
|
|
377
|
-
case TYPE_CLOSE: {
|
|
378
|
-
teardownStream(s);
|
|
379
|
-
return;
|
|
380
|
-
}
|
|
381
|
-
default:
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
384
|
-
};
|
|
385
|
-
|
|
386
|
-
// Inbound consumer
|
|
387
|
-
void (async () => {
|
|
388
|
-
try {
|
|
389
|
-
for await (const frame of channel.recv) {
|
|
390
|
-
if (muxClosed) break;
|
|
391
|
-
handleFrame(frame);
|
|
392
|
-
}
|
|
393
|
-
} catch (err) {
|
|
394
|
-
failAll(err instanceof Error ? err : new Error(String(err)));
|
|
395
|
-
return;
|
|
396
|
-
}
|
|
397
|
-
failAll(new TransportClosedError());
|
|
398
|
-
})();
|
|
399
|
-
|
|
400
|
-
void channel.closed.then(() => failAll(new TransportClosedError())).catch(() => {});
|
|
401
|
-
|
|
402
|
-
const call: Duplex = (input) => {
|
|
403
|
-
if (muxClosed) return failedGenerator(new TransportClosedError());
|
|
404
|
-
if (streams.size >= maxStreams) {
|
|
405
|
-
return failedGenerator(new RangeError(`emulateMux: maxStreams=${maxStreams} exceeded`));
|
|
406
|
-
}
|
|
407
|
-
const id = nextLocalId;
|
|
408
|
-
nextLocalId += 2;
|
|
409
|
-
const s = createStream(id);
|
|
410
|
-
streams.set(id, s);
|
|
411
|
-
sendFrame(id, TYPE_OPEN, encodeUint32(maxStreamBuffer));
|
|
412
|
-
void pumpOutbound(s, input);
|
|
413
|
-
return s.inbound;
|
|
414
|
-
};
|
|
415
|
-
|
|
416
|
-
const serve = (h: Duplex): (() => Promise<void>) => {
|
|
417
|
-
handler = h;
|
|
418
|
-
let torn = false;
|
|
419
|
-
return async () => {
|
|
420
|
-
if (torn) return;
|
|
421
|
-
torn = true;
|
|
422
|
-
if (handler === h) handler = null;
|
|
423
|
-
};
|
|
424
|
-
};
|
|
425
|
-
|
|
426
|
-
const close = async (): Promise<void> => {
|
|
427
|
-
if (muxClosed) return;
|
|
428
|
-
failAll(new TransportClosedError());
|
|
429
|
-
try {
|
|
430
|
-
channel.close();
|
|
431
|
-
} catch {
|
|
432
|
-
/* ignore */
|
|
433
|
-
}
|
|
434
|
-
};
|
|
435
|
-
|
|
436
|
-
return { call, serve, close };
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
function failedGenerator(err: Error): AsyncGenerator<Uint8Array> {
|
|
440
|
-
return (async function* () {
|
|
441
|
-
if ((0 as number) === 0) throw err;
|
|
442
|
-
yield new Uint8Array(0);
|
|
443
|
-
})();
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
/**
|
|
447
|
-
* Push/pull queue with eager push/done handles. Unlike `newAsyncGenerator`, the
|
|
448
|
-
* push and done functions are usable *before* the consumer begins iterating —
|
|
449
|
-
* `emulateMux` needs to enqueue frames from inbound traffic regardless of when
|
|
450
|
-
* (or whether) the consumer pulls them.
|
|
451
|
-
*
|
|
452
|
-
* `onCancel` fires only when the consumer terminates the generator before
|
|
453
|
-
* `done()` was called — i.e., a unilateral cancellation. If `done()` is
|
|
454
|
-
* called first (peer END/ERROR/CLOSE), the generator ends naturally and
|
|
455
|
-
* `onCancel` does not fire.
|
|
456
|
-
*/
|
|
457
|
-
function makeInboundQueue(): {
|
|
458
|
-
generator: (onCancel: () => void) => AsyncGenerator<Uint8Array>;
|
|
459
|
-
push: (chunk: Uint8Array) => Promise<boolean>;
|
|
460
|
-
done: (err?: Error) => Promise<boolean>;
|
|
461
|
-
} {
|
|
462
|
-
type Slot =
|
|
463
|
-
| { type: "value"; value: Uint8Array; resolve: (v: boolean) => void }
|
|
464
|
-
| { type: "done"; err?: Error; resolve: (v: boolean) => void };
|
|
465
|
-
const slots: Slot[] = [];
|
|
466
|
-
let wake: (() => void) | null = null;
|
|
467
|
-
let queueClosed = false;
|
|
468
|
-
let doneCalled = false;
|
|
469
|
-
|
|
470
|
-
const push = (chunk: Uint8Array): Promise<boolean> => {
|
|
471
|
-
if (queueClosed) return Promise.resolve(false);
|
|
472
|
-
return new Promise<boolean>((resolve) => {
|
|
473
|
-
slots.push({ type: "value", value: chunk, resolve });
|
|
474
|
-
wake?.();
|
|
475
|
-
});
|
|
476
|
-
};
|
|
477
|
-
|
|
478
|
-
const done = (err?: Error): Promise<boolean> => {
|
|
479
|
-
if (queueClosed || doneCalled) return Promise.resolve(false);
|
|
480
|
-
doneCalled = true;
|
|
481
|
-
return new Promise<boolean>((resolve) => {
|
|
482
|
-
slots.push({ type: "done", err, resolve });
|
|
483
|
-
wake?.();
|
|
484
|
-
});
|
|
485
|
-
};
|
|
486
|
-
|
|
487
|
-
async function* generator(onCancel: () => void): AsyncGenerator<Uint8Array> {
|
|
488
|
-
try {
|
|
489
|
-
while (true) {
|
|
490
|
-
if (slots.length === 0) {
|
|
491
|
-
await new Promise<void>((r) => {
|
|
492
|
-
wake = r;
|
|
493
|
-
});
|
|
494
|
-
wake = null;
|
|
495
|
-
continue;
|
|
496
|
-
}
|
|
497
|
-
const slot = slots.shift() as Slot;
|
|
498
|
-
if (slot.type === "done") {
|
|
499
|
-
slot.resolve(true);
|
|
500
|
-
if (slot.err) throw slot.err;
|
|
501
|
-
return;
|
|
502
|
-
}
|
|
503
|
-
yield slot.value;
|
|
504
|
-
slot.resolve(true);
|
|
505
|
-
}
|
|
506
|
-
} finally {
|
|
507
|
-
queueClosed = true;
|
|
508
|
-
for (const s of slots) s.resolve(false);
|
|
509
|
-
slots.length = 0;
|
|
510
|
-
if (!doneCalled) onCancel();
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
return { generator, push, done };
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
function encodeVarint(value: number): Uint8Array {
|
|
518
|
-
if (!Number.isInteger(value) || value < 0) {
|
|
519
|
-
throw new RangeError(`encodeVarint: ${value} is not a non-negative integer`);
|
|
520
|
-
}
|
|
521
|
-
const out: number[] = [];
|
|
522
|
-
let v = value;
|
|
523
|
-
while (v >= 0x80) {
|
|
524
|
-
out.push((v & 0x7f) | 0x80);
|
|
525
|
-
v >>>= 7;
|
|
526
|
-
}
|
|
527
|
-
out.push(v & 0x7f);
|
|
528
|
-
return new Uint8Array(out);
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
function decodeVarint(buf: Uint8Array, start: number): { value: number; offset: number } {
|
|
532
|
-
let value = 0;
|
|
533
|
-
let shift = 0;
|
|
534
|
-
let i = start;
|
|
535
|
-
while (i < buf.length) {
|
|
536
|
-
// biome-ignore lint/style/noNonNullAssertion: i < buf.length, checked by the while condition, so this index exists.
|
|
537
|
-
const b = buf[i++]!;
|
|
538
|
-
value |= (b & 0x7f) << shift;
|
|
539
|
-
if ((b & 0x80) === 0) return { value: value >>> 0, offset: i };
|
|
540
|
-
shift += 7;
|
|
541
|
-
if (shift > 28) throw new Error("decodeVarint: too long");
|
|
542
|
-
}
|
|
543
|
-
throw new Error("decodeVarint: truncated");
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
function encodeError(err: Error): Uint8Array {
|
|
547
|
-
return textEncoder.encode(JSON.stringify(serializeError(err)));
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
function decodeError(buf: Uint8Array): Error {
|
|
551
|
-
if (buf.byteLength === 0) return new Error("unknown stream error");
|
|
552
|
-
try {
|
|
553
|
-
return deserializeError(JSON.parse(textDecoder.decode(buf)));
|
|
554
|
-
} catch {
|
|
555
|
-
return new Error(textDecoder.decode(buf));
|
|
556
|
-
}
|
|
557
|
-
}
|
package/src/uint32.ts
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Credit payloads are a single big-endian uint32, matching the frame header's
|
|
3
|
-
* byte order.
|
|
4
|
-
*
|
|
5
|
-
* Deliberately **not** re-exported from `index.ts`: this is an internal codec
|
|
6
|
-
* for the `emulateMux` wire format, not a compatibility commitment. Tests
|
|
7
|
-
* import it by path.
|
|
8
|
-
*/
|
|
9
|
-
/** The largest credit a single frame can advertise or grant: 2^32 - 1. */
|
|
10
|
-
export const MAX_UINT32 = 0xffffffff;
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Clamps rather than wraps. `n >>> 0` is the obvious spelling and it is wrong
|
|
14
|
-
* here: 2^32 becomes **0**, so a 4 GiB window advertises *zero credit* and the
|
|
15
|
-
* peer stalls forever with no error — the exact silent hang credit exists to
|
|
16
|
-
* remove. 2^32 + 5 becomes 5, which looks like a working window and is worse.
|
|
17
|
-
* Anything above the ceiling is advertised as the ceiling.
|
|
18
|
-
*/
|
|
19
|
-
export function encodeUint32(n: number): Uint8Array {
|
|
20
|
-
const bytes = new Uint8Array(4);
|
|
21
|
-
const clamped = Number.isFinite(n) ? Math.min(MAX_UINT32, Math.max(0, Math.floor(n))) : 0;
|
|
22
|
-
new DataView(bytes.buffer).setUint32(0, clamped, false);
|
|
23
|
-
return bytes;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Returns `undefined` rather than a garbage number when the payload is too
|
|
28
|
-
* short, so a truncated frame — or one from a peer predating credit — is
|
|
29
|
-
* detectable at the call site instead of silently granting nonsense.
|
|
30
|
-
*/
|
|
31
|
-
export function decodeUint32(bytes: Uint8Array): number | undefined {
|
|
32
|
-
if (bytes.byteLength < 4) return undefined;
|
|
33
|
-
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0, false);
|
|
34
|
-
}
|