tunnelfetch 1.0.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 (96) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +617 -0
  3. package/README.zh-CN.md +470 -0
  4. package/package.json +74 -0
  5. package/src/client/cookies.js +429 -0
  6. package/src/client/decode.js +346 -0
  7. package/src/client/redirect.js +249 -0
  8. package/src/client.js +704 -0
  9. package/src/errors.js +181 -0
  10. package/src/http1/chunked.js +289 -0
  11. package/src/http1/index.js +10 -0
  12. package/src/http1/request.js +143 -0
  13. package/src/http1/response.js +493 -0
  14. package/src/http2/connection.js +1170 -0
  15. package/src/http2/constants.js +129 -0
  16. package/src/http2/frames.js +291 -0
  17. package/src/http2/hpack.js +420 -0
  18. package/src/http2/huffman.js +203 -0
  19. package/src/http2/index.js +21 -0
  20. package/src/index.js +46 -0
  21. package/src/pool.js +256 -0
  22. package/src/proxy/direct.js +62 -0
  23. package/src/proxy/http-connect.js +206 -0
  24. package/src/proxy/index.js +197 -0
  25. package/src/proxy/socks5.js +344 -0
  26. package/src/tls/aead.js +263 -0
  27. package/src/tls/connect.js +407 -0
  28. package/src/tls/constants.js +334 -0
  29. package/src/tls/extensions.js +376 -0
  30. package/src/tls/handshake-messages.js +901 -0
  31. package/src/tls/handshake.js +568 -0
  32. package/src/tls/handshake12.js +507 -0
  33. package/src/tls/index.js +44 -0
  34. package/src/tls/keyschedule.js +473 -0
  35. package/src/tls/record.js +872 -0
  36. package/src/tls/tickets.js +145 -0
  37. package/src/tls/transcript.js +101 -0
  38. package/src/tls/wire.js +224 -0
  39. package/src/transport.js +296 -0
  40. package/src/trust/der.js +551 -0
  41. package/src/trust/index.js +375 -0
  42. package/src/trust/name.js +235 -0
  43. package/src/trust/ocsp.js +759 -0
  44. package/src/trust/path.js +595 -0
  45. package/src/trust/roots.js +454 -0
  46. package/src/trust/x509.js +902 -0
  47. package/src/util/bytes.js +470 -0
  48. package/src/util/deadline.js +266 -0
  49. package/src/warmup-fixture.js +85 -0
  50. package/src/warmup.js +243 -0
  51. package/types/client/cookies.d.ts +159 -0
  52. package/types/client/decode.d.ts +54 -0
  53. package/types/client/redirect.d.ts +96 -0
  54. package/types/client.d.ts +323 -0
  55. package/types/errors.d.ts +141 -0
  56. package/types/http1/chunked.d.ts +48 -0
  57. package/types/http1/index.d.ts +3 -0
  58. package/types/http1/request.d.ts +44 -0
  59. package/types/http1/response.d.ts +183 -0
  60. package/types/http2/connection.d.ts +282 -0
  61. package/types/http2/constants.d.ts +95 -0
  62. package/types/http2/frames.d.ts +116 -0
  63. package/types/http2/hpack.d.ts +99 -0
  64. package/types/http2/huffman.d.ts +21 -0
  65. package/types/http2/index.d.ts +5 -0
  66. package/types/index.d.ts +17 -0
  67. package/types/pool.d.ts +135 -0
  68. package/types/proxy/direct.d.ts +26 -0
  69. package/types/proxy/http-connect.d.ts +37 -0
  70. package/types/proxy/index.d.ts +62 -0
  71. package/types/proxy/socks5.d.ts +47 -0
  72. package/types/tls/aead.d.ts +67 -0
  73. package/types/tls/connect.d.ts +280 -0
  74. package/types/tls/constants.d.ts +275 -0
  75. package/types/tls/extensions.d.ts +195 -0
  76. package/types/tls/handshake-messages.d.ts +430 -0
  77. package/types/tls/handshake.d.ts +90 -0
  78. package/types/tls/handshake12.d.ts +35 -0
  79. package/types/tls/index.d.ts +9 -0
  80. package/types/tls/keyschedule.d.ts +272 -0
  81. package/types/tls/record.d.ts +361 -0
  82. package/types/tls/tickets.d.ts +66 -0
  83. package/types/tls/transcript.d.ts +52 -0
  84. package/types/tls/wire.d.ts +106 -0
  85. package/types/transport.d.ts +222 -0
  86. package/types/trust/der.d.ts +239 -0
  87. package/types/trust/index.d.ts +194 -0
  88. package/types/trust/name.d.ts +33 -0
  89. package/types/trust/ocsp.d.ts +138 -0
  90. package/types/trust/path.d.ts +139 -0
  91. package/types/trust/roots.d.ts +36 -0
  92. package/types/trust/x509.d.ts +401 -0
  93. package/types/util/bytes.d.ts +183 -0
  94. package/types/util/deadline.d.ts +133 -0
  95. package/types/warmup-fixture.d.ts +11 -0
  96. package/types/warmup.d.ts +45 -0
