@statewalker/webrun-streams 0.1.0 → 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.
Files changed (44) hide show
  1. package/README.md +197 -13
  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} +251 -56
  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 +13 -8
  38. package/src/duplex.ts +59 -0
  39. package/src/emulate-mux.ts +188 -74
  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/LICENSE +0 -21
@@ -0,0 +1 @@
1
+ {"version":3,"file":"uint32.d.ts","sourceRoot":"","sources":["../src/uint32.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,0EAA0E;AAC1E,eAAO,MAAM,UAAU,aAAa,CAAC;AAErC;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,CAAC,EAAE,MAAM,GAAG,UAAU,CAKlD;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,GAAG,SAAS,CAGlE"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statewalker/webrun-streams",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Async-iterator / ReadableStream primitives: collect, text/jsonl codecs, lines, backpressure generators, serialisable errors.",
@@ -16,31 +16,36 @@
16
16
  "directory": "packages/webrun-streams"
17
17
  },
18
18
  "exports": {
19
- ".": "./src/index.ts"
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js"
22
+ }
20
23
  },
21
24
  "files": [
22
25
  "dist",
23
26
  "src"
24
27
  ],
25
28
  "devDependencies": {
26
- "@types/node": "^25.6.0",
29
+ "@types/node": "^26.2.0",
27
30
  "rimraf": "^6.1.3",
28
- "tsdown": "^0.21.9",
29
- "typescript": "^6.0.3",
30
- "vitest": "^4.1.4"
31
+ "rolldown": "^1.2.4",
32
+ "typescript": "^7.0.2",
33
+ "vitest": "^4.1.10"
31
34
  },
32
35
  "sideEffects": false,
33
36
  "publishConfig": {
34
37
  "access": "public"
35
38
  },
39
+ "types": "./dist/index.d.ts",
36
40
  "scripts": {
37
- "build": "tsdown",
41
+ "build": "rimraf dist && rolldown -c && tsc --emitDeclarationOnly --declaration",
38
42
  "dev": "tsdown --watch",
39
43
  "test": "vitest run",
40
44
  "test:watch": "vitest",
41
45
  "typecheck": "tsc --noEmit",
46
+ "typecheck:tests": "tsc -p tsconfig.tests.json",
42
47
  "clean": "rimraf dist",
43
- "lint": "biome check --write .",
48
+ "lint": "biome check src tests",
44
49
  "format": "biome format --write ."
45
50
  }
46
51
  }
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,35 +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
- export type Connect<P> = (params: P) => Promise<{
24
- call: Duplex;
25
- close: () => Promise<void>;
26
- }>;
27
-
28
- /**
29
- * Adapter-side factory that registers a handler `Duplex` against a transport.
30
- * Returns an idempotent teardown.
31
- */
32
- 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";
33
10
 
34
11
  /**
35
12
  * The minimum a transport must expose to be wrapped by `emulateMux`. Inbound
@@ -45,18 +22,6 @@ export type ByteChannel = {
45
22
  close(): void;
46
23
  };
47
24
 
48
- /**
49
- * Thrown by `emulateMux` and adapters when the underlying transport closes
50
- * while one or more `Duplex` calls are in flight. Consumers can catch by
51
- * `instanceof TransportClosedError` or by checking `error.name`.
52
- */
53
- export class TransportClosedError extends Error {
54
- override readonly name = "TransportClosedError";
55
- constructor(message = "transport closed") {
56
- super(message);
57
- }
58
- }
59
-
60
25
  const TYPE_OPEN = 0x01;
61
26
  const TYPE_DATA = 0x02;
62
27
  const TYPE_ACK = 0x03;
@@ -66,6 +31,7 @@ const TYPE_CLOSE = 0x06;
66
31
 
67
32
  const DEFAULT_MAX_STREAMS = 256;
68
33
  const DEFAULT_MTU = 64 * 1024;
34
+ const DEFAULT_MAX_STREAM_BUFFER = 8 * 1024 * 1024;
69
35
 
70
36
  const textEncoder = new TextEncoder();
71
37
  const textDecoder = new TextDecoder();
@@ -75,14 +41,42 @@ interface Stream {
75
41
  inbound: AsyncGenerator<Uint8Array>;
76
42
  pushIn: (chunk: Uint8Array) => Promise<boolean>;
77
43
  doneIn: (err?: Error) => Promise<boolean>;
78
- resolveAck: (() => void) | null;
79
- 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;
80
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;
81
65
  }
82
66
 
