@statewalker/webrun-streams 0.1.1 → 0.2.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 +87 -8
- package/dist/collect.d.ts +7 -0
- package/dist/collect.d.ts.map +1 -0
- package/dist/duplex.d.ts +52 -0
- package/dist/duplex.d.ts.map +1 -0
- package/dist/emulate-mux.d.ts +39 -0
- package/dist/emulate-mux.d.ts.map +1 -0
- package/dist/errors.d.ts +8 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/flow-control.d.ts +71 -0
- package/dist/flow-control.d.ts.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/{index.mjs → index.js} +200 -52
- package/dist/jsonl.d.ts +5 -0
- package/dist/jsonl.d.ts.map +1 -0
- package/dist/lines.d.ts +5 -0
- package/dist/lines.d.ts.map +1 -0
- package/dist/map.d.ts +3 -0
- package/dist/map.d.ts.map +1 -0
- package/dist/new-async-generator.d.ts +70 -0
- package/dist/new-async-generator.d.ts.map +1 -0
- package/dist/normalize.d.ts +8 -0
- package/dist/normalize.d.ts.map +1 -0
- package/dist/readable-streams.d.ts +13 -0
- package/dist/readable-streams.d.ts.map +1 -0
- package/dist/recieve-iterator.d.ts +14 -0
- package/dist/recieve-iterator.d.ts.map +1 -0
- package/dist/send-iterator.d.ts +15 -0
- package/dist/send-iterator.d.ts.map +1 -0
- package/dist/text.d.ts +5 -0
- package/dist/text.d.ts.map +1 -0
- package/dist/to-chunks.d.ts +11 -0
- package/dist/to-chunks.d.ts.map +1 -0
- package/dist/uint32.d.ts +25 -0
- package/dist/uint32.d.ts.map +1 -0
- package/package.json +10 -5
- package/src/duplex.ts +59 -0
- package/src/emulate-mux.ts +98 -84
- package/src/flow-control.ts +146 -0
- package/src/index.ts +2 -0
- package/src/readable-streams.ts +49 -13
- package/src/uint32.ts +34 -0
- package/LICENSE +0 -21
- package/dist/index.d.mts +0 -244
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs.map +0 -1
package/src/duplex.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transport seam every `webrun-streams-*` adapter and the conformance suite
|
|
3
|
+
* are written against.
|
|
4
|
+
*
|
|
5
|
+
* These types live here rather than beside an implementation on purpose: the
|
|
6
|
+
* emulated multiplexer that once declared them is scheduled for deletion, and a
|
|
7
|
+
* seam that disappears with its first implementation is not a seam.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Canonical seam for the webrun-streams transport family. A `Duplex` carries
|
|
12
|
+
* one logical call: caller emits an iterable of bytes as input, peer yields an
|
|
13
|
+
* async generator of bytes as output. Same shape on both sides — an in-process
|
|
14
|
+
* test can wire `const caller = handler` and run without any transport.
|
|
15
|
+
*
|
|
16
|
+
* Iterator semantics carry every signal:
|
|
17
|
+
* - Consumer `.return()` on the output → producer's `finally` runs.
|
|
18
|
+
* - Producer `throw` → consumer's `for await` throws.
|
|
19
|
+
* - Normal exhaustion on either side → matching end on the other side.
|
|
20
|
+
*/
|
|
21
|
+
export type Duplex = (
|
|
22
|
+
input: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
|
|
23
|
+
) => AsyncGenerator<Uint8Array>;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Adapter-side factory that stands up a transport connection and yields a
|
|
27
|
+
* caller `Duplex`. One `Connect` invocation owns one transport; each call
|
|
28
|
+
* to the resolved `call` opens a new sub-stream on it.
|
|
29
|
+
*
|
|
30
|
+
* A caller must either drain the returned generator or `.return()` it.
|
|
31
|
+
* Dropping the reference without doing either emits no observable signal:
|
|
32
|
+
* the abandoned consumer never acknowledges inbound data, so the peer's
|
|
33
|
+
* outbound pump blocks awaiting that acknowledgement, no end-of-stream is
|
|
34
|
+
* ever exchanged, and both peers hold the stream's slot open. There is no
|
|
35
|
+
* way for the transport to detect this — an unreferenced generator is not
|
|
36
|
+
* observable — so it is the consumer's obligation.
|
|
37
|
+
*/
|
|
38
|
+
export type Connect<P> = (params: P) => Promise<{
|
|
39
|
+
call: Duplex;
|
|
40
|
+
close: () => Promise<void>;
|
|
41
|
+
}>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Adapter-side factory that registers a handler `Duplex` against a transport.
|
|
45
|
+
* Returns an idempotent teardown.
|
|
46
|
+
*/
|
|
47
|
+
export type Serve<P> = (params: P, handler: Duplex) => Promise<() => Promise<void>>;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Thrown by `emulateMux` and adapters when the underlying transport closes
|
|
51
|
+
* while one or more `Duplex` calls are in flight. Consumers can catch by
|
|
52
|
+
* `instanceof TransportClosedError` or by checking `error.name`.
|
|
53
|
+
*/
|
|
54
|
+
export class TransportClosedError extends Error {
|
|
55
|
+
override readonly name = "TransportClosedError";
|
|
56
|
+
constructor(message = "transport closed") {
|
|
57
|
+
super(message);
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/emulate-mux.ts
CHANGED
|
@@ -1,43 +1,12 @@
|
|
|
1
|
+
import { type Duplex, TransportClosedError } from "./duplex.js";
|
|
1
2
|
import { deserializeError, serializeError } from "./errors.js";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
* Iterator semantics carry every signal:
|
|
10
|
-
* - Consumer `.return()` on the output → producer's `finally` runs.
|
|
11
|
-
* - Producer `throw` → consumer's `for await` throws.
|
|
12
|
-
* - Normal exhaustion on either side → matching end on the other side.
|
|
13
|
-
*/
|
|
14
|
-
export type Duplex = (
|
|
15
|
-
input: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
|
|
16
|
-
) => AsyncGenerator<Uint8Array>;
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Adapter-side factory that stands up a transport connection and yields a
|
|
20
|
-
* caller `Duplex`. One `Connect` invocation owns one transport; each call
|
|
21
|
-
* to the resolved `call` opens a new sub-stream on it.
|
|
22
|
-
*
|
|
23
|
-
* A caller must either drain the returned generator or `.return()` it.
|
|
24
|
-
* Dropping the reference without doing either emits no observable signal:
|
|
25
|
-
* the abandoned consumer never acknowledges inbound data, so the peer's
|
|
26
|
-
* outbound pump blocks awaiting that acknowledgement, no end-of-stream is
|
|
27
|
-
* ever exchanged, and both peers hold the stream's slot open. There is no
|
|
28
|
-
* way for the transport to detect this — an unreferenced generator is not
|
|
29
|
-
* observable — so it is the consumer's obligation.
|
|
30
|
-
*/
|
|
31
|
-
export type Connect<P> = (params: P) => Promise<{
|
|
32
|
-
call: Duplex;
|
|
33
|
-
close: () => Promise<void>;
|
|
34
|
-
}>;
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Adapter-side factory that registers a handler `Duplex` against a transport.
|
|
38
|
-
* Returns an idempotent teardown.
|
|
39
|
-
*/
|
|
40
|
-
export type Serve<P> = (params: P, handler: Duplex) => Promise<() => Promise<void>>;
|
|
3
|
+
import {
|
|
4
|
+
type CreditGrantor,
|
|
5
|
+
type CreditLedger,
|
|
6
|
+
newCreditGrantor,
|
|
7
|
+
newCreditLedger,
|
|
8
|
+
} from "./flow-control.js";
|
|
9
|
+
import { decodeUint32, encodeUint32 } from "./uint32.js";
|
|
41
10
|
|
|
42
11
|
/**
|
|
43
12
|
* The minimum a transport must expose to be wrapped by `emulateMux`. Inbound
|
|
@@ -53,18 +22,6 @@ export type ByteChannel = {
|
|
|
53
22
|
close(): void;
|
|
54
23
|
};
|
|
55
24
|
|
|
56
|
-
/**
|
|
57
|
-
* Thrown by `emulateMux` and adapters when the underlying transport closes
|
|
58
|
-
* while one or more `Duplex` calls are in flight. Consumers can catch by
|
|
59
|
-
* `instanceof TransportClosedError` or by checking `error.name`.
|
|
60
|
-
*/
|
|
61
|
-
export class TransportClosedError extends Error {
|
|
62
|
-
override readonly name = "TransportClosedError";
|
|
63
|
-
constructor(message = "transport closed") {
|
|
64
|
-
super(message);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
25
|
const TYPE_OPEN = 0x01;
|
|
69
26
|
const TYPE_DATA = 0x02;
|
|
70
27
|
const TYPE_ACK = 0x03;
|
|
@@ -84,8 +41,10 @@ interface Stream {
|
|
|
84
41
|
inbound: AsyncGenerator<Uint8Array>;
|
|
85
42
|
pushIn: (chunk: Uint8Array) => Promise<boolean>;
|
|
86
43
|
doneIn: (err?: Error) => Promise<boolean>;
|
|
87
|
-
|
|
88
|
-
|
|
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;
|
|
89
48
|
closed: boolean;
|
|
90
49
|
/** Peer sent END (or the stream was torn down): nothing more arrives. */
|
|
91
50
|
inDone: boolean;
|
|
@@ -93,6 +52,16 @@ interface Stream {
|
|
|
93
52
|
outDone: boolean;
|
|
94
53
|
/** Inbound bytes pushed but not yet taken by the consumer. */
|
|
95
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;
|
|
96
65
|
}
|
|
97
66
|
|
|
98
67
|
export interface EmulateMuxOptions {
|
|
@@ -101,6 +70,11 @@ export interface EmulateMuxOptions {
|
|
|
101
70
|
/**
|
|
102
71
|
* Cap on inbound bytes one stream may hold for a consumer that has not
|
|
103
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`.
|
|
104
78
|
*/
|
|
105
79
|
maxStreamBuffer?: number;
|
|
106
80
|
/**
|
|
@@ -121,6 +95,12 @@ export function emulateMux(
|
|
|
121
95
|
const maxStreams = opts.maxStreams ?? DEFAULT_MAX_STREAMS;
|
|
122
96
|
const mtu = opts.mtu ?? DEFAULT_MTU;
|
|
123
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
|
+
}
|
|
124
104
|
const side = opts.side ?? "initiator";
|
|
125
105
|
|
|
126
106
|
const streams = new Map<number, Stream>();
|
|
@@ -146,13 +126,15 @@ export function emulateMux(
|
|
|
146
126
|
const teardownStream = (s: Stream, err?: Error): void => {
|
|
147
127
|
if (s.closed) return;
|
|
148
128
|
s.closed = true;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
s.
|
|
152
|
-
s.rejectAck = null;
|
|
153
|
-
if (reject && err) reject(err);
|
|
154
|
-
else resolve?.();
|
|
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"));
|
|
155
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?.();
|
|
156
138
|
streams.delete(s.id);
|
|
157
139
|
};
|
|
158
140
|
|
|
@@ -189,8 +171,8 @@ export function emulateMux(
|
|
|
189
171
|
}),
|
|
190
172
|
pushIn: queue.push,
|
|
191
173
|
doneIn: queue.done,
|
|
192
|
-
|
|
193
|
-
|
|
174
|
+
outboundCredit: newCreditLedger(0),
|
|
175
|
+
grantor: newCreditGrantor(maxStreamBuffer),
|
|
194
176
|
closed: false,
|
|
195
177
|
inDone: false,
|
|
196
178
|
outDone: false,
|
|
@@ -203,20 +185,40 @@ export function emulateMux(
|
|
|
203
185
|
s: Stream,
|
|
204
186
|
input: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
|
|
205
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
|
+
};
|
|
206
204
|
try {
|
|
207
|
-
for
|
|
205
|
+
for (;;) {
|
|
206
|
+
const next = await iterator.next();
|
|
207
|
+
if (next.done === true) break;
|
|
208
|
+
const chunk = next.value;
|
|
208
209
|
if (s.closed || muxClosed) return;
|
|
209
210
|
if (chunk.byteLength === 0) continue;
|
|
210
211
|
let off = 0;
|
|
211
212
|
while (off < chunk.byteLength) {
|
|
212
213
|
if (s.closed || muxClosed) return;
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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));
|
|
220
222
|
off = end;
|
|
221
223
|
}
|
|
222
224
|
}
|
|
@@ -308,6 +310,13 @@ export function emulateMux(
|
|
|
308
310
|
}
|
|
309
311
|
const s = createStream(id);
|
|
310
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));
|
|
311
320
|
void runHandler(s, handler);
|
|
312
321
|
return;
|
|
313
322
|
}
|
|
@@ -317,11 +326,11 @@ export function emulateMux(
|
|
|
317
326
|
|
|
318
327
|
switch (type) {
|
|
319
328
|
case TYPE_DATA: {
|
|
320
|
-
// Flow control is the peer
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
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
|
|
325
334
|
// per-stream cap and tear down THAT stream, never the whole mux.
|
|
326
335
|
s.queuedBytes += payload.byteLength;
|
|
327
336
|
if (s.queuedBytes > maxStreamBuffer) {
|
|
@@ -333,21 +342,26 @@ export function emulateMux(
|
|
|
333
342
|
return;
|
|
334
343
|
}
|
|
335
344
|
const copy = payload.byteLength === 0 ? payload : new Uint8Array(payload);
|
|
336
|
-
// Push fire-and-forget;
|
|
337
|
-
// loop must NOT block on consumer drainage —
|
|
338
|
-
//
|
|
339
|
-
// cross-direction deadlock where ACK frames can't be
|
|
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.
|
|
340
350
|
void s.pushIn(copy).then((handled) => {
|
|
341
351
|
s.queuedBytes -= copy.byteLength;
|
|
342
|
-
if (handled
|
|
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));
|
|
343
357
|
});
|
|
344
358
|
return;
|
|
345
359
|
}
|
|
346
360
|
case TYPE_ACK: {
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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);
|
|
351
365
|
return;
|
|
352
366
|
}
|
|
353
367
|
case TYPE_END: {
|
|
@@ -394,7 +408,7 @@ export function emulateMux(
|
|
|
394
408
|
nextLocalId += 2;
|
|
395
409
|
const s = createStream(id);
|
|
396
410
|
streams.set(id, s);
|
|
397
|
-
sendFrame(id, TYPE_OPEN);
|
|
411
|
+
sendFrame(id, TYPE_OPEN, encodeUint32(maxStreamBuffer));
|
|
398
412
|
void pumpOutbound(s, input);
|
|
399
413
|
return s.inbound;
|
|
400
414
|
};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credit-based flow control, as a pair of pure state machines with no I/O.
|
|
3
|
+
*
|
|
4
|
+
* The sender holds a {@link CreditLedger}: it reserves credit before it puts
|
|
5
|
+
* anything on the wire, and stalls at zero. The receiver holds a
|
|
6
|
+
* {@link CreditGrantor}: it counts what its consumer has actually drained and
|
|
7
|
+
* says when to hand the sender more.
|
|
8
|
+
*
|
|
9
|
+
* A sender therefore cannot overrun a receiver's buffer, because it was never
|
|
10
|
+
* granted permission to. That is the property a sender-side window cannot
|
|
11
|
+
* offer: the receiver's capacity is not knowable to the sender unless the
|
|
12
|
+
* receiver states it.
|
|
13
|
+
*
|
|
14
|
+
* **The unit is opaque.** This module never interprets the numbers it counts.
|
|
15
|
+
* `emulateMux` passes byte counts and advertises `maxStreamBuffer`; the RPC
|
|
16
|
+
* tier passes 1 per value and advertises a maximum in-flight value count.
|
|
17
|
+
* Nothing here depends on which.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface CreditLedger {
|
|
21
|
+
/** Units authorised by the peer and not yet reserved. */
|
|
22
|
+
readonly available: number;
|
|
23
|
+
/**
|
|
24
|
+
* Reserve up to `upTo` units, resolving with how many were actually
|
|
25
|
+
* granted — at least 1, never more than `upTo`. The caller sends exactly
|
|
26
|
+
* that much and calls `reserve` again for the rest.
|
|
27
|
+
*
|
|
28
|
+
* `upTo` must itself be at least 1; anything less rejects with a
|
|
29
|
+
* `RangeError` rather than resolving with 0, which would be a silent no-op
|
|
30
|
+
* that also consumed a waiter slot.
|
|
31
|
+
*
|
|
32
|
+
* Returning a partial amount rather than waiting for the full request is
|
|
33
|
+
* what makes the ledger deadlock-free: a peer that advertises less than
|
|
34
|
+
* one `upTo` still makes progress, one short piece at a time.
|
|
35
|
+
*
|
|
36
|
+
* Rejects if {@link fail} is called.
|
|
37
|
+
*/
|
|
38
|
+
reserve(upTo: number): Promise<number>;
|
|
39
|
+
/** The peer authorised `units` more. */
|
|
40
|
+
grant(units: number): void;
|
|
41
|
+
/** Reject every pending and future reservation — transport or stream is gone. */
|
|
42
|
+
fail(err: Error): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface Waiter {
|
|
46
|
+
upTo: number;
|
|
47
|
+
resolve: (granted: number) => void;
|
|
48
|
+
reject: (err: Error) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function newCreditLedger(initial = 0): CreditLedger {
|
|
52
|
+
let available = initial;
|
|
53
|
+
let failure: Error | undefined;
|
|
54
|
+
const waiters: Waiter[] = [];
|
|
55
|
+
|
|
56
|
+
// Waiters are released strictly in order, head first. Letting a later
|
|
57
|
+
// reservation overtake an earlier one would reorder the stream.
|
|
58
|
+
const pump = (): void => {
|
|
59
|
+
while (waiters.length > 0 && available > 0) {
|
|
60
|
+
const next = waiters[0];
|
|
61
|
+
if (!next) return;
|
|
62
|
+
waiters.shift();
|
|
63
|
+
const granted = Math.min(next.upTo, available);
|
|
64
|
+
available -= granted;
|
|
65
|
+
next.resolve(granted);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
get available() {
|
|
71
|
+
return available;
|
|
72
|
+
},
|
|
73
|
+
reserve(upTo: number): Promise<number> {
|
|
74
|
+
if (failure) return Promise.reject(failure);
|
|
75
|
+
// A reservation below one unit is a caller bug, not a legal request: the
|
|
76
|
+
// contract is "at least 1", and the queued path would otherwise consume
|
|
77
|
+
// a waiter in order to hand back nothing.
|
|
78
|
+
if (!(upTo >= 1)) {
|
|
79
|
+
return Promise.reject(
|
|
80
|
+
new RangeError(`newCreditLedger: reserve(${upTo}) — upTo must be at least 1`),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
if (waiters.length === 0 && available > 0) {
|
|
84
|
+
const granted = Math.min(upTo, available);
|
|
85
|
+
available -= granted;
|
|
86
|
+
return Promise.resolve(granted);
|
|
87
|
+
}
|
|
88
|
+
return new Promise<number>((resolve, reject) => {
|
|
89
|
+
waiters.push({ upTo, resolve, reject });
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
grant(units: number): void {
|
|
93
|
+
if (failure) return;
|
|
94
|
+
available += units;
|
|
95
|
+
pump();
|
|
96
|
+
},
|
|
97
|
+
fail(err: Error): void {
|
|
98
|
+
failure ??= err;
|
|
99
|
+
while (waiters.length > 0) {
|
|
100
|
+
waiters.shift()?.reject(err);
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface CreditGrantor {
|
|
107
|
+
/**
|
|
108
|
+
* Record that the consumer drained `units`, and whether the receive queue
|
|
109
|
+
* is now empty. Returns the credit to hand back to the peer, or `0` to stay
|
|
110
|
+
* silent and keep accumulating.
|
|
111
|
+
*/
|
|
112
|
+
consumed(units: number, queueEmpty: boolean): number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Grants are batched: replenishing on every chunk while the receiver is
|
|
117
|
+
* behind would reinvent the per-frame ACK this change exists to remove.
|
|
118
|
+
* `threshold` is the fraction of the window that must drain before a grant is
|
|
119
|
+
* emitted.
|
|
120
|
+
*
|
|
121
|
+
* The batch is flushed unconditionally once the receive queue is empty, even
|
|
122
|
+
* below the threshold, so the receiver never sits on credit it owes. Note what
|
|
123
|
+
* this is and is not: paired with a {@link CreditLedger}, a sender blocks only
|
|
124
|
+
* at *exactly* zero credit, and at that point the receiver holds the entire
|
|
125
|
+
* window as `pending` — above any threshold at or below the whole window — so
|
|
126
|
+
* the threshold alone cannot deadlock that pairing. The flush is what keeps
|
|
127
|
+
* that from being an argument about a global accounting identity: it is
|
|
128
|
+
* locally decidable from one boolean, and it returns owed credit now rather
|
|
129
|
+
* than at the next threshold crossing. It costs an extra frame only when the
|
|
130
|
+
* consumer is keeping pace, which is exactly when the sender is not blocked
|
|
131
|
+
* and the frame is cheap.
|
|
132
|
+
*/
|
|
133
|
+
export function newCreditGrantor(window: number, threshold = 0.5): CreditGrantor {
|
|
134
|
+
const trigger = Math.max(1, Math.floor(window * threshold));
|
|
135
|
+
let pending = 0;
|
|
136
|
+
return {
|
|
137
|
+
consumed(units: number, queueEmpty: boolean): number {
|
|
138
|
+
pending += units;
|
|
139
|
+
if (pending === 0) return 0;
|
|
140
|
+
if (pending < trigger && !queueEmpty) return 0;
|
|
141
|
+
const grant = pending;
|
|
142
|
+
pending = 0;
|
|
143
|
+
return grant;
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
package/src/index.ts
CHANGED
package/src/readable-streams.ts
CHANGED
|
@@ -1,21 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The iterator ↔ `ReadableStream` boundary.
|
|
3
|
+
*
|
|
4
|
+
* Both adapters must carry CANCELLATION, not just data: a response body leaves
|
|
5
|
+
* a handler as a `ReadableStream`, crosses a transport as an iterator, and
|
|
6
|
+
* becomes a `ReadableStream` again at the caller — so when the caller walks
|
|
7
|
+
* away, the only path back to the handler's producer runs through both of
|
|
8
|
+
* these functions. Teardown that stops at an adapter leaves a producer running
|
|
9
|
+
* for ever.
|
|
10
|
+
*/
|
|
11
|
+
|
|
1
12
|
export function toReadableStream(it: AsyncIterator<Uint8Array>): ReadableStream<Uint8Array> {
|
|
2
13
|
return new ReadableStream<Uint8Array>({
|
|
14
|
+
/**
|
|
15
|
+
* One chunk per pull. An earlier version drained the whole iterator inside
|
|
16
|
+
* a single `pull`, which defeated the stream's own backpressure (every
|
|
17
|
+
* chunk was enqueued as fast as the producer could make them, however slow
|
|
18
|
+
* the reader was) and left no point between chunks at which a cancellation
|
|
19
|
+
* could take effect.
|
|
20
|
+
*/
|
|
3
21
|
async pull(controller) {
|
|
4
|
-
let handled = false;
|
|
5
22
|
try {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
controller.enqueue(value);
|
|
23
|
+
const slot = await it.next();
|
|
24
|
+
if (!slot || slot.done) {
|
|
25
|
+
controller.close();
|
|
26
|
+
return;
|
|
11
27
|
}
|
|
28
|
+
controller.enqueue((await slot.value) as Uint8Array);
|
|
12
29
|
} catch (error) {
|
|
13
|
-
handled = true;
|
|
14
30
|
controller.error(error);
|
|
15
|
-
} finally {
|
|
16
|
-
if (!handled) controller.close();
|
|
17
31
|
}
|
|
18
32
|
},
|
|
33
|
+
/**
|
|
34
|
+
* Release the source. NOT awaited: `.return()` on an async generator that
|
|
35
|
+
* is parked awaiting its own source is queued behind that pending
|
|
36
|
+
* `next()`, so awaiting it here would hang `reader.cancel()` on exactly
|
|
37
|
+
* the producers that most need cancelling.
|
|
38
|
+
*/
|
|
39
|
+
cancel(reason) {
|
|
40
|
+
void Promise.resolve(it.return?.(reason)).catch(() => {});
|
|
41
|
+
},
|
|
19
42
|
});
|
|
20
43
|
}
|
|
21
44
|
|
|
@@ -23,9 +46,22 @@ export async function* fromReadableStream(
|
|
|
23
46
|
stream: ReadableStream<Uint8Array>,
|
|
24
47
|
): AsyncGenerator<Uint8Array, void, unknown> {
|
|
25
48
|
const reader = stream.getReader();
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
49
|
+
let drained = false;
|
|
50
|
+
try {
|
|
51
|
+
while (true) {
|
|
52
|
+
const { done, value } = await reader.read();
|
|
53
|
+
if (done) {
|
|
54
|
+
drained = true;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
if (value !== undefined) yield value;
|
|
58
|
+
}
|
|
59
|
+
} finally {
|
|
60
|
+
// A consumer that stops early (`break`, `.return()`, an error) must cancel
|
|
61
|
+
// the source, or whatever fills it keeps filling it. A stream that ended
|
|
62
|
+
// on its own is merely released — cancelling it would be a lie to any
|
|
63
|
+
// `cancel()` hook watching for an abandoned reader.
|
|
64
|
+
if (drained) reader.releaseLock();
|
|
65
|
+
else await reader.cancel().catch(() => {});
|
|
30
66
|
}
|
|
31
67
|
}
|
package/src/uint32.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
}
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2022-2026 statewalker
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|