@@ -0,0 +1,1170 @@
1
+ // The HTTP/2 connection engine (RFC 9113): one TCP+TLS byte duplex, many concurrent streams.
2
+ //
3
+ // CONNECTION SHARING — the decision the pool header warns about. HTTP/1.1 makes reuse safe by
4
+ // EXCLUSIVE CHECKOUT: a connection leaves the pool for one request and returns only when that
5
+ // response's body has reached its framed end, so bytes for one response can never be read as
6
+ // another's. HTTP/2 multiplexes, so exclusive checkout is the wrong model — the connection is
7
+ // never checked out at all. Here the same invariant ("bytes of one response are never delivered
8
+ // as another's") is carried by the stream id instead: exactly ONE reader — the frame loop below —
9
+ // touches the transport, it demultiplexes each DATA frame to the stream its id names, and every
10
+ // stream has its own body queue. A response's bytes reach a caller only through the stream object
11
+ // that owns that id, which is a different object per request. There is no shared byte cursor to
12
+ // desynchronise, so there is nothing to check out. The connection is held in a per-Client registry
13
+ // (see client.js) keyed exactly like the h1 pool, and a single connection serves every concurrent
14
+ // request to that key until it goes away.
15
+ //
16
+ // FLOW CONTROL is the other load-bearing part, and it is wired to CONSUMPTION, not arrival. When a
17
+ // DATA frame lands, its bytes are debited from our receive window immediately (the peer has spent
18
+ // that credit); the window is only reopened — a WINDOW_UPDATE is only sent — as the CONSUMER drains
19
+ // the body. Reopening on arrival instead would defeat backpressure (a fast server + slow reader
20
+ // would buffer without bound); never reopening at all would stall every body larger than the
21
+ // initial window forever, which looks exactly like a hung server. Both failure modes are real and
22
+ // each is guarded by a test.
23
+
24
+ import { Http2Error, codes } from '../errors.js';
25
+ import { ByteReader, ByteWriter, concat } from '../util/bytes.js';
26
+ import {
27
+ CLIENT_CONNECTION_WINDOW,
28
+ CLIENT_CONNECTION_WINDOW_INCREMENT,
29
+ CLIENT_INITIAL_WINDOW_SIZE,
30
+ CLIENT_MAX_CONCURRENT_STREAMS,
31
+ CONNECTION_PREFACE,
32
+ DEFAULT_INITIAL_WINDOW,
33
+ DEFAULT_MAX_FRAME_SIZE,
34
+ FLAG,
35
+ FRAME,
36
+ H2_ERROR,
37
+ H2_ERROR_NAME,
38
+ MAX_ALLOWED_FRAME_SIZE,
39
+ MAX_WINDOW,
40
+ PSEUDO_HEADER_ORDER,
41
+ SETTINGS,
42
+ } from './constants.js';
43
+ import {
44
+ continuationFrame,
45
+ dataFrame,
46
+ goawayFrame,
47
+ headersBlockFragment,
48
+ headersFrame,
49
+ parseGoaway,
50
+ parseRstStream,
51
+ parseSettings,
52
+ parseWindowUpdate,
53
+ pingFrame,
54
+ readFrame,
55
+ rstStreamFrame,
56
+ serializeFrame,
57
+ settingsFrame,
58
+ stripPadding,
59
+ windowUpdateFrame,
60
+ } from './frames.js';
61
+ import { DEFAULT_HEADER_TABLE_SIZE, HpackDecoder, encodeHeaderBlock } from './hpack.js';
62
+
63
+ /** A promise with its settle functions exposed, rejection pre-observed so an unconsumed
64
+ * trailers/completed promise can never crash the isolate. */
65
+ function deferred() {
66
+ let resolve;
67
+ let reject;
68
+ const promise = new Promise((res, rej) => {
69
+ resolve = res;
70
+ reject = rej;
71
+ });
72
+ promise.catch(() => {});
73
+ return { promise, resolve, reject, settled: false };
74
+ }
75
+
76
+ /** RFC 9113 s8.2.2 / s8.2.3: connection-specific header fields a client must not send in h2, and
77
+ * a server must not send either. Their presence in a response is malformed; we drop them on the
78
+ * request side and reject them on the response side. `te` is allowed only when its value is
79
+ * exactly "trailers", handled separately. */
80
+ const FORBIDDEN_H2_HEADERS = new Set([
81
+ 'connection',
82
+ 'proxy-connection',
83
+ 'keep-alive',
84
+ 'transfer-encoding',
85
+ 'upgrade',
86
+ ]);
87
+
88
+ /**
89
+ * @typedef {ReadableStream<Uint8Array> & { completed: Promise<boolean>,
90
+ * trailers: Promise<Headers | null> }} BodyStream
91
+ */
92
+
93
+ /**
94
+ * @typedef {object} Http2ResponseHead
95
+ * @property {number} status
96
+ * @property {string} statusText always '' — HTTP/2 has no reason phrase
97
+ * @property {Headers} headers
98
+ * @property {string[]} setCookie one entry per set-cookie field, kept separate like the h1 path
99
+ * @property {'2'} httpVersion
100
+ * @property {BodyStream} body
101
+ */
102
+
103
+ /**
104
+ * Raised by request() when the connection cannot take the stream but the request PROVABLY was not
105
+ * processed (going away, or refused). It mirrors h1's serverNeverSawIt: only a request the server
106
+ * demonstrably never saw may be re-sent, so client.js can safely open a fresh connection and retry.
107
+ */
108
+ export class Http2Retryable extends Http2Error {}
109
+
110
+ /**
111
+ * @typedef {object} Http2ConnectionOptions
112
+ * @property {import('../transport.js').ConnectionInfo} [info] provenance attached to responses
113
+ * @property {number} [initialWindowSize] our SETTINGS_INITIAL_WINDOW_SIZE (receive window per
114
+ * stream). Defaults to curl's 10 MiB; tests lower it to exercise flow control.
115
+ * @property {number} [connectionWindow] the connection receive window we open with a WINDOW_UPDATE
116
+ * right after SETTINGS. Defaults to curl's 1000 MiB.
117
+ * @property {number} [maxConcurrentStreams] our advertised SETTINGS_MAX_CONCURRENT_STREAMS.
118
+ * @property {number} [maxHeaderTableSize] our advertised SETTINGS_HEADER_TABLE_SIZE.
119
+ * @property {number} [maxHeaderListSize] self-protection cap on a decoded response header list.
120
+ * @property {(err: Error | null) => void} [onClose] called once when the connection dies, so a
121
+ * registry can drop it.
122
+ */
123
+
124
+ export class Http2Connection {
125
+ /**
126
+ * @param {import('./frames.js').ByteDuplex | { readable: ReadableStream<Uint8Array>,
127
+ * writable: WritableStream<Uint8Array> }} duplex plaintext transport (a TLS session's
128
+ * plaintextDuplex, or a raw socket for cleartext h2 in tests)
129
+ * @param {Http2ConnectionOptions} [opts]
130
+ */
131
+ constructor(duplex, opts = {}) {
132
+ this._reader = new ByteReader(duplex.readable);
133
+ this._writer = new ByteWriter(duplex.writable);
134
+ this._closeTransport = () => duplex.close?.();
135
+ this.info = opts.info ?? null;
136
+
137
+ // Our advertised settings. The defaults ARE the fingerprint (see constants.js); overrides
138
+ // exist for tests, and shifting them shifts what the server sees, so production leaves them.
139
+ this._ourInitialWindow = opts.initialWindowSize ?? CLIENT_INITIAL_WINDOW_SIZE;
140
+ this._ourConnWindow = opts.connectionWindow ?? CLIENT_CONNECTION_WINDOW;
141
+ this._ourMaxConcurrent = opts.maxConcurrentStreams ?? CLIENT_MAX_CONCURRENT_STREAMS;
142
+ this._ourHeaderTableSize = opts.maxHeaderTableSize ?? DEFAULT_HEADER_TABLE_SIZE;
143
+
144
+ // Peer settings, at their protocol defaults until the server's SETTINGS arrives.
145
+ this._peerInitialWindow = DEFAULT_INITIAL_WINDOW;
146
+ this._peerMaxFrameSize = DEFAULT_MAX_FRAME_SIZE;
147
+ this._peerMaxConcurrent = Infinity; // unknown until told; unlimited by default (RFC 9113 s6.5.2)
148
+ this._peerHeaderTableSize = DEFAULT_HEADER_TABLE_SIZE;
149
+
150
+ // Receive-side flow control (what the peer may send us). Connection window jumps to
151
+ // `_ourConnWindow` the moment our opening WINDOW_UPDATE is written.
152
+ this._connRecvWindow = DEFAULT_INITIAL_WINDOW;
153
+ this._connConsumed = 0; // bytes consumed since the last connection WINDOW_UPDATE we sent
154
+ // Send-side flow control (what WE may send the peer), connection level.
155
+ this._connSendWindow = DEFAULT_INITIAL_WINDOW;
156
+ /** @type {Array<() => void>} wakers for senders blocked on the connection window */
157
+ this._connSendWaiters = [];
158
+
159
+ this._decoder = new HpackDecoder({
160
+ maxTableSize: this._ourHeaderTableSize,
161
+ maxHeaderListSize: opts.maxHeaderListSize,
162
+ });
163
+
164
+ /** @type {Map<number, any>} live streams by id */
165
+ this._streams = new Map();
166
+ this._nextStreamId = 1; // client-initiated streams are odd (RFC 9113 s5.1.1)
167
+ this._lastPeerStreamId = 0;
168
+
169
+ // Header-block continuation state: while assembling a HEADERS+CONTINUATION run, no other frame
170
+ // may interleave (RFC 9113 s6.10). Non-null means "the next frame must be CONTINUATION on this
171
+ // stream id".
172
+ this._continuation = null; // { streamId, fragments: Uint8Array[], endStream, kind }
173
+ this._expectFirstSettings = true;
174
+
175
+ this._fatal = null; // set once; rejects every stream and every future request
176
+ this._goaway = null; // { lastStreamId, errorCode } received from the peer
177
+ this._closed = false;
178
+ this._onClose = opts.onClose ?? null;
179
+ this._writeChain = Promise.resolve();
180
+
181
+ // Kick off the preface flight and the read loop. Neither is awaited here: the constructor
182
+ // returns a usable connection and request() serialises behind the preface via the write chain.
183
+ this._sendPreface();
184
+ this._readLoop().catch((err) => this._die(err));
185
+ }
186
+
187
+ /** Whether a new request may be dispatched onto this connection right now. */
188
+ canDispatch() {
189
+ return (
190
+ !this._fatal &&
191
+ !this._closed &&
192
+ !this._goaway &&
193
+ this._streams.size < this._peerMaxConcurrent &&
194
+ this._nextStreamId <= 0x7fffffff
195
+ );
196
+ }
197
+
198
+ get activeStreams() {
199
+ return this._streams.size;
200
+ }
201
+
202
+ // ------------------------------------------------------------------ preface / writing
203
+
204
+ _sendPreface() {
205
+ // Exactly curl's flight and order: the 24-byte magic, then SETTINGS (ids 3,4,2), then a
206
+ // connection-level WINDOW_UPDATE that raises the receive window to 1000 MiB. See constants.js.
207
+ const settings = settingsFrame([
208
+ [SETTINGS.MAX_CONCURRENT_STREAMS, this._ourMaxConcurrent],
209
+ [SETTINGS.INITIAL_WINDOW_SIZE, this._ourInitialWindow],
210
+ [SETTINGS.ENABLE_PUSH, 0],
211
+ ]);
212
+ const inc = this._ourConnWindow - DEFAULT_INITIAL_WINDOW;
213
+ const flight =
214
+ inc > 0
215
+ ? concat([CONNECTION_PREFACE, settings, windowUpdateFrame(0, inc)])
216
+ : concat([CONNECTION_PREFACE, settings]);
217
+ this._connRecvWindow = this._ourConnWindow;
218
+ this._write(flight);
219
+ }
220
+
221
+ /** Serialise a wire write behind every previous one, so a header block is never split by another
222
+ * frame and the preface always leads. Mirrors the record layer's write discipline. */
223
+ _write(bytes) {
224
+ const task = this._writeChain.then(() => this._writer.write(bytes));
225
+ this._writeChain = task.then(
226
+ () => undefined,
227
+ () => undefined,
228
+ );
229
+ return task;
230
+ }
231
+
232
+ // ------------------------------------------------------------------ request
233
+
234
+ /**
235
+ * Open a stream and send a request. Resolves once the response header block (the first non-1xx
236
+ * HEADERS) has arrived; the body streams after.
237
+ *
238
+ * @param {object} req
239
+ * @param {string} req.method
240
+ * @param {string} req.scheme
241
+ * @param {string} req.authority
242
+ * @param {string} req.path
243
+ * @param {Array<[string, string]>} req.headers already lowercased, connection-specific ones removed
244
+ * @param {Uint8Array | null} [req.body] buffered whole by the caller, so send flow control is simple
245
+ * @param {AbortSignal} [req.signal] aborting it RST_STREAMs the stream and rejects its promises;
246
+ * this is how a per-request deadline tears down exactly one stream without touching the others
247
+ * @returns {Promise<Http2ResponseHead>}
248
+ */
249
+ request({ method, scheme, authority, path, headers, body, signal }) {
250
+ if (this._fatal) throw this._fatal;
251
+ if (this._closed) throw new Http2Error(codes.HTTP2_PROTOCOL, 'connection is closed');
252
+ if (this._goaway) {
253
+ throw new Http2Retryable(
254
+ codes.HTTP2_GOAWAY,
255
+ 'connection is going away; this stream was never opened',
256
+ { lastStreamId: this._goaway.lastStreamId },
257
+ );
258
+ }
259
+ if (this._streams.size >= this._peerMaxConcurrent) {
260
+ throw new Http2Retryable(
261
+ codes.HTTP2_STREAM_STATE,
262
+ `at the peer's SETTINGS_MAX_CONCURRENT_STREAMS (${this._peerMaxConcurrent})`,
263
+ { limit: this._peerMaxConcurrent },
264
+ );
265
+ }
266
+ const id = this._nextStreamId;
267
+ this._nextStreamId += 2;
268
+
269
+ const stream = this._createStream(id);
270
+ if (signal) {
271
+ if (signal.aborted) {
272
+ this._resetStream(stream, H2_ERROR.CANCEL, signal.reason ?? new Http2Error(codes.HTTP2_PROTOCOL, 'aborted'));
273
+ return stream.head.promise;
274
+ }
275
+ const onAbort = () => {
276
+ if (!stream.closed) {
277
+ this._resetStream(stream, H2_ERROR.CANCEL, signal.reason ?? new Http2Error(codes.HTTP2_PROTOCOL, 'aborted'));
278
+ }
279
+ };
280
+ signal.addEventListener('abort', onAbort, { once: true });
281
+ }
282
+ const hasBody = body != null && body.byteLength > 0;
283
+
284
+ const fields = buildRequestFields({ method, scheme, authority, path, headers });
285
+ const block = encodeHeaderBlock(fields);
286
+ this._sendHeaderBlock(id, block, !hasBody);
287
+ stream.localEnded = !hasBody;
288
+
289
+ if (hasBody) {
290
+ // Send the body respecting flow control. Not awaited: a large body may block on WINDOW_UPDATE,
291
+ // and blocking request() would stop the caller from ever reading the response head that
292
+ // unblocks it. Errors surface on the stream, which is what the caller is awaiting.
293
+ this._sendBody(id, body, stream).catch((err) => this._failStream(stream, err));
294
+ }
295
+ return stream.head.promise;
296
+ }
297
+
298
+ _createStream(id) {
299
+ const stream = {
300
+ id,
301
+ // response head
302
+ head: deferred(),
303
+ responseReceived: false,
304
+ // body plumbing: an in-memory queue drained by the body stream's pull, so WINDOW_UPDATE is
305
+ // tied to the consumer, not to arrival.
306
+ /** @type {Uint8Array[]} */
307
+ recvQueue: [],
308
+ recvEnded: false,
309
+ /** @type {Error | null} */
310
+ bodyError: null,
311
+ /** @type {(() => void) | null} */
312
+ pullWaiter: null,
313
+ completed: deferred(),
314
+ trailers: deferred(),
315
+ /** @type {Headers | null} */
316
+ trailerFields: null,
317
+ // flow control
318
+ recvWindow: this._ourInitialWindow,
319
+ recvConsumed: 0,
320
+ sendWindow: this._peerInitialWindow,
321
+ /** @type {Array<() => void>} */
322
+ sendWaiters: [],
323
+ localEnded: false,
324
+ cancelled: false,
325
+ closed: false,
326
+ rstSent: false,
327
+ };
328
+ stream.body = this._makeBodyStream(stream);
329
+ this._streams.set(id, stream);
330
+ return stream;
331
+ }
332
+
333
+ /** Send a header block as HEADERS plus CONTINUATION frames if it overflows one frame. The whole
334
+ * run is one write, so no other frame can interleave it (RFC 9113 s6.10). */
335
+ _sendHeaderBlock(streamId, block, endStream) {
336
+ const max = this._peerMaxFrameSize;
337
+ if (block.length <= max) {
338
+ this._write(headersFrame(streamId, block, { endStream, endHeaders: true }));
339
+ return;
340
+ }
341
+ const frames = [];
342
+ let o = 0;
343
+ const first = block.subarray(0, max);
344
+ frames.push(headersFrame(streamId, first, { endStream, endHeaders: false }));
345
+ o = max;
346
+ while (o < block.length) {
347
+ const chunk = block.subarray(o, Math.min(o + max, block.length));
348
+ o += chunk.length;
349
+ frames.push(continuationFrame(streamId, chunk, o >= block.length));
350
+ }
351
+ this._write(concat(frames));
352
+ }
353
+
354
+ /** Send a request body as DATA frames, respecting stream and connection send windows. */
355
+ async _sendBody(streamId, body, stream) {
356
+ let offset = 0;
357
+ while (offset < body.byteLength) {
358
+ if (stream.closed || stream.cancelled) return; // reset or cancelled underneath us
359
+ const room = Math.min(stream.sendWindow, this._connSendWindow);
360
+ if (room <= 0) {
361
+ await this._awaitSendWindow(stream);
362
+ continue;
363
+ }
364
+ const n = Math.min(room, this._peerMaxFrameSize, body.byteLength - offset);
365
+ const slice = body.subarray(offset, offset + n);
366
+ offset += n;
367
+ stream.sendWindow -= n;
368
+ this._connSendWindow -= n;
369
+ const end = offset >= body.byteLength;
370
+ await this._write(dataFrame(streamId, slice, end));
371
+ if (end) stream.localEnded = true;
372
+ }
373
+ }
374
+
375
+ /** Block until either the stream or the connection send window grows (a WINDOW_UPDATE arrives). */
376
+ _awaitSendWindow(stream) {
377
+ if (this._fatal) return Promise.reject(this._fatal);
378
+ return new Promise((resolve) => {
379
+ stream.sendWaiters.push(resolve);
380
+ this._connSendWaiters.push(resolve);
381
+ });
382
+ }
383
+
384
+ _wakeSendWaiters(stream) {
385
+ const wake = (list) => {
386
+ const waiters = list.splice(0);
387
+ for (const w of waiters) w();
388
+ };
389
+ if (stream) wake(stream.sendWaiters);
390
+ wake(this._connSendWaiters);
391
+ }
392
+
393
+ // ------------------------------------------------------------------ body stream / receive FC
394
+
395
+ _makeBodyStream(stream) {
396
+ const self = this;
397
+ const rs = new ReadableStream(
398
+ {
399
+ // highWaterMark 0: pull only when a consumer actually reads, so delivering a chunk here IS
400
+ // the moment of consumption — which is exactly when the flow-control window may be reopened.
401
+ async pull(controller) {
402
+ for (;;) {
403
+ if (stream.recvQueue.length > 0) {
404
+ const chunk = stream.recvQueue.shift();
405
+ controller.enqueue(chunk);
406
+ self._consumeStream(stream, chunk.byteLength);
407
+ return;
408
+ }
409
+ if (stream.bodyError) {
410
+ controller.error(stream.bodyError);
411
+ self._settleReject(stream.completed, stream.bodyError);
412
+ return;
413
+ }
414
+ if (stream.recvEnded) {
415
+ controller.close();
416
+ self._settleResolve(stream.completed, true);
417
+ self._settleResolve(stream.trailers, stream.trailerFields);
418
+ return;
419
+ }
420
+ await new Promise((resolve) => {
421
+ stream.pullWaiter = resolve;
422
+ });
423
+ }
424
+ },
425
+ cancel() {
426
+ // The caller abandoned the body at an unknown position. Tell the peer to stop (RST_STREAM
427
+ // CANCEL) and settle the completion contract as "not finished" (resolve false, not reject).
428
+ stream.cancelled = true;
429
+ self._settleResolve(stream.completed, false);
430
+ self._settleResolve(stream.trailers, null);
431
+ self._sendRst(stream, H2_ERROR.CANCEL);
432
+ self._removeStream(stream);
433
+ return undefined;
434
+ },
435
+ },
436
+ { highWaterMark: 0 },
437
+ );
438
+ return Object.assign(rs, { completed: stream.completed.promise, trailers: stream.trailers.promise });
439
+ }
440
+
441
+ _wakePull(stream) {
442
+ if (stream.pullWaiter) {
443
+ const w = stream.pullWaiter;
444
+ stream.pullWaiter = null;
445
+ w();
446
+ }
447
+ }
448
+
449
+ /** Called as the CONSUMER drains `n` bytes: reopen the stream and connection receive windows,
450
+ * batched so a byte-at-a-time consumer does not produce a WINDOW_UPDATE storm. */
451
+ _consumeStream(stream, n) {
452
+ this._replenish(stream, n);
453
+ this._replenishConn(n);
454
+ }
455
+
456
+ _replenish(stream, n) {
457
+ stream.recvConsumed += n;
458
+ stream.recvWindow += n;
459
+ const threshold = Math.max(1, this._ourInitialWindow >> 1);
460
+ if (stream.recvConsumed >= threshold && !stream.closed) {
461
+ const inc = stream.recvConsumed;
462
+ stream.recvConsumed = 0;
463
+ this._write(windowUpdateFrame(stream.id, inc));
464
+ }
465
+ }
466
+
467
+ _replenishConn(n) {
468
+ this._connConsumed += n;
469
+ this._connRecvWindow += n;
470
+ const threshold = Math.max(1, this._ourConnWindow >> 1);
471
+ if (this._connConsumed >= threshold) {
472
+ const inc = this._connConsumed;
473
+ this._connConsumed = 0;
474
+ this._write(windowUpdateFrame(0, inc));
475
+ }
476
+ }
477
+
478
+ // ------------------------------------------------------------------ read loop
479
+
480
+ async _readLoop() {
481
+ for (;;) {
482
+ const frame = await readFrame(this._reader, DEFAULT_MAX_FRAME_SIZE);
483
+ if (frame === null) {
484
+ // Clean transport EOF. Any stream still open ended without END_STREAM — a truncation.
485
+ this._die(
486
+ this._streams.size === 0
487
+ ? null
488
+ : new Http2Error(codes.HTTP2_PROTOCOL, 'connection closed with streams still open'),
489
+ );
490
+ return;
491
+ }
492
+ if (this._fatal) return;
493
+ this._dispatchFrame(frame);
494
+ }
495
+ }
496
+
497
+ _dispatchFrame(frame) {
498
+ const { type, flags, streamId, payload } = frame;
499
+
500
+ // A header block in progress may be interrupted by nothing but its own CONTINUATION.
501
+ if (this._continuation) {
502
+ if (type !== FRAME.CONTINUATION || streamId !== this._continuation.streamId) {
503
+ this._die(
504
+ new Http2Error(
505
+ codes.HTTP2_PROTOCOL,
506
+ `expected CONTINUATION on stream ${this._continuation.streamId}, got frame type ` +
507
+ `${type} on stream ${streamId}`,
508
+ { expectedStream: this._continuation.streamId, gotType: type, gotStream: streamId },
509
+ ),
510
+ );
511
+ return;
512
+ }
513
+ this._onContinuation(flags, payload);
514
+ return;
515
+ }
516
+
517
+ // The very first frame from the server must be a SETTINGS frame (RFC 9113 s3.4).
518
+ if (this._expectFirstSettings) {
519
+ if (type !== FRAME.SETTINGS) {
520
+ this._die(
521
+ new Http2Error(
522
+ codes.HTTP2_PROTOCOL,
523
+ `first frame from the server was type ${type}, expected SETTINGS`,
524
+ { type },
525
+ ),
526
+ );
527
+ return;
528
+ }
529
+ this._expectFirstSettings = false;
530
+ }
531
+
532
+ switch (type) {
533
+ case FRAME.SETTINGS:
534
+ this._onSettings(flags, streamId, payload);
535
+ break;
536
+ case FRAME.HEADERS:
537
+ this._onHeaders(flags, streamId, payload);
538
+ break;
539
+ case FRAME.DATA:
540
+ this._onData(flags, streamId, payload);
541
+ break;
542
+ case FRAME.WINDOW_UPDATE:
543
+ this._onWindowUpdate(streamId, payload);
544
+ break;
545
+ case FRAME.RST_STREAM:
546
+ this._onRstStream(streamId, payload);
547
+ break;
548
+ case FRAME.PING:
549
+ this._onPing(flags, streamId, payload);
550
+ break;
551
+ case FRAME.GOAWAY:
552
+ this._onGoaway(payload);
553
+ break;
554
+ case FRAME.PUSH_PROMISE:
555
+ // We advertised SETTINGS_ENABLE_PUSH = 0, so a PUSH_PROMISE is a protocol violation, not a
556
+ // resource to accept (RFC 9113 s8.4). Fail the whole connection closed.
557
+ this._die(
558
+ new Http2Error(
559
+ codes.HTTP2_PUSH_UNEXPECTED,
560
+ 'server sent PUSH_PROMISE despite SETTINGS_ENABLE_PUSH = 0',
561
+ ),
562
+ );
563
+ break;
564
+ case FRAME.PRIORITY:
565
+ // The priority scheme is deprecated (RFC 9113 s5.3.2). A well-formed PRIORITY frame is
566
+ // accepted and ignored; a mis-sized one is still a stream-level FRAME_SIZE_ERROR.
567
+ if (payload.length !== 5) {
568
+ this._die(
569
+ new Http2Error(codes.HTTP2_FRAME_SIZE, `PRIORITY payload is ${payload.length} bytes, must be 5`),
570
+ );
571
+ }
572
+ break;
573
+ case FRAME.CONTINUATION:
574
+ // A CONTINUATION with no HEADERS in progress has nothing to continue.
575
+ this._die(
576
+ new Http2Error(codes.HTTP2_PROTOCOL, 'CONTINUATION frame with no open header block'),
577
+ );
578
+ break;
579
+ default:
580
+ // Unknown frame types MUST be ignored (RFC 9113 s5.5) — this is deliberate and NOT a
581
+ // relaxation of the fail-closed rule: an unknown frame is fully length-delimited, so
582
+ // skipping it is unambiguous, and real servers (ALTSVC, ORIGIN, GREASE) send extension
583
+ // frames that a client refusing them could never reach. The one place they are refused is
584
+ // mid-header-block above, where s6.10 makes any interleaved frame a connection error.
585
+ break;
586
+ }
587
+ }
588
+
589
+ _onSettings(flags, streamId, payload) {
590
+ if (streamId !== 0) {
591
+ this._die(new Http2Error(codes.HTTP2_PROTOCOL, 'SETTINGS on a non-zero stream'));
592
+ return;
593
+ }
594
+ if (flags & FLAG.ACK) {
595
+ if (payload.length !== 0) {
596
+ this._die(new Http2Error(codes.HTTP2_FRAME_SIZE, 'SETTINGS ACK must have an empty payload'));
597
+ }
598
+ return; // our SETTINGS were acknowledged; nothing to apply
599
+ }
600
+ let entries;
601
+ try {
602
+ entries = parseSettings(payload);
603
+ } catch (err) {
604
+ this._die(err);
605
+ return;
606
+ }
607
+ for (const [id, value] of entries) {
608
+ switch (id) {
609
+ case SETTINGS.INITIAL_WINDOW_SIZE: {
610
+ if (value > MAX_WINDOW) {
611
+ this._die(
612
+ new Http2Error(
613
+ codes.HTTP2_FLOW_CONTROL,
614
+ `SETTINGS_INITIAL_WINDOW_SIZE ${value} exceeds ${MAX_WINDOW}`,
615
+ { value },
616
+ ),
617
+ );
618
+ return;
619
+ }
620
+ // A change retroactively adjusts every open stream's SEND window by the delta
621
+ // (RFC 9113 s6.9.2).
622
+ const delta = value - this._peerInitialWindow;
623
+ this._peerInitialWindow = value;
624
+ for (const stream of this._streams.values()) {
625
+ stream.sendWindow += delta;
626
+ if (stream.sendWindow > 0) this._wakeSendWaiters(stream);
627
+ }
628
+ break;
629
+ }
630
+ case SETTINGS.MAX_FRAME_SIZE:
631
+ if (value < DEFAULT_MAX_FRAME_SIZE || value > MAX_ALLOWED_FRAME_SIZE) {
632
+ this._die(
633
+ new Http2Error(codes.HTTP2_PROTOCOL, `illegal SETTINGS_MAX_FRAME_SIZE ${value}`, { value }),
634
+ );
635
+ return;
636
+ }
637
+ this._peerMaxFrameSize = value;
638
+ break;
639
+ case SETTINGS.MAX_CONCURRENT_STREAMS:
640
+ this._peerMaxConcurrent = value;
641
+ break;
642
+ case SETTINGS.HEADER_TABLE_SIZE:
643
+ this._peerHeaderTableSize = value;
644
+ break;
645
+ case SETTINGS.ENABLE_PUSH:
646
+ if (value !== 0 && value !== 1) {
647
+ this._die(new Http2Error(codes.HTTP2_PROTOCOL, `illegal SETTINGS_ENABLE_PUSH ${value}`));
648
+ return;
649
+ }
650
+ break;
651
+ default:
652
+ break; // unknown settings are ignored (RFC 9113 s6.5.2)
653
+ }
654
+ }
655
+ // Acknowledge, as required (RFC 9113 s6.5.3).
656
+ this._write(settingsFrame([], true));
657
+ }
658
+
659
+ _onHeaders(flags, streamId, payload) {
660
+ if (streamId === 0 || (streamId & 1) === 0) {
661
+ // A response arrives on the odd, client-initiated stream we opened; 0 and even ids are wrong.
662
+ this._die(new Http2Error(codes.HTTP2_PROTOCOL, `HEADERS on invalid stream ${streamId}`));
663
+ return;
664
+ }
665
+ let fragment;
666
+ try {
667
+ fragment = headersBlockFragment(payload, flags);
668
+ } catch (err) {
669
+ this._die(err);
670
+ return;
671
+ }
672
+ const endStream = (flags & FLAG.END_STREAM) !== 0;
673
+ if (flags & FLAG.END_HEADERS) {
674
+ this._completeHeaderBlock(streamId, fragment, endStream);
675
+ } else {
676
+ this._continuation = { streamId, fragments: [fragment.slice()], endStream };
677
+ }
678
+ }
679
+
680
+ _onContinuation(flags, payload) {
681
+ this._continuation.fragments.push(payload.slice());
682
+ if (flags & FLAG.END_HEADERS) {
683
+ const { streamId, fragments, endStream } = this._continuation;
684
+ this._continuation = null;
685
+ this._completeHeaderBlock(streamId, concat(fragments), endStream);
686
+ }
687
+ }
688
+
689
+ /** A full header block has been assembled: HPACK-decode it (connection-fatal on failure, since
690
+ * HPACK state is shared) and route it to the stream as a response head or as trailers. */
691
+ _completeHeaderBlock(streamId, block, endStream) {
692
+ let pairs;
693
+ try {
694
+ pairs = this._decoder.decode(block);
695
+ } catch (err) {
696
+ // An HPACK error corrupts the shared decoder for every stream, so it is a connection error
697
+ // of type COMPRESSION_ERROR (RFC 9113 s4.3), never a stream-local one.
698
+ this._die(err);
699
+ return;
700
+ }
701
+ const stream = this._streams.get(streamId);
702
+ if (!stream) {
703
+ // HEADERS for a stream we have closed. It cost us HPACK work (already done, so the table is
704
+ // consistent) but we owe it nothing else; ignore it rather than tear the connection down.
705
+ return;
706
+ }
707
+ if (!stream.responseReceived) {
708
+ this._deliverResponseHead(stream, pairs, endStream);
709
+ } else {
710
+ this._deliverTrailers(stream, pairs, endStream);
711
+ }
712
+ }
713
+
714
+ _deliverResponseHead(stream, pairs, endStream) {
715
+ let head;
716
+ try {
717
+ head = parseResponseHeaders(pairs);
718
+ } catch (err) {
719
+ // A malformed response is a STREAM error: reset this stream, leave the connection and its
720
+ // other streams untouched (RFC 9113 s8.1.1).
721
+ this._resetStream(stream, H2_ERROR.PROTOCOL_ERROR, err);
722
+ return;
723
+ }
724
+ if (head.status >= 100 && head.status <= 199) {
725
+ // Interim (1xx) response. It never carries a body and is not the real response; wait for the
726
+ // final HEADERS. An interim response with END_STREAM is malformed.
727
+ if (endStream) {
728
+ this._resetStream(
729
+ stream,
730
+ H2_ERROR.PROTOCOL_ERROR,
731
+ new Http2Error(codes.HTTP2_HEADER, `interim ${head.status} response ended the stream`),
732
+ );
733
+ }
734
+ return;
735
+ }
736
+ stream.responseReceived = true;
737
+ if (endStream) {
738
+ // No body and no trailers: the completion contract is satisfiable now, exactly like the h1
739
+ // "complete at creation" case, so a caller that never reads the (empty) body still lets the
740
+ // deadline dispose.
741
+ stream.recvEnded = true;
742
+ this._settleResolve(stream.completed, true);
743
+ this._settleResolve(stream.trailers, null);
744
+ this._wakePull(stream);
745
+ }
746
+ this._settleResolve(stream.head, {
747
+ status: head.status,
748
+ statusText: '',
749
+ headers: head.headers,
750
+ setCookie: head.setCookie,
751
+ httpVersion: '2',
752
+ body: stream.body,
753
+ });
754
+ if (endStream) this._maybeCloseStream(stream);
755
+ }
756
+
757
+ _deliverTrailers(stream, pairs, endStream) {
758
+ if (!endStream) {
759
+ // A second header block that is not the end can only be trailers, and trailers END the
760
+ // stream by definition (RFC 9113 s8.1).
761
+ this._resetStream(
762
+ stream,
763
+ H2_ERROR.PROTOCOL_ERROR,
764
+ new Http2Error(codes.HTTP2_TRAILER, 'trailing HEADERS without END_STREAM'),
765
+ );
766
+ return;
767
+ }
768
+ try {
769
+ stream.trailerFields = parseTrailers(pairs);
770
+ } catch (err) {
771
+ this._resetStream(stream, H2_ERROR.PROTOCOL_ERROR, err);
772
+ return;
773
+ }
774
+ stream.recvEnded = true;
775
+ this._wakePull(stream);
776
+ this._maybeCloseStream(stream);
777
+ }
778
+
779
+ _onData(flags, streamId, payload) {
780
+ const stream = this._streams.get(streamId);
781
+ // Flow control is accounted at the connection level for EVERY DATA frame, even one for a
782
+ // stream we have already closed — the peer spent connection window to send it, and not
783
+ // crediting it back would slowly starve the connection (RFC 9113 s6.9.1).
784
+ const flowLen = payload.byteLength;
785
+ this._connRecvWindow -= flowLen;
786
+ if (this._connRecvWindow < 0) {
787
+ this._die(
788
+ new Http2Error(codes.HTTP2_FLOW_CONTROL, 'peer overran the connection flow-control window'),
789
+ );
790
+ return;
791
+ }
792
+ if (!stream || stream.closed) {
793
+ // No consumer will ever drain these bytes, so credit the whole frame back now.
794
+ this._replenishConn(flowLen);
795
+ return;
796
+ }
797
+ if (!stream.responseReceived) {
798
+ this._resetStream(
799
+ stream,
800
+ H2_ERROR.PROTOCOL_ERROR,
801
+ new Http2Error(codes.HTTP2_STREAM_STATE, 'DATA before response HEADERS'),
802
+ );
803
+ return;
804
+ }
805
+ let data;
806
+ try {
807
+ data = flags & FLAG.PADDED ? stripPadding(payload).data : payload;
808
+ } catch (err) {
809
+ this._die(err);
810
+ return;
811
+ }
812
+ stream.recvWindow -= flowLen;
813
+ if (stream.recvWindow < 0) {
814
+ this._resetStream(
815
+ stream,
816
+ H2_ERROR.FLOW_CONTROL_ERROR,
817
+ new Http2Error(codes.HTTP2_FLOW_CONTROL, 'peer overran the stream window'),
818
+ );
819
+ return;
820
+ }
821
+ // Padding (and the pad-length byte) is discarded, so it is consumed immediately for
822
+ // flow-control purposes; only the payload's data defers to the consumer.
823
+ const overhead = flowLen - data.byteLength;
824
+ if (overhead > 0) this._replenishConn(overhead);
825
+ // The stream window was debited by flowLen; credit the overhead back on the stream too.
826
+ if (overhead > 0) this._replenish(stream, overhead);
827
+ if (data.byteLength > 0) {
828
+ stream.recvQueue.push(data.slice());
829
+ this._wakePull(stream);
830
+ }
831
+ if (flags & FLAG.END_STREAM) {
832
+ stream.recvEnded = true;
833
+ this._wakePull(stream);
834
+ this._maybeCloseStream(stream);
835
+ }
836
+ }
837
+
838
+ _onWindowUpdate(streamId, payload) {
839
+ let inc;
840
+ try {
841
+ inc = parseWindowUpdate(payload);
842
+ } catch (err) {
843
+ this._die(err);
844
+ return;
845
+ }
846
+ if (inc === 0) {
847
+ // A zero increment is a PROTOCOL_ERROR — connection-level when on stream 0, else stream-level.
848
+ if (streamId === 0) {
849
+ this._die(new Http2Error(codes.HTTP2_PROTOCOL, 'connection WINDOW_UPDATE with a zero increment'));
850
+ } else {
851
+ const stream = this._streams.get(streamId);
852
+ if (stream) {
853
+ this._resetStream(
854
+ stream,
855
+ H2_ERROR.PROTOCOL_ERROR,
856
+ new Http2Error(codes.HTTP2_PROTOCOL, 'zero WINDOW_UPDATE increment'),
857
+ );
858
+ }
859
+ }
860
+ return;
861
+ }
862
+ if (streamId === 0) {
863
+ this._connSendWindow += inc;
864
+ if (this._connSendWindow > MAX_WINDOW) {
865
+ this._die(new Http2Error(codes.HTTP2_FLOW_CONTROL, 'connection send window exceeded 2^31-1'));
866
+ return;
867
+ }
868
+ this._wakeSendWaiters(null);
869
+ } else {
870
+ const stream = this._streams.get(streamId);
871
+ if (!stream) return; // WINDOW_UPDATE for a closed stream is harmless and ignored
872
+ stream.sendWindow += inc;
873
+ if (stream.sendWindow > MAX_WINDOW) {
874
+ this._resetStream(
875
+ stream,
876
+ H2_ERROR.FLOW_CONTROL_ERROR,
877
+ new Http2Error(codes.HTTP2_FLOW_CONTROL, 'stream send window exceeded 2^31-1'),
878
+ );
879
+ return;
880
+ }
881
+ this._wakeSendWaiters(stream);
882
+ }
883
+ }
884
+
885
+ _onRstStream(streamId, payload) {
886
+ let errorCode;
887
+ try {
888
+ errorCode = parseRstStream(payload);
889
+ } catch (err) {
890
+ this._die(err);
891
+ return;
892
+ }
893
+ if (streamId === 0) {
894
+ this._die(new Http2Error(codes.HTTP2_PROTOCOL, 'RST_STREAM on stream 0'));
895
+ return;
896
+ }
897
+ const stream = this._streams.get(streamId);
898
+ if (!stream) return;
899
+ const name = H2_ERROR_NAME[errorCode] ?? `0x${errorCode.toString(16)}`;
900
+ // REFUSED_STREAM specifically means the server did not process the request (RFC 9113 s8.7), so
901
+ // it is safe to retry on a fresh connection — the same "server never saw it" guarantee the h1
902
+ // path relies on. Everything else is reported as-is.
903
+ const retryable = errorCode === H2_ERROR.REFUSED_STREAM && !stream.responseReceived;
904
+ const err = retryable
905
+ ? new Http2Retryable(codes.HTTP2_STREAM_CLOSED, 'server refused the stream (REFUSED_STREAM)', { errorCode })
906
+ : new Http2Error(codes.HTTP2_STREAM_CLOSED, `server reset the stream: ${name}`, { errorCode });
907
+ this._failStream(stream, err);
908
+ }
909
+
910
+ _onPing(flags, streamId, payload) {
911
+ if (streamId !== 0) {
912
+ this._die(new Http2Error(codes.HTTP2_PROTOCOL, 'PING on a non-zero stream'));
913
+ return;
914
+ }
915
+ if (payload.length !== 8) {
916
+ this._die(new Http2Error(codes.HTTP2_FRAME_SIZE, `PING payload is ${payload.length} bytes, must be 8`));
917
+ return;
918
+ }
919
+ if (flags & FLAG.ACK) return; // a reply to a PING we never send; ignore
920
+ this._write(pingFrame(payload.slice(), true)); // echo the opaque data (RFC 9113 s6.7)
921
+ }
922
+
923
+ _onGoaway(payload) {
924
+ let g;
925
+ try {
926
+ g = parseGoaway(payload);
927
+ } catch (err) {
928
+ this._die(err);
929
+ return;
930
+ }
931
+ this._goaway = { lastStreamId: g.lastStreamId, errorCode: g.errorCode };
932
+ // Streams the server never committed to (id > lastStreamId) provably were not processed and
933
+ // may be retried elsewhere; streams within the promise keep running until they finish or the
934
+ // transport dies.
935
+ for (const stream of this._streams.values()) {
936
+ if (stream.id > g.lastStreamId && !stream.responseReceived) {
937
+ const name = H2_ERROR_NAME[g.errorCode] ?? `0x${g.errorCode.toString(16)}`;
938
+ this._failStream(
939
+ stream,
940
+ new Http2Retryable(codes.HTTP2_GOAWAY, `stream not processed before GOAWAY (${name})`, {
941
+ errorCode: g.errorCode,
942
+ }),
943
+ );
944
+ }
945
+ }
946
+ if (this._onClose) this._onClose(null); // stop new dispatch; existing streams finish
947
+ }
948
+
949
+ // ------------------------------------------------------------------ stream teardown
950
+ //
951
+ // Three composable, idempotent primitives so every teardown path is exact about two independent
952
+ // questions: does the peer need an RST_STREAM (only when WE abandon a stream it still believes
953
+ // is live), and do the caller's promises reject or resolve. Conflating them is how a stream ends
954
+ // up both RST'd in response to the peer's own RST (illegal) and left un-rejected.
955
+
956
+ /** Forget a stream: drop it from the table and wake anything blocked on it. */
957
+ _removeStream(stream) {
958
+ if (stream.closed) return;
959
+ stream.closed = true;
960
+ this._streams.delete(stream.id);
961
+ this._wakePull(stream);
962
+ this._wakeSendWaiters(stream);
963
+ }
964
+
965
+ /** Reject the caller's promises for a stream. Idempotent via the deferreds' settled flags. */
966
+ _rejectStream(stream, err) {
967
+ if (!stream.bodyError) stream.bodyError = err;
968
+ this._settleReject(stream.head, err);
969
+ this._settleReject(stream.completed, err);
970
+ this._settleReject(stream.trailers, err);
971
+ this._wakePull(stream);
972
+ }
973
+
974
+ /** Send RST_STREAM once, telling the peer to stop spending bandwidth on a stream we gave up on. */
975
+ _sendRst(stream, errorCode) {
976
+ if (stream.rstSent) return;
977
+ stream.rstSent = true;
978
+ if (!this._fatal && !this._closed) this._write(rstStreamFrame(stream.id, errorCode));
979
+ }
980
+
981
+ /** A stream finished cleanly once both halves ended: our request fully sent, END_STREAM received. */
982
+ _maybeCloseStream(stream) {
983
+ if (stream.recvEnded && stream.localEnded) this._removeStream(stream);
984
+ }
985
+
986
+ /** A failure originating with the PEER (its RST_STREAM, its GOAWAY, a connection death): reject
987
+ * the caller, never RST back — RFC 9113 s5.4.2 forbids answering a reset with a reset. */
988
+ _failStream(stream, err) {
989
+ this._removeStream(stream);
990
+ this._rejectStream(stream, err);
991
+ }
992
+
993
+ /** A failure originating with US (malformed response, our timeout, flow-control overrun we caught):
994
+ * tell the peer with RST_STREAM, then reject the caller. */
995
+ _resetStream(stream, errorCode, err) {
996
+ this._sendRst(stream, errorCode);
997
+ this._removeStream(stream);
998
+ if (err) this._rejectStream(stream, err);
999
+ }
1000
+
1001
+ // ------------------------------------------------------------------ connection teardown
1002
+
1003
+ _die(err) {
1004
+ if (this._fatal) return;
1005
+ this._fatal = err ?? new Http2Error(codes.HTTP2_PROTOCOL, 'connection closed');
1006
+ // A GOAWAY on the way out is a courtesy so the peer knows the last stream we handled; failures
1007
+ // are ignored because the transport may already be unusable.
1008
+ if (!this._closed) {
1009
+ this._closed = true;
1010
+ this._write(goawayFrame(this._lastPeerStreamId, err ? H2_ERROR.PROTOCOL_ERROR : H2_ERROR.NO_ERROR)).catch(
1011
+ () => {},
1012
+ );
1013
+ }
1014
+ for (const stream of [...this._streams.values()]) {
1015
+ this._failStream(stream, this._fatal);
1016
+ }
1017
+ void this._writer.close?.();
1018
+ try {
1019
+ void this._reader.cancel?.(this._fatal);
1020
+ } catch {
1021
+ /* already gone */
1022
+ }
1023
+ void Promise.resolve(this._closeTransport()).catch(() => {});
1024
+ if (this._onClose) {
1025
+ const cb = this._onClose;
1026
+ this._onClose = null;
1027
+ cb(err ?? null);
1028
+ }
1029
+ }
1030
+
1031
+ /**
1032
+ * Graceful shutdown: GOAWAY(NO_ERROR), then close the transport. Any live stream is failed.
1033
+ *
1034
+ * The GOAWAY and the writer close are BEST-EFFORT and are not awaited: a peer that has stopped
1035
+ * reading applies backpressure that never clears, and awaiting a courtesy frame into a full
1036
+ * buffer would hang close() forever — the exact trap the record layer avoids with its grace
1037
+ * window. Only the transport close is awaited, because that is what actually releases the socket.
1038
+ */
1039
+ async close() {
1040
+ if (this._closed) return;
1041
+ this._closed = true;
1042
+ this._write(goawayFrame(this._lastPeerStreamId, H2_ERROR.NO_ERROR)).catch(() => {});
1043
+ const err = new Http2Error(codes.HTTP2_PROTOCOL, 'connection closed by client');
1044
+ if (!this._fatal) this._fatal = err;
1045
+ for (const stream of [...this._streams.values()]) this._failStream(stream, err);
1046
+ void this._writer.close?.().catch?.(() => {});
1047
+ try {
1048
+ void this._reader.cancel?.(err);
1049
+ } catch {
1050
+ /* already gone */
1051
+ }
1052
+ await Promise.resolve(this._closeTransport()).catch(() => {});
1053
+ if (this._onClose) {
1054
+ const cb = this._onClose;
1055
+ this._onClose = null;
1056
+ cb(null);
1057
+ }
1058
+ }
1059
+
1060
+ // ------------------------------------------------------------------ settle helpers
1061
+
1062
+ _settleResolve(d, value) {
1063
+ if (d.settled) return;
1064
+ d.settled = true;
1065
+ d.resolve(value);
1066
+ }
1067
+
1068
+ _settleReject(d, err) {
1069
+ if (d.settled) return;
1070
+ d.settled = true;
1071
+ d.reject(err);
1072
+ }
1073
+ }
1074
+
1075
+ // ---------------------------------------------------------------------- header building / parsing
1076
+
1077
+ /**
1078
+ * Build the ordered HPACK field list for a request, pseudo-headers first in curl's order
1079
+ * (:method, :scheme, :authority, :path). :path is emitted "without indexing" and the rest
1080
+ * "incremental", matching the captured curl encoding.
1081
+ *
1082
+ * @param {{ method: string, scheme: string, authority: string, path: string,
1083
+ * headers: Array<[string, string]> }} req
1084
+ * @returns {import('./hpack.js').HpackField[]}
1085
+ */
1086
+ export function buildRequestFields({ method, scheme, authority, path, headers }) {
1087
+ const pseudo = { ':method': method, ':scheme': scheme, ':authority': authority, ':path': path };
1088
+ const fields = [];
1089
+ for (const name of PSEUDO_HEADER_ORDER) {
1090
+ fields.push({ name, value: pseudo[name], indexing: name === ':path' ? 'without' : 'incremental' });
1091
+ }
1092
+ for (const [name, value] of headers) {
1093
+ fields.push({ name, value, indexing: 'incremental' });
1094
+ }
1095
+ return fields;
1096
+ }
1097
+
1098
+ /** Validate and split a decoded RESPONSE header list into status + regular headers + set-cookie. */
1099
+ function parseResponseHeaders(pairs) {
1100
+ let status = null;
1101
+ let sawRegular = false;
1102
+ const headers = new Headers();
1103
+ const setCookie = [];
1104
+ for (const [name, value] of pairs) {
1105
+ if (name.length === 0) {
1106
+ throw new Http2Error(codes.HTTP2_HEADER, 'empty header field name');
1107
+ }
1108
+ if (name[0] === ':') {
1109
+ if (sawRegular) {
1110
+ throw new Http2Error(codes.HTTP2_HEADER, `pseudo-header ${name} appeared after a regular header`);
1111
+ }
1112
+ if (name !== ':status') {
1113
+ throw new Http2Error(codes.HTTP2_HEADER, `unknown response pseudo-header ${name}`, { name });
1114
+ }
1115
+ if (status !== null) {
1116
+ throw new Http2Error(codes.HTTP2_HEADER, 'duplicate :status pseudo-header');
1117
+ }
1118
+ if (!/^[0-9]{3}$/.test(value)) {
1119
+ throw new Http2Error(codes.HTTP2_HEADER, `:status ${JSON.stringify(value)} is not three digits`, { value });
1120
+ }
1121
+ status = Number(value);
1122
+ continue;
1123
+ }
1124
+ assertHeaderNameGrammar(name);
1125
+ if (FORBIDDEN_H2_HEADERS.has(name)) {
1126
+ throw new Http2Error(codes.HTTP2_HEADER, `connection-specific header ${name} is forbidden in HTTP/2`, { name });
1127
+ }
1128
+ if (name === 'te' && value.toLowerCase() !== 'trailers') {
1129
+ throw new Http2Error(codes.HTTP2_HEADER, 'the only legal te value in HTTP/2 is "trailers"');
1130
+ }
1131
+ sawRegular = true;
1132
+ if (name === 'set-cookie') setCookie.push(value);
1133
+ headers.append(name, value);
1134
+ }
1135
+ if (status === null) {
1136
+ throw new Http2Error(codes.HTTP2_HEADER, 'response has no :status pseudo-header');
1137
+ }
1138
+ return { status, headers, setCookie };
1139
+ }
1140
+
1141
+ /** Validate decoded trailers: regular field lines only, never a pseudo-header (RFC 9113 s8.1). */
1142
+ function parseTrailers(pairs) {
1143
+ const trailers = new Headers();
1144
+ for (const [name, value] of pairs) {
1145
+ if (name.length === 0 || name[0] === ':') {
1146
+ throw new Http2Error(codes.HTTP2_TRAILER, `trailer field ${JSON.stringify(name)} is not a regular header`);
1147
+ }
1148
+ assertHeaderNameGrammar(name);
1149
+ if (FORBIDDEN_H2_HEADERS.has(name)) {
1150
+ throw new Http2Error(codes.HTTP2_TRAILER, `connection-specific header ${name} is forbidden in a trailer`);
1151
+ }
1152
+ trailers.append(name, value);
1153
+ }
1154
+ return trailers;
1155
+ }
1156
+
1157
+ /** RFC 9113 s8.2.1: field names are lowercase; an uppercase letter is malformed. Also reject the
1158
+ * bytes the field-name grammar forbids, so a decoded name can never split a downstream parser. */
1159
+ function assertHeaderNameGrammar(name) {
1160
+ for (let i = 0; i < name.length; i++) {
1161
+ const c = name.charCodeAt(i);
1162
+ if (c >= 0x41 && c <= 0x5a) {
1163
+ throw new Http2Error(codes.HTTP2_HEADER, `header name ${JSON.stringify(name)} contains an uppercase letter`, { name });
1164
+ }
1165
+ // Control bytes, space, and the HTTP/2 forbidden separators have no place in a field name.
1166
+ if (c <= 0x20 || c === 0x7f || c === 0x3a /* ':' mid-name */) {
1167
+ throw new Http2Error(codes.HTTP2_HEADER, `header name ${JSON.stringify(name)} contains an illegal byte`, { name });
1168
+ }
1169
+ }
1170
+ }