83
67
  export interface EmulateMuxOptions {
84
68
  maxStreams?: number;
85
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;
86
80
  /**
87
81
  * Stream-id allocation side. Initiator uses even ids (2, 4, …); responder
88
82
  * uses odd ids (1, 3, …). Pick one per peer so allocations don't collide.
@@ -100,6 +94,13 @@ export function emulateMux(
100
94
  } {
101
95
  const maxStreams = opts.maxStreams ?? DEFAULT_MAX_STREAMS;
102
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
+ }
103
104
  const side = opts.side ?? "initiator";
104
105
 
105
106
  const streams = new Map<number, Stream>();
@@ -125,13 +126,31 @@ export function emulateMux(
125
126
  const teardownStream = (s: Stream, err?: Error): void => {
126
127
  if (s.closed) return;
127
128
  s.closed = true;
128
- const resolve = s.resolveAck;
129
- const reject = s.rejectAck;
130
- s.resolveAck = null;
131
- s.rejectAck = null;
132
- if (reject && err) reject(err);
133
- 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"));
134
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;
135
154
  streams.delete(s.id);
136
155
  };
137
156
 
@@ -152,9 +171,12 @@ export function emulateMux(
152
171
  }),
153
172
  pushIn: queue.push,
154
173
  doneIn: queue.done,
155
- resolveAck: null,
156
- rejectAck: null,
174
+ outboundCredit: newCreditLedger(0),
175
+ grantor: newCreditGrantor(maxStreamBuffer),
157
176
  closed: false,
177
+ inDone: false,
178
+ outDone: false,
179
+ queuedBytes: 0,
158
180
  };
159
181
  return state;
160
182
  };
@@ -163,24 +185,48 @@ export function emulateMux(
163
185
  s: Stream,
164
186
  input: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
165
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
+ };
166
204
  try {
167
- 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;
168
209
  if (s.closed || muxClosed) return;
169
210
  if (chunk.byteLength === 0) continue;
170
211
  let off = 0;
171
212
  while (off < chunk.byteLength) {
172
213
  if (s.closed || muxClosed) return;
173
- const end = Math.min(off + mtu, chunk.byteLength);
174
- const piece = chunk.subarray(off, end);
175
- sendFrame(s.id, TYPE_DATA, piece);
176
- await new Promise<void>((resolve, reject) => {
177
- s.resolveAck = resolve;
178
- s.rejectAck = reject;
179
- });
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));
180
222
  off = end;
181
223
  }
182
224
  }
183
- if (!s.closed && !muxClosed) sendFrame(s.id, TYPE_END);
225
+ if (!s.closed && !muxClosed) {
226
+ sendFrame(s.id, TYPE_END);
227
+ s.outDone = true;
228
+ releaseIfComplete(s);
229
+ }
184
230
  } catch (err) {
185
231
  if (!s.closed && !muxClosed) {
186
232
  const e = err instanceof Error ? err : new Error(String(err));
@@ -201,16 +247,54 @@ export function emulateMux(
201
247
  return;
202
248
  }
203
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
+ }
204
263
  };
205
264
 
206
265
  const handleFrame = (frame: Uint8Array): void => {
207
266
  if (muxClosed) return;
208
267
  if (frame.byteLength < 2) return;
209
- const { value: id, offset } = decodeVarint(frame, 0);
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
210
287
  const type = frame[offset];
211
288
  const payload = frame.subarray(offset + 1);
212
289
 
213
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.
214
298
  if (streams.has(id)) return;
215
299
  if (streams.size >= maxStreams) {
216
300
  sendFrame(
@@ -226,6 +310,13 @@ export function emulateMux(
226
310
  }
227
311
  const s = createStream(id);
228
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));
229
320
  void runHandler(s, handler);
230
321
  return;
231
322
  }
@@ -235,25 +326,48 @@ export function emulateMux(
235
326
 
236
327
  switch (type) {
237
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
+ }
238
344
  const copy = payload.byteLength === 0 ? payload : new Uint8Array(payload);
239
- // Push fire-and-forget; ACK after the consumer drains. The inbound
240
- // loop must NOT block on consumer drainage — peer holds one in-flight
241
- // DATA per stream and waits for ACK, so blocking here causes a
242
- // 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.
243
350
  void s.pushIn(copy).then((handled) => {
244
- if (handled && !s.closed && !muxClosed) sendFrame(id, TYPE_ACK);
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));
245
357
  });
246
358
  return;
247
359
  }
248
360
  case TYPE_ACK: {
249
- const r = s.resolveAck;
250
- s.resolveAck = null;
251
- s.rejectAck = null;
252
- 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);
253
365
  return;
254
366
  }
255
367
  case TYPE_END: {
368
+ s.inDone = true;
256
369
  void s.doneIn();
370
+ releaseIfComplete(s);
257
371
  return;
258
372
  }
259
373
  case TYPE_ERROR: {
@@ -294,7 +408,7 @@ export function emulateMux(
294
408
  nextLocalId += 2;
295
409
  const s = createStream(id);
296
410
  streams.set(id, s);
297
- sendFrame(id, TYPE_OPEN);
411
+ sendFrame(id, TYPE_OPEN, encodeUint32(maxStreamBuffer));
298
412
  void pumpOutbound(s, input);
299
413
  return s.inbound;
300
414
  };
@@ -419,7 +533,7 @@ function decodeVarint(buf: Uint8Array, start: number): { value: number; offset:
419
533
  let shift = 0;
420
534
  let i = start;
421
535
  while (i < buf.length) {
422
- const b = buf[i++];
536
+ const b = buf[i++]!; // i < buf.length, checked by the while condition, so this index exists
423
537
  value |= (b & 0x7f) << shift;
424
538
  if ((b & 0x80) === 0) return { value: value >>> 0, offset: i };
425
539
  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";