@statewalker/webrun-streams 0.1.1 → 0.2.1

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.
Files changed (46) hide show
  1. package/README.md +88 -9
  2. package/dist/collect.d.ts +7 -0
  3. package/dist/collect.d.ts.map +1 -0
  4. package/dist/duplex.d.ts +52 -0
  5. package/dist/duplex.d.ts.map +1 -0
  6. package/dist/emulate-mux.d.ts +39 -0
  7. package/dist/emulate-mux.d.ts.map +1 -0
  8. package/dist/errors.d.ts +8 -0
  9. package/dist/errors.d.ts.map +1 -0
  10. package/dist/flow-control.d.ts +71 -0
  11. package/dist/flow-control.d.ts.map +1 -0
  12. package/dist/index.d.ts +16 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/{index.mjs → index.js} +200 -52
  15. package/dist/jsonl.d.ts +5 -0
  16. package/dist/jsonl.d.ts.map +1 -0
  17. package/dist/lines.d.ts +5 -0
  18. package/dist/lines.d.ts.map +1 -0
  19. package/dist/map.d.ts +3 -0
  20. package/dist/map.d.ts.map +1 -0
  21. package/dist/new-async-generator.d.ts +70 -0
  22. package/dist/new-async-generator.d.ts.map +1 -0
  23. package/dist/normalize.d.ts +8 -0
  24. package/dist/normalize.d.ts.map +1 -0
  25. package/dist/readable-streams.d.ts +13 -0
  26. package/dist/readable-streams.d.ts.map +1 -0
  27. package/dist/recieve-iterator.d.ts +14 -0
  28. package/dist/recieve-iterator.d.ts.map +1 -0
  29. package/dist/send-iterator.d.ts +15 -0
  30. package/dist/send-iterator.d.ts.map +1 -0
  31. package/dist/text.d.ts +5 -0
  32. package/dist/text.d.ts.map +1 -0
  33. package/dist/to-chunks.d.ts +11 -0
  34. package/dist/to-chunks.d.ts.map +1 -0
  35. package/dist/uint32.d.ts +25 -0
  36. package/dist/uint32.d.ts.map +1 -0
  37. package/package.json +14 -7
  38. package/src/duplex.ts +59 -0
  39. package/src/emulate-mux.ts +100 -85
  40. package/src/flow-control.ts +146 -0
  41. package/src/index.ts +2 -0
  42. package/src/readable-streams.ts +49 -13
  43. package/src/uint32.ts +34 -0
  44. package/dist/index.d.mts +0 -244
  45. package/dist/index.d.mts.map +0 -1
  46. 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
+ }
@@ -1,43 +1,12 @@
1
+ import { type Duplex, TransportClosedError } from "./duplex.js";
1
2
  import { deserializeError, serializeError } from "./errors.js";
2
-
3
- /**
4
- * Canonical seam for the webrun-streams transport family. A `Duplex` carries
5
- * one logical call: caller emits an iterable of bytes as input, peer yields an
6
- * async generator of bytes as output. Same shape on both sides — an in-process
7
- * test can wire `const caller = handler` and run without any transport.
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
- resolveAck: (() => void) | null;
88
- rejectAck: ((err: Error) => void) | null;
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
- const resolve = s.resolveAck;
150
- const reject = s.rejectAck;
151
- s.resolveAck = null;
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
- resolveAck: null,
193
- rejectAck: null,
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 await (const chunk of input) {
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
- const end = Math.min(off + mtu, chunk.byteLength);
214
- const piece = chunk.subarray(off, end);
215
- sendFrame(s.id, TYPE_DATA, piece);
216
- await new Promise<void>((resolve, reject) => {
217
- s.resolveAck = resolve;
218
- s.rejectAck = reject;
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 holding one in-flight DATA per stream and
321
- // waiting for its ACK — voluntary, and a hostile peer simply does not.
322
- // Pushes are fire-and-forget by necessity (see the comment below), so
323
- // nothing else bounds this queue: a peer that floods DATA at a handler
324
- // which has not started draining retains every payload. Refuse past a
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; ACK after the consumer drains. The inbound
337
- // loop must NOT block on consumer drainage — peer holds one in-flight
338
- // DATA per stream and waits for ACK, so blocking here causes a
339
- // cross-direction deadlock where ACK frames can't be processed.
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 && !s.closed && !muxClosed) sendFrame(id, TYPE_ACK);
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 r = s.resolveAck;
348
- s.resolveAck = null;
349
- s.rejectAck = null;
350
- r?.();
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
  };
@@ -519,7 +533,8 @@ function decodeVarint(buf: Uint8Array, start: number): { value: number; offset:
519
533
  let shift = 0;
520
534
  let i = start;
521
535
  while (i < buf.length) {
522
- const b = buf[i++]!; // i < buf.length, checked by the while condition, so this index exists
536
+ // biome-ignore lint/style/noNonNullAssertion: i < buf.length, checked by the while condition, so this index exists.
537
+ const b = buf[i++]!;
523
538
  value |= (b & 0x7f) << shift;
524
539
  if ((b & 0x80) === 0) return { value: value >>> 0, offset: i };
525
540
  shift += 7;
@@ -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
@@ -1,6 +1,8 @@
1
1
  export * from "./collect.js";
2
+ export * from "./duplex.js";
2
3
  export * from "./emulate-mux.js";
3
4
  export * from "./errors.js";
5
+ export * from "./flow-control.js";
4
6
  export * from "./jsonl.js";
5
7
  export * from "./lines.js";
6
8
  export * from "./map.js";
@@ -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
- while (true) {
7
- const slot = await it.next();
8
- if (!slot || slot.done) break;
9
- const value = (await slot.value) as Uint8Array;
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
- while (true) {
27
- const { done, value } = await reader.read();
28
- if (done) break;
29
- if (value !== undefined) yield value;
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
+ }