h2lean 0.0.0-stage → 0.1.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.
@@ -0,0 +1,2068 @@
1
+ // HTTP/2 client connection state machine (h2c and h2s). The mirror of lib/session.js.
2
+ //
3
+ // The server file is hard-locked to its role -- it reads the client preface
4
+ // (session.js:771), refuses even stream ids (session.js:968) and treats the
5
+ // rapid-reset budget as "server side only". A client does the exact inverse on
6
+ // every one of those: it WRITES the preface, opens ODD ids, parses `:status`
7
+ // instead of `:method`, obeys the peer's SETTINGS rather than advertising its
8
+ // own limits, and returns flow-control credit for bytes it consumed instead of
9
+ // granting it from its window. So this is a second state machine sharing the
10
+ // role-neutral primitives, not a `role` flag on the first.
11
+ //
12
+ // Reference for the JS-visible client surface is node's own bundled
13
+ // internal/http2/core.js: connect() (arg normalisation), ClientHttp2Session
14
+ // .request() (defaults + endStream = isPayloadMeaningless(method)) and
15
+ // ClientHttp2Stream (a Duplex). Measured off node v26.12.1 in the same session.
16
+ //
17
+ // The two performance decisions are the server's, ported rather than re-invented
18
+ // (AGENTS.md 1), because a client and a server move the same bytes in opposite
19
+ // directions and the shape of the work is identical:
20
+ // * a whole frame is parsed in place out of the chunk node handed us. A frame
21
+ // that straddles two reads is not assembled at all: its payload is kept as
22
+ // the views of the chunks it arrived in, one view per read. A DATA payload
23
+ // reaches the application that way, which is what node does with the same
24
+ // frame (tools/probe-chunking.js measures both stacks delivering a cut
25
+ // payload as the pieces it arrived in), and a header block is decoded out of
26
+ // the views (lib/hpack.js decodePieces). A padded DATA payload is no copy
27
+ // either: its views are not the payload (RFC 9113 6.1 -- the first octet is
28
+ // the pad length, the last ones are the padding), so the padding is taken
29
+ // off the views once the frame is complete (lib/frames.js trimPadding);
30
+ // * one output staging buffer per batch: every frame produced while handling one
31
+ // read or one tick is serialised into it and leaves through a single write (or a
32
+ // single corked writev for a body that travels by reference above BODY_MIN), so a
33
+ // HEADERS + WINDOW_UPDATE batch costs no per-frame allocation and no per-frame
34
+ // syscall. The credit release has its own coalescing buffer (_windowUpdate), because
35
+ // it is due at the end of a read batch, when the previous one is usually still in
36
+ // flight and the queue would hold it a whole poll cycle.
37
+ // What still differs from the server, deliberately: receive credit is returned for
38
+ // bytes as they ARRIVE, not when the application reads them (so a slow reader does
39
+ // not throttle the peer -- read-side backpressure is a behaviour change that needs
40
+ // its own differential tests, not a performance tweak), and a body is never deferred
41
+ // for lack of queue room because the staging bound above already caps what one
42
+ // connection can make us hold.
43
+ // A fatal inbound frame ends the session the way node's does: GOAWAY on the wire
44
+ // -- flushed out of the staging batch before the socket is closed (_fatal; the
45
+ // server side does it the same way, lib/session.js _fatal -> sendGoaway -> _flush)
46
+ // -- and ERR_HTTP2_ERROR reported on the stream and on the session. Measured
47
+ // against node v26.12.1 on a malformed padded DATA frame
48
+ // (tools/probe-client-pad-bad.js) and on padded HEADERS frames
49
+ // (tools/probe-client-hdr-pad.js).
50
+ // The padded-frame rules are the server's, ported (lib/session.js _headersPrefix
51
+ // and _onDataFrame): a padded HEADERS whose padding does not fit is a connection
52
+ // error of type PROTOCOL_ERROR (RFC 9113 6.2: "If the length of the padding is
53
+ // the length of the frame payload or greater"), and so is a padded frame with no
54
+ // payload at all -- no pad-length octet to read (_beginHeaders refuses both). The
55
+ // DATA rule is RFC 9113 6.1, measured by tools/probe-client-pad-bad.js
56
+ // (_onDataFrame). The response pseudo-header rules are checked in
57
+ // _refuseHeaders before the application sees the object: a response without
58
+ // exactly one :status, a request-only pseudo-header on a response, and a
59
+ // pseudo-header in a trailer block (RFC 9113 8.1) are all per-stream refusals
60
+ // -- ERR_HTTP2_STREAM_ERROR, RST_STREAM(PROTOCOL_ERROR), the session stays up
61
+ // (measured, tools/probe-client-status.js; this closes the empty-block
62
+ // divergence).
63
+ 'use strict';
64
+
65
+ const { EventEmitter } = require('node:events');
66
+ const tls = require('node:tls');
67
+ const f = require('./frames');
68
+ const { Decoder, Encoder, HPackError } = require('./hpack');
69
+ const { nullProto, isFieldName, lowerName, CONNECTION_SPECIFIC } = require('./headers');
70
+ const { ClientHttp2Stream } = require('./client-stream');
71
+ const { pieceByteAt, pieceSlice, pieceConcat, pieceEquals } = require('./pieces');
72
+ // The settings tables, defaults and buffer-path constants are the server's, reused
73
+ // verbatim so a client and a server in this one library never disagree about what a
74
+ // SETTINGS id means, what node advertises, or where the copy/reference break-even sits.
75
+ const {
76
+ OUR_DEFAULTS, PEER_DEFAULTS, SETTINGS_BY_ID, ID_BY_SETTING, BODY_MIN, MAX_OUT_BATCH,
77
+ STAGE_MIN, MAX_SHAPES, hashBlock, hashPieces
78
+ } = require('./session');
79
+
80
+ const {
81
+ DATA, HEADERS, PRIORITY, RST_STREAM, SETTINGS, PUSH_PROMISE, PING, GOAWAY,
82
+ WINDOW_UPDATE, CONTINUATION,
83
+ F_END_STREAM, F_ACK, F_END_HEADERS, F_PADDED, F_PRIORITY,
84
+ NO_ERROR, PROTOCOL_ERROR, FRAME_SIZE_ERROR, COMPRESSION_ERROR, CANCEL,
85
+ HEADER_LEN, PREFACE, PREFACE_LEN, readHeader, writeHeader, trimPadding
86
+ } = f;
87
+
88
+ const MAX_SETTINGS_ENTRIES = 32; // CVE-2020-11080; same bound as the server
89
+ const MAX_CONTINUATIONS = 8; // CVE-2024-28182; same bound as the server
90
+ // RFC 7540 6.9.2 / 6.5.2: what an endpoint that has not been told otherwise runs a
91
+ // stream's flow control with. The reason it matters here is ordering: a server writes
92
+ // its SETTINGS -- advertising its own window -- before it has read ours, so any stream it
93
+ // answers on is sized by OUR advertised value if that has landed by then and by this
94
+ // default if it has not. The client cannot tell which happened, so credit release is
95
+ // planned against the smaller of the two -- see _creditRecv().
96
+ const DEFAULT_WINDOW = 65535;
97
+
98
+ // The field-name checks are the server's, imported rather than restated: `isFieldName`
99
+ // carries the cache of names already known to be legal (lib/headers.js, and
100
+ // OPTIMIZATION.md item 7 for what it measures on the names a request really
101
+ // carries). Re-running an uncached regex here would be the same work the response
102
+ // path has already learned to skip.
103
+
104
+ // Methods RFC 9113 asks a client not to send a body with; node ends the stream by
105
+ // default for these (`isPayloadMeaningless(method)` in core.js).
106
+ function payloadMeaningless(method) {
107
+ return method === 'GET' || method === 'HEAD' || method === 'DELETE' || method === 'CONNECT'
108
+ || method === 'OPTIONS' || method === 'TRACE';
109
+ }
110
+
111
+ class ClientHttp2Session extends EventEmitter {
112
+ constructor(socket, options, scheme, authority) {
113
+ super();
114
+ const opts = options || {};
115
+ this.socket = socket;
116
+ this.type = 1; // NGHTTP2_SESSION_CLIENT
117
+ this.closed = false;
118
+ this.destroyed = false;
119
+ this.connecting = true;
120
+ // encrypted/alpnProtocol are derived from the socket once it is connected
121
+ // (_start), the way node's setupHandle fills them after 'secureConnect':
122
+ // before that node's getters read undefined.
123
+ this.pendingSettingsAck = true;
124
+ this.originSet = [];
125
+ this.localSettings = { ...OUR_DEFAULTS, ...opts.settings };
126
+ this.remoteSettings = { ...PEER_DEFAULTS };
127
+ this._scheme = scheme || 'http';
128
+ this._authority = authority || 'localhost';
129
+
130
+ // Only what the application asked for is advertised (see session.js _advertised:
131
+ // a number we advertise is a number a peer plans around). Empty by default, which
132
+ // is node's usual client SETTINGS frame too.
133
+ this._advertised = new Set((opts.advertised !== undefined
134
+ ? opts.advertised : Object.keys(opts.settings || {}))
135
+ .filter((k) => ID_BY_SETTING[k] !== undefined));
136
+
137
+ this._nextSid = 1; // client owns odd ids
138
+ this._streams = new Map();
139
+ this._outboundQueue = []; // requests waiting for a concurrency slot
140
+ this._decoder = new Decoder(this.localSettings.headerTableSize);
141
+ this._encoder = new Encoder(this.remoteSettings.headerTableSize,
142
+ this.localSettings.headerTableSize);
143
+ this._blocks = new WeakMap();
144
+ this._flat = [];
145
+
146
+ // Send-side windows: our budget to write, governed by the peer's window. Both
147
+ // start at 65535 and move with the peer's SETTINGS delta and its WINDOW_UPDATEs.
148
+ this._connSendWin = PEER_DEFAULTS.initialWindowSize;
149
+ this._prevInitialWin = PEER_DEFAULTS.initialWindowSize;
150
+ // The receive side's mirror: octets consumed from the connection window whose
151
+ // credit is not yet announced. Released in half-window steps -- see
152
+ // _creditRecv() and the server's _creditConnection() for the measurement.
153
+ this._connRecvUsed = 0;
154
+ // The connection receive window we actually maintain: spent by every DATA octet,
155
+ // restored by every WINDOW_UPDATE(0) we send. It starts at the smaller of what we
156
+ // advertise and the 65535 a peer runs with until it has read our SETTINGS, which is
157
+ // the only value we can promise it credit against -- see _creditRecv(). At the
158
+ // default this is the same number the old `>= advertised >> 1` test produced.
159
+ this._connRecvWin = Math.min(this.localSettings.initialWindowSize, DEFAULT_WINDOW);
160
+ // Streams that took DATA since the last credit pass. _creditRecv() visits only
161
+ // these instead of scanning every open stream: with the default 100 concurrent
162
+ // streams a download that touches one of them used to pay ~100 property reads per
163
+ // socket read (OPTIMIZATION.md item 19).
164
+ this._debt = [];
165
+ // The credit pass writes every WINDOW_UPDATE due from one batch into this one
166
+ // session-owned buffer -- reused, never handed to the application -- so the batch
167
+ // costs one small write instead of one allocation and one write call per update.
168
+ this._wuBuf = null;
169
+ this._wuLen = 0;
170
+
171
+ // Inbound parsing, the server's design: frames are read straight out of the chunk
172
+ // node gave us, and a frame that straddles two reads is kept as the views it
173
+ // arrived in. The only octets the session copies are the nine of a frame header
174
+ // that a read split: they wait in this scratch until the header is complete.
175
+ this._fragHdr = Buffer.allocUnsafe(HEADER_LEN);
176
+ this._frag = 0; // bytes of a straddling frame
177
+ this._fragTarget = null;
178
+ // A straddling DATA payload is NOT assembled: it is kept as the views of the
179
+ // chunks the peer sent it in -- one per read -- and handed to the application
180
+ // as they are. Node does the same: its payload reaches the application in the
181
+ // pieces it arrived in, not in one piece per frame (measured with a frame whose
182
+ // payload was cut in two: node's client gets two chunks, tools/probe-chunking.js),
183
+ // so this is parity rather than a shortcut.
184
+ this._fragPieces = null;
185
+ //
186
+ // A PADDED payload is the one case where the views are not the payload: its
187
+ // first octet is the pad length and its last octets are padding (RFC 9113
188
+ // 6.1). The padding is taken off the views when the frame is complete
189
+ // (frames.js trimPadding), so even that case costs no copy.
190
+ // The header of the frame being assembled, kept HERE and not read back from
191
+ // frames.js's FH_TYPE/FH_FLAGS/FH_SID. Those are module-level slots shared by every
192
+ // session in the process, and a straddling frame is dispatched in a LATER _onData()
193
+ // call: another connection's readHeader() in between replaces them, and this session
194
+ // delivers a frame its peer never sent. Measured with 8 sessions x 64 streams of
195
+ // 256 kB (bench/micro/probe-client-framedrop.js): a DATA frame dispatched as WINDOW_UPDATE/0,
196
+ // one whole 16 384-octet frame missing per affected stream, then HPACK
197
+ // COMPRESSION_ERROR and the connection destroyed -- which is what the paired
198
+ // benchmark had been reading as "the client's download is 2.5x slower".
199
+ this._fragType = 0;
200
+ this._fragFlags = 0;
201
+ this._fragSid = 0;
202
+ // CONTINUATION assembly for one inbound header block. A block whose frames all
203
+ // arrived whole is kept in one buffer (_hdr); a block with a frame that straddled
204
+ // a read is kept as the views it arrived in (_hdrPieces).
205
+ this._hdr = null;
206
+ this._hdrPieces = null;
207
+ this._hdrLen = 0;
208
+ this._hdrSid = -1;
209
+ this._hdrEnd = false;
210
+ this._hdrCont = 0;
211
+ // The first octets of a HEADERS payload -- the pad length and the priority
212
+ // block -- are at most six, and a payload that arrived in pieces has no
213
+ // buffer to read them from: they are copied into this scratch.
214
+ this._hdrPrefix = Buffer.allocUnsafe(6);
215
+
216
+ // Outbound: frames are serialised into one staging buffer per batch and queued by
217
+ // reference only when the payload is big enough for the extra writev chunk to beat
218
+ // the memcpy (BODY_MIN). See _frame/_need/_commit/_writePending, which mirror the
219
+ // server's queue exactly.
220
+ this._outQ = [];
221
+ this._stage = null;
222
+ this._stageLen = 0; // bytes of _stage not yet queued
223
+ this._stageCap = STAGE_MIN; // size of the next staging buffer
224
+ this._outLen = 0; // bytes the socket has not been given
225
+ this._writing = false; // one batch in flight per connection
226
+ this._flushScheduled = false;
227
+ this._open = true;
228
+ this._started = false;
229
+ this._queuedReq = null; // requests made before 'connect'
230
+ this._closeRequested = false; // close()/goaway asked to end once drained
231
+ this._goawayCode = null;
232
+ this._goawayLastStreamID = null;
233
+ this._pings = new Map(); // 8-octet payload -> callback
234
+ this._pingSeq = 0;
235
+ // Response header shapes seen before: the cached object for a block whose bytes
236
+ // we have already decoded under this dynamic-table state (the server's cache).
237
+ this._shapes = opts.shapeCache === false ? null : new Map();
238
+ this._shapeEpoch = 0;
239
+ this._shapeHits = 0;
240
+ this._shapeMisses = 0;
241
+
242
+ this._flushSoon = () => {
243
+ this._flushScheduled = false; this._flush();
244
+ };
245
+ this._onWrittenCb = () => this._onWritten();
246
+
247
+ socket.on('data', (d) => this._onData(d));
248
+ socket.on('error', (err) => this._onSocketError(err));
249
+ socket.on('close', () => this._onSocketClose());
250
+ socket.on('end', () => { /* peer FIN; 'close' does the teardown */ });
251
+ // The socket setup node's Http2Session constructor does when a handle is
252
+ // available (core.js): no Nagle, and no TLS renegotiation under TLS.
253
+ if (typeof socket.setNoDelay === 'function') {
254
+ socket.setNoDelay();
255
+ }
256
+ if (typeof socket.disableRenegotiation === 'function') {
257
+ socket.disableRenegotiation();
258
+ }
259
+ // The preface and our SETTINGS may only go out once the socket is connected;
260
+ // under TLS that is 'secureConnect' -- the handshake has to complete and pick
261
+ // ALPN before the first octet is written (core.js defers setupHandle the same
262
+ // way: "socket.connecting || socket.secureConnecting"). A createConnection
263
+ // socket can already be established, so start immediately then.
264
+ if (socket.connecting || socket.secureConnecting) {
265
+ const ev = socket instanceof tls.TLSSocket ? 'secureConnect' : 'connect';
266
+ socket.once(ev, () => this._start());
267
+ } else {
268
+ this._start();
269
+ }
270
+ }
271
+
272
+ // ---- start / connect --------------------------------------------------------
273
+ _start() {
274
+ if (this._started) {
275
+ return;
276
+ }
277
+ this._started = true;
278
+ this.connecting = false;
279
+ // What node's setupHandle records once the socket is connected: whether the
280
+ // transport is a TLSSocket, and what ALPN negotiated on it -- 'h2' normally,
281
+ // false when the peer ignored ALPN (prior knowledge over TLS), 'h2c' on a
282
+ // plain socket. Reported as measured off the socket; node does not check the
283
+ // client side against a required value, and neither do we.
284
+ if (this.socket.encrypted) {
285
+ this.encrypted = true;
286
+ this.alpnProtocol = this.socket.alpnProtocol;
287
+ } else {
288
+ this.encrypted = false;
289
+ this.alpnProtocol = 'h2c';
290
+ }
291
+ // Client magic: 24-octet preface, then SETTINGS. This is the frame ordering the
292
+ // server validates on the way in (session.js:771) and the one we owe here.
293
+ // Anything the application queued before the socket connected (a control frame,
294
+ // say) must follow the handshake, never precede it -- so those bytes are set aside,
295
+ // the handshake is staged in front of them, and the set-aside bytes go back into the
296
+ // queue afterwards, which puts them behind SETTINGS on the wire.
297
+ this._commit();
298
+ const preQueued = this._outQ;
299
+ this._outQ = [];
300
+ this._need(PREFACE_LEN);
301
+ PREFACE.copy(this._stage, this._stageLen);
302
+ this._stageLen += PREFACE_LEN;
303
+ this._outLen += PREFACE_LEN;
304
+ this._sendSettings();
305
+ this._flush();
306
+ if (preQueued.length !== 0) {
307
+ this._outQ = preQueued;
308
+ for (let i = 0; i < preQueued.length; i++) {
309
+ this._outLen += preQueued[i].length;
310
+ }
311
+ this._schedule();
312
+ }
313
+ // Node's ClientHttp2Session defers 'connect' unconditionally — even for an
314
+ // already-established socket passed via createConnection — so that the caller
315
+ // has a chance to attach a listener after connect() returns and before the
316
+ // event fires. Without the deferral, this pattern misses the event:
317
+ // const sess = lite.connect(url, { createConnection: () => alreadyOpenSocket });
318
+ // sess.on('connect', handler); // attached after emit already ran synchronously
319
+ process.nextTick(() => {
320
+ if (this.destroyed || this._closeEmitted) {
321
+ return;
322
+ }
323
+ this.emit('connect');
324
+ if (this._queuedReq !== null) {
325
+ const q = this._queuedReq;
326
+ this._queuedReq = null;
327
+ for (const r of q) {
328
+ this._enqueue(r);
329
+ } // respects the peer's concurrency limit
330
+ }
331
+ });
332
+ }
333
+
334
+ // ---- outbound ---------------------------------------------------------------
335
+ // A payload reaches the socket either by reference or by copy, and the difference is
336
+ // who owns the memory. `owned` says it may be kept by reference: the buffer is either
337
+ // one this session allocated for the frame (SETTINGS, RST_STREAM, GOAWAY, PING) or a
338
+ // view of what the application handed to stream.write()/end(), which it may not mutate
339
+ // before the bytes are on the wire -- the same guarantee node:http2 gives. Bodies above
340
+ // BODY_MIN take that path and are never copied. Everything else travels by copy into the
341
+ // staging buffer, because it is a view into memory the next call rewrites -- the HPACK
342
+ // scratch block -- and those payloads are tens of bytes.
343
+ _frame(type, flags, sid, payload, owned) {
344
+ const len = payload === null || payload === undefined ? 0 : payload.length;
345
+ if (owned && len >= BODY_MIN) {
346
+ this._commit(); // what is staged was produced earlier, so it
347
+ // has to go out first
348
+ const head = Buffer.allocUnsafe(HEADER_LEN);
349
+ writeHeader(head, 0, len, type, flags, sid);
350
+ this._outQ.push(head, payload);
351
+ } else {
352
+ this._need(HEADER_LEN + len);
353
+ const off = this._stageLen;
354
+ writeHeader(this._stage, off, len, type, flags, sid);
355
+ if (len) {
356
+ payload.copy(this._stage, off + HEADER_LEN);
357
+ }
358
+ this._stageLen = off + HEADER_LEN + len;
359
+ }
360
+ this._outLen += HEADER_LEN + len;
361
+ if (!this._inBatch) {
362
+ this._schedule();
363
+ }
364
+ }
365
+
366
+ // Room in the staging buffer for n more bytes, committing what it holds to the queue
367
+ // if it does not fit. A buffer that goes into _outQ is never written into again --
368
+ // those bytes may already be on their way to the peer -- so a full buffer is handed
369
+ // over and a fresh one started, one allocation per batch instead of one per frame.
370
+ _need(n) {
371
+ if (this._stage !== null && this._stageLen + n <= this._stage.length) {
372
+ return;
373
+ }
374
+ this._commit();
375
+ let cap = this._stageCap;
376
+ while (cap < n) {
377
+ cap *= 2;
378
+ }
379
+ this._stageCap = Math.min(cap, MAX_OUT_BATCH);
380
+ this._stage = Buffer.allocUnsafe(cap);
381
+ }
382
+
383
+ _commit() {
384
+ if (this._stageLen === 0) {
385
+ return;
386
+ }
387
+ this._outQ.push(this._stage.subarray(0, this._stageLen));
388
+ this._stage = null; // the queued bytes are not ours to write
389
+ this._stageLen = 0;
390
+ }
391
+
392
+ // Frames produced outside a read batch -- a request made from an 'open' handler or
393
+ // after an await -- would otherwise sit in the queue until the next frame arrived.
394
+ // Node drains the tick queue before microtasks, so one microtask per batch still
395
+ // coalesces everything produced in the same turn of the loop into a single write.
396
+ _schedule() {
397
+ if (this._flushScheduled) {
398
+ return;
399
+ }
400
+ this._flushScheduled = true;
401
+ queueMicrotask(this._flushSoon);
402
+ }
403
+
404
+ _flush() {
405
+ // leaving a batch, whatever the reason: from here on frames are produced outside a
406
+ // socket read and have to schedule their own write
407
+ this._inBatch = false;
408
+ this._writePending();
409
+ }
410
+
411
+ // Hands the queued frames to the socket. The batch is written corked, so the whole of
412
+ // it goes out through one _writev -- both transports implement it -- which is what
413
+ // keeps a HEADERS + DATA + WINDOW_UPDATE batch in a single TCP segment even though the
414
+ // frames were never concatenated into one buffer. Only one batch is ever in flight:
415
+ // the buffers handed over belong to the socket until the callback fires (a TLS socket
416
+ // encrypts at flush time, well after the next read), and an application that keeps
417
+ // writing into a buffer it passed to stream.end() would otherwise change bytes already
418
+ // promised. The queue fills up behind the outstanding write and leaves on _onWritten.
419
+ _writePending() {
420
+ if (this._outLen === 0) {
421
+ return;
422
+ }
423
+ // Nothing leaves before the handshake is laid down: a frame flushed to the socket
424
+ // ahead of PREFACE + SETTINGS makes the peer read it as a pre-preface violation
425
+ // (nghttp2 answers GOAWAY(INTERNAL_ERROR)). _start sets _started and drives the
426
+ // first write itself; until then queued frames simply wait in the staging buffer.
427
+ if (!this._started) {
428
+ return;
429
+ }
430
+ if (!this._open) {
431
+ this._discardOut(); return;
432
+ }
433
+ if (this._writing || !this.socket.writable) {
434
+ return;
435
+ }
436
+ this._writing = true;
437
+ this._commit();
438
+ const batch = this._outQ;
439
+ this._outQ = [];
440
+ this._outLen = 0;
441
+ const s = this.socket;
442
+ if (batch.length === 1) {
443
+ // the common case: everything the batch holds went into one staging buffer, so
444
+ // there is nothing to coalesce and corking would only add a round trip through the
445
+ // stream's own buffer (OPTIMIZATION.md item 16).
446
+ s.write(batch[0], this._onWrittenCb);
447
+ } else {
448
+ // one callback for the batch, put on its last chunk: writes complete in order, so
449
+ // the last one is the point at which every buffer in it is the socket's no longer
450
+ s.cork();
451
+ for (let i = 0; i < batch.length - 1; i++) {
452
+ s.write(batch[i]);
453
+ }
454
+ s.write(batch[batch.length - 1], this._onWrittenCb);
455
+ s.uncork();
456
+ }
457
+ }
458
+
459
+ _onWritten() {
460
+ this._writing = false;
461
+ // Dropping the batch is the whole release: the frames were never assembled into
462
+ // anything shared, so there is no tail to move down and nothing to reset.
463
+ this._writePending();
464
+ }
465
+
466
+ _discardOut() {
467
+ this._outQ = [];
468
+ this._stage = null;
469
+ this._stageLen = 0;
470
+ this._outLen = 0;
471
+ }
472
+
473
+ _sendSettings() {
474
+ const s = this.localSettings;
475
+ const names = [...this._advertised].filter((k) => ID_BY_SETTING[k] !== undefined
476
+ && s[k] !== undefined);
477
+ const body = Buffer.allocUnsafe(names.length * 6);
478
+ for (let i = 0; i < names.length; i++) {
479
+ const name = names[i];
480
+ const value = typeof s[name] === 'boolean' ? (s[name] ? 1 : 0) : s[name];
481
+ const at = i * 6;
482
+ body.writeUInt16BE(ID_BY_SETTING[name], at);
483
+ body.writeUInt32BE(value >>> 0, at + 2);
484
+ }
485
+ this._frame(SETTINGS, 0, 0, body, true);
486
+ this.pendingSettingsAck = true;
487
+ }
488
+
489
+ // request(): the node-shaped surface. Accepts an object, an array of pairs, or
490
+ // nothing; fills the request pseudo-headers from the session's scheme/authority
491
+ // unless the application supplied them; returns a ClientHttp2Stream.
492
+ request(headersParam, options) {
493
+ if (this.destroyed) {
494
+ throw httpErr('ERR_HTTP2_INVALID_SESSION', 'The session has been destroyed');
495
+ }
496
+ if (this.closed) {
497
+ throw httpErr('ERR_HTTP2_GOAWAY_SESSION', 'New streams cannot be created after receiving a GOAWAY');
498
+ }
499
+ const options2 = options ? { ...options } : {};
500
+ const list = [];
501
+ let method = 'GET';
502
+ let sawMethod = false;
503
+
504
+ const pushPseudo = (name, value) => {
505
+ list.push(name, value);
506
+ };
507
+ const src = headersParam === undefined ? {} : headersParam;
508
+ if (Array.isArray(src)) {
509
+ for (let i = 0; i + 1 < src.length; i += 2) {
510
+ this._addField(list, src[i], src[i + 1]);
511
+ }
512
+ } else {
513
+ const keys = Object.keys(src);
514
+ for (let i = 0; i < keys.length; i++) {
515
+ this._addField(list, keys[i], src[keys[i]]);
516
+ }
517
+ }
518
+ // list may already contain app pseudo-headers; extract method for the endStream
519
+ // default and the pseudo-header completion node performs.
520
+ for (let i = 0; i < list.length; i += 2) {
521
+ if (list[i] === ':method') {
522
+ method = list[i + 1]; sawMethod = true; break;
523
+ }
524
+ }
525
+ if (!sawMethod) {
526
+ pushPseudo(':method', method);
527
+ }
528
+
529
+ let endStream = options2.endStream;
530
+ if (endStream === undefined) {
531
+ endStream = payloadMeaningless(method);
532
+ }
533
+
534
+ const stream = new ClientHttp2Stream(this, this._nextSid, {});
535
+ this._nextSid += 2; // the id is reserved now, HEADERS may wait
536
+ stream.endAfterHeaders = endStream;
537
+ if (endStream) {
538
+ stream.end();
539
+ }
540
+
541
+ // hp: the original headersParam object. When the application reuses the same
542
+ // object across requests, the HPACK encoder produces identical bytes every time
543
+ // after the dynamic table stabilises, and _sendRequest can use _blocks to replay
544
+ // the encoded block without re-encoding. (The server's shareHeaders equivalent.)
545
+ const req = { stream, list, end: endStream, hp: headersParam };
546
+ if (this._started) {
547
+ this._enqueue(req);
548
+ } else {
549
+ (this._queuedReq || (this._queuedReq = [])).push(req);
550
+ }
551
+ return stream;
552
+ }
553
+
554
+ // Open a request if a concurrency slot is free, otherwise park it. The peer's
555
+ // SETTINGS_MAX_CONCURRENT_STREAMS bounds how many streams may be open at once; a
556
+ // request beyond that limit keeps its reserved id but its HEADERS wait in the queue
557
+ // until an open stream closes (RFC 9113 5.1.2, 6.5.2).
558
+ //
559
+ // The request is NOT held back until the peer's SETTINGS have been applied. Node
560
+ // does not hold it back either: its client emits 'connect' as soon as the socket
561
+ // is up (internal/http2/core.js setupHandle -> nextTick(emit, 'connect')) and
562
+ // submits the request then, so the request goes out in the first flight. Measured
563
+ // against a raw server that delays its SETTINGS by 200 ms: node's request HEADERS
564
+ // arrive at 5 ms, while h2lite's used to arrive at 225 ms -- after the SETTINGS it
565
+ // was waiting for -- which is a round trip on every session's first request
566
+ // (tools/probe-client-first.js).
567
+ //
568
+ // What the peer's SETTINGS would have told us is applied when it is known: the
569
+ // window a stream runs on is taken when the stream is opened (_openStream), and a
570
+ // peer that will not take the stream answers REFUSED_STREAM -- which is what
571
+ // node's client gets for a burst fired before the peer's SETTINGS land too.
572
+ _enqueue(req) {
573
+ const max = this.remoteSettings.maxConcurrentStreams;
574
+ if (this._outboundQueue.length === 0
575
+ && (max === undefined || this._streams.size < max)) {
576
+ this._openStream(req);
577
+ } else {
578
+ this._outboundQueue.push(req);
579
+ }
580
+ }
581
+
582
+ _openStream(req) {
583
+ // RFC 9113 6.9.2: a stream runs on the peer's SETTINGS_INITIAL_WINDOW_SIZE. The
584
+ // stream object was built when the application asked for the request, which may
585
+ // have been before the peer's SETTINGS arrived -- and the delta a later SETTINGS
586
+ // applies reaches only the streams that are open by then (see _applySettings).
587
+ // Taking the window here, where the stream is actually opened, is what makes it
588
+ // the peer's number and not the 65535 default the peer may never have chosen.
589
+ req.stream._sendWin = this.remoteSettings.initialWindowSize;
590
+ this._streams.set(req.stream.id, req.stream);
591
+ this._sendRequest(req.stream, req.list, req.end, req.hp);
592
+ }
593
+
594
+ // A slot just freed (a stream closed): start queued requests while the peer's
595
+ // concurrency limit and the live connection allow.
596
+ _drainQueue() {
597
+ if (this.destroyed || this._closeEmitted) {
598
+ return;
599
+ }
600
+ const max = this.remoteSettings.maxConcurrentStreams;
601
+ while (this._outboundQueue.length > 0 && (max === undefined || this._streams.size < max)) {
602
+ const req = this._outboundQueue.shift();
603
+ if (this.closed && req.stream.id > (this._goawayLastStreamID || 0)) {
604
+ // past the peer's GOAWAY mark: this request will not be carried; surface it.
605
+ req.stream.destroy(httpErr('ERR_HTTP2_GOAWAY_SESSION',
606
+ 'New streams cannot be created after receiving a GOAWAY'));
607
+ continue;
608
+ }
609
+ this._openStream(req);
610
+ }
611
+ }
612
+
613
+ // Normalises one application-supplied field into the flat [name, value, ...] list:
614
+ // lower-cases names, coerces pseudo/regular, rejects connection-specific fields
615
+ // (RFC 9113 8.2.2) and a non-token name (RFC 9113 8.2.1) exactly as node's
616
+ // prepareRequestHeaders* does for a client request.
617
+ _addField(list, rawKey, rawValue) {
618
+ if (rawValue === undefined) {
619
+ return;
620
+ }
621
+ const key = lowerName(rawKey);
622
+ // The colon test is spelled as a code-point read, where lib/headers.js uses
623
+ // `key[0] === ':'`. Not a style difference: the one-character read boxes a new
624
+ // one-char string, and charCodeAt is the cheaper spelling on every input --
625
+ // the fall-through case and the match case alike (OPTIMIZATION.md item 33).
626
+ if (key.charCodeAt(0) === 58 /* : */) {
627
+ if (key !== ':method' && key !== ':path' && key !== ':scheme' && key !== ':authority'
628
+ && key !== ':protocol') {
629
+ throw httpErr('ERR_HTTP2_PSEUDOHEADER_NOT_ALLOWED', `cannot send ${key}`);
630
+ }
631
+ const single = Array.isArray(rawValue) ? rawValue[0] : rawValue;
632
+ list.push(key, String(single));
633
+ return;
634
+ }
635
+ if (!isFieldName(key)) {
636
+ throw httpErr('ERR_INVALID_HTTP_TOKEN', `${key} is an invalid header token`);
637
+ }
638
+ if (CONNECTION_SPECIFIC.has(key)) {
639
+ throw httpErr('ERR_HTTP2_INVALID_CONNECTION_HEADERS',
640
+ `HTTP/1 Connection specific header is forbidden: ${key}`);
641
+ }
642
+ if (Array.isArray(rawValue)) {
643
+ for (let j = 0; j < rawValue.length; j++) {
644
+ list.push(key, String(rawValue[j]));
645
+ }
646
+ } else {
647
+ list.push(key, String(rawValue));
648
+ }
649
+ }
650
+
651
+ _sendRequest(stream, list, end, hp) {
652
+ // Fill the pseudo-headers the application omitted, in the canonical order, then
653
+ // hand the whole list to HPACK. node completes :path to '/', :scheme and
654
+ // :authority from the session; we mirror that so `request({})` is a GET /.
655
+ //
656
+ // The outbound block cache: when the same headersParam object is passed to
657
+ // request() again and the encoder's dynamic-table epoch has not moved since
658
+ // we last encoded it, the HPACK block is byte-identical to the cached form.
659
+ // Replaying the cached bytes skips _addField, orderPseudo, and _encodeRequest
660
+ // in their entirety -- the server's shareHeaders mechanism inverted: on the
661
+ // server the same idea caches outbound response blocks; here it caches outbound
662
+ // request blocks. The WeakMap entry is dropped with the object itself (no
663
+ // explicit invalidation), and epoch mismatch handles dynamic-table changes.
664
+ // The subarray the cache holds is stable: _encodeRequest's buf is freshly
665
+ // allocated each call, so the view stays alive as long as the entry is reachable.
666
+ const cached = hp !== undefined && hp !== null && typeof hp === 'object'
667
+ ? this._blocks.get(hp) : undefined;
668
+ let block;
669
+ if (cached !== undefined && cached.epoch === this._encoder.epoch) {
670
+ block = cached.block;
671
+ } else {
672
+ if (!list.includes(':path')) {
673
+ list.push(':path', '/');
674
+ }
675
+ if (!list.includes(':scheme')) {
676
+ list.push(':scheme', this._scheme);
677
+ }
678
+ if (!list.includes(':authority')) {
679
+ list.push(':authority', this._authority);
680
+ }
681
+ const ordered = orderPseudo(list);
682
+ block = this._encodeRequest(ordered);
683
+ if (hp !== undefined && hp !== null && typeof hp === 'object') {
684
+ this._blocks.set(hp, { epoch: this._encoder.epoch, block });
685
+ }
686
+ }
687
+ stream._sid = stream.id;
688
+ this._sendHeaders(stream.id, block, end);
689
+ // HEADERS is now queued; release any body the application wrote before the
690
+ // socket connected (or before this queued request was framed). The DATA frames
691
+ // land in _out behind the HEADERS, so the wire order stays HEADERS -> DATA.
692
+ stream._headersSent = true;
693
+ stream.headersSent = true;
694
+ if (stream._pending !== null || stream._finalCb !== null) {
695
+ this._pump(stream);
696
+ }
697
+ }
698
+
699
+ _encodeRequest(list) {
700
+ const enc = this._encoder;
701
+ // The dynamic-table-size announcement has to precede the block it applies to, and its
702
+ // length is not known before the block is written: the field lines are what change the
703
+ // table, and the announcement reports where the table ended up. So the take happens
704
+ // first and its octets are copied into the headroom of the same buffer the block is
705
+ // encoded into -- never into a second one.
706
+ const update = enc.takeSizeUpdate();
707
+ let cap = 0;
708
+ for (let i = 0; i < list.length; i += 2) {
709
+ cap += Encoder.maxBytesOf(list[i], list[i + 1]);
710
+ }
711
+ // Without a size update the block IS the scratch: _sendHeaders runs before any later
712
+ // encode can rewrite it, and _frame(owned=false) puts a stable copy in the batch.
713
+ // With one, the old form was Buffer.concat([update, block]) -- a second allocation,
714
+ // a second copy of the whole block and a temporary array per request. Reserving the
715
+ // update's length up front costs one memcpy of the update itself
716
+ // (OPTIMIZATION.md item 14).
717
+ // The headroom exists on this branch only, and the returned view then starts past
718
+ // the front of its buffer -- which every consumer here reads as
719
+ // .length/.copy/write(), all of which honour byteOffset.
720
+ const ul = update === null ? 0 : update.length;
721
+ const buf = Buffer.allocUnsafe(ul + cap);
722
+ enc.unsettled = false;
723
+ let off = ul;
724
+ for (let i = 0; i < list.length; i += 2) {
725
+ off = enc.write(buf, off, list[i], list[i + 1], false);
726
+ }
727
+ if (ul === 0) {
728
+ return buf.subarray(0, off);
729
+ }
730
+ update.copy(buf, 0);
731
+ return buf.subarray(0, off);
732
+ }
733
+
734
+ _sendHeaders(sid, block, end) {
735
+ const max = this.remoteSettings.maxFrameSize;
736
+ if (block.length <= max) {
737
+ // owned=false: the batch keeps a copy, because `block` may be the encoder
738
+ // scratch; one memcpy, and the double allocation the old by-reference path cost
739
+ // is gone.
740
+ this._frame(HEADERS, F_END_HEADERS | (end ? F_END_STREAM : 0), sid, block, false);
741
+ return;
742
+ }
743
+ this._frame(HEADERS, 0, sid, block.subarray(0, max), false);
744
+ let off = max;
745
+ for (;;) {
746
+ const n = Math.min(block.length - off, max);
747
+ const last = off + n >= block.length;
748
+ this._frame(CONTINUATION, last ? F_END_HEADERS | (end ? F_END_STREAM : 0) : 0,
749
+ sid, block.subarray(off, off + n), false);
750
+ off += n;
751
+ if (last) {
752
+ return;
753
+ }
754
+ }
755
+ }
756
+
757
+ // Flow-controlled send for the writable half of a stream. Called from the Duplex's
758
+ // _write/_final and again from WINDOW_UPDATE / SETTINGS to resume a stalled body.
759
+ _pump(stream) {
760
+ // No bytes may leave before the stream's HEADERS: DATA on an unopened stream id
761
+ // is a connection error, and nghttp2 answers it with GOAWAY(INTERNAL_ERROR). A
762
+ // write() that arrives before 'connect' parks its payload in _pending and waits
763
+ // for _sendRequest to flip _headersSent and call us again.
764
+ if (!stream._headersSent) {
765
+ return;
766
+ }
767
+ const max = this.remoteSettings.maxFrameSize;
768
+ while (stream._pending !== null) {
769
+ const room = Math.min(this._connSendWin, stream._sendWin, max);
770
+ if (room <= 0) {
771
+ return;
772
+ } // stalled; WINDOW_UPDATE resumes
773
+ const avail = stream._pending.length - stream._pendingOff;
774
+ const take = Math.min(room, avail);
775
+ const finishedByte = (stream._pendingOff + take) === stream._pending.length;
776
+ const es = finishedByte && stream._writeCb === null && stream._finalCb !== null;
777
+ const slice = (stream._pendingOff === 0 && take === stream._pending.length)
778
+ ? stream._pending : stream._pending.subarray(stream._pendingOff, stream._pendingOff + take);
779
+ this._frame(DATA, es ? F_END_STREAM : 0, stream.id, slice, true);
780
+ this._connSendWin -= take;
781
+ stream._sendWin -= take;
782
+ stream._pendingOff += take;
783
+ if (stream._pendingOff === stream._pending.length) {
784
+ const cb = stream._writeCb;
785
+ stream._pending = null;
786
+ stream._pendingOff = 0;
787
+ if (cb !== null) {
788
+ stream._writeCb = null; cb();
789
+ }
790
+ if (es) {
791
+ stream._writableDone(); return;
792
+ }
793
+ break; // a further write may follow
794
+ }
795
+ }
796
+ // Everything is framed and we are finalizing: close the message with END_STREAM,
797
+ // unless the GET put it on HEADERS already.
798
+ if (stream._pending === null && stream._finalCb !== null && stream._writeCb === null
799
+ && !stream._endSent) {
800
+ if (stream.endAfterHeaders) {
801
+ stream._writableDone(); return;
802
+ }
803
+ this._frame(DATA, F_END_STREAM, stream.id, null, false);
804
+ stream._writableDone();
805
+ }
806
+ }
807
+
808
+ // ---- inbound ----------------------------------------------------------------
809
+ // The server's parse loop, mirrored: a frame that fits inside the chunk is handled
810
+ // in place with no copy at all, and a straddling frame is kept as the views it
811
+ // arrived in -- a DATA payload is handed to the application as those views, a
812
+ // header block is decoded out of them, and the control frames are put in a
813
+ // buffer of their own for the parse. Node's socket chunks are never rewritten
814
+ // after being handed out (measured, AGENTS.md 4), which is what makes the view
815
+ // safe to keep.
816
+ _onData(chunk) {
817
+ const n = chunk.length;
818
+ let i = 0;
819
+ this._inBatch = true;
820
+
821
+ while (i < n) {
822
+ if (this._frag > 0) {
823
+ // continuing a frame that straddles reads: take no more than it wants
824
+ const want = this._fragTarget === null
825
+ ? HEADER_LEN - this._frag
826
+ : this._fragTarget - this._frag;
827
+ const take = Math.min(want, n - i);
828
+ if (take <= 0) {
829
+ break;
830
+ }
831
+ if (this._fragPieces !== null) {
832
+ this._fragPieces.push(chunk.subarray(i, i + take));
833
+ } else {
834
+ // the frame's header is still incomplete: its octets wait in the
835
+ // scratch until the header is whole
836
+ chunk.copy(this._fragHdr, this._frag, i, i + take);
837
+ }
838
+ this._frag += take;
839
+ i += take;
840
+ if (this._fragTarget === null && this._frag === HEADER_LEN) {
841
+ readHeader(this._fragHdr, 0);
842
+ if (!this._allowFrame(f.FH_TYPE, f.FH_LEN, f.FH_FLAGS)) {
843
+ return;
844
+ }
845
+ this._fragTarget = HEADER_LEN + f.FH_LEN;
846
+ this._fragType = f.FH_TYPE;
847
+ this._fragFlags = f.FH_FLAGS;
848
+ this._fragSid = f.FH_SID;
849
+ // The payload of the frame is kept as the views it arrives in: a DATA
850
+ // payload is handed to the application that way, and a header block is
851
+ // decoded out of them (lib/hpack.js decodePieces).
852
+ this._fragPieces = [];
853
+ }
854
+ if (this._fragTarget !== null && this._frag === this._fragTarget) {
855
+ const pieces = this._fragPieces;
856
+ const len = this._fragTarget - HEADER_LEN;
857
+ const ok = this._handlePieces(this._fragType, this._fragFlags, this._fragSid, pieces, len);
858
+ this._frag = 0;
859
+ this._fragTarget = null;
860
+ this._fragPieces = null;
861
+ if (!ok) {
862
+ return;
863
+ }
864
+ }
865
+ continue;
866
+ }
867
+
868
+ if (n - i < HEADER_LEN) { // even the header is incomplete
869
+ this._fragTarget = null;
870
+ this._fragPieces = null;
871
+ this._frag = n - i;
872
+ chunk.copy(this._fragHdr, 0, i, n);
873
+ break;
874
+ }
875
+
876
+ readHeader(chunk, i);
877
+ if (!this._allowFrame(f.FH_TYPE, f.FH_LEN, f.FH_FLAGS)) {
878
+ return;
879
+ }
880
+ const total = HEADER_LEN + f.FH_LEN;
881
+ if (n - i < total) { // header known, payload straddles
882
+ this._fragTarget = total;
883
+ this._fragType = f.FH_TYPE;
884
+ this._fragFlags = f.FH_FLAGS;
885
+ this._fragSid = f.FH_SID;
886
+ this._frag = n - i;
887
+ // The payload is not contiguous: it starts in this chunk and ends in a
888
+ // later one. It is kept as the views it is and handed over as the pieces
889
+ // it arrived in, which is what node does with the same frame. A header
890
+ // block is decoded out of them, so it costs no copy either.
891
+ this._fragPieces = [chunk.subarray(i + HEADER_LEN, n)];
892
+ break;
893
+ }
894
+ if (!this._handle(f.FH_TYPE, f.FH_FLAGS, f.FH_SID, chunk, i + HEADER_LEN, f.FH_LEN)) {
895
+ return;
896
+ }
897
+ i += total;
898
+ if (!this._open) {
899
+ return;
900
+ }
901
+ }
902
+
903
+ this._endBatch();
904
+ }
905
+
906
+ // What may arrive at all. RFC 9113 6.5.2 lets the peer send up to OUR advertised
907
+ // SETTINGS_MAX_FRAME_SIZE and nothing more; a longer payload has no bounded home
908
+ // in the session, so it is a connection error of type FRAME_SIZE_ERROR -- the same
909
+ // rule the server applies to us. SETTINGS and PING are exempt because their
910
+ // payloads are not frame-size governed (and SETTINGS is bounded by entry count in
911
+ // _applySettings).
912
+ // Unlike the server, no first-frame-is-SETTINGS rule is enforced here: that is a
913
+ // protocol-strictness change, not a fast path, and it needs its own node comparison.
914
+ _allowFrame(type, len, _flags) {
915
+ if (type !== PING && type !== SETTINGS && len > this.localSettings.maxFrameSize) {
916
+ this._fatal(FRAME_SIZE_ERROR, 'frame larger than our advertised max frame size');
917
+ return false;
918
+ }
919
+ return true;
920
+ }
921
+
922
+ _endBatch() {
923
+ this._inBatch = false;
924
+ // One read batch is one accounting period: the frames that crossed their
925
+ // thresholds during it leave together, the rest of the bytes stay owed until
926
+ // the next batch pays them (the server's _endBatch does the same). The release
927
+ // visits only the streams in _debt -- the ones that actually took DATA.
928
+ this._creditRecv();
929
+ this._flush();
930
+ }
931
+
932
+ // Return the flow-control credit (RFC 9113 6.9) for every stream that has
933
+ // consumed past half its window, then for the connection's share, each as one
934
+ // coalesced WINDOW_UPDATE. Not every consumed octet is worth a frame: node's
935
+ // nghttp2 releases credit in half-window steps (measured on the server side,
936
+ // session.js _creditConnection, tools/probe-conn-credit.js), and the eager
937
+ // one-frame-per-DATA alternative measured far slower (OPTIMIZATION.md item 32).
938
+ //
939
+ // Each threshold is half of the window the level could ACTUALLY be run with, and the
940
+ // honest answer to that question at both levels is the smaller of what we advertise
941
+ // and the 65535 an endpoint uses until it has read our SETTINGS.
942
+ //
943
+ // The ordering is what forces the conservative number rather than our advertised one.
944
+ // A server writes its SETTINGS before it has read ours, and it sizes a new stream's
945
+ // send window -- and the connection window it starts answering on -- by whatever it
946
+ // knows at that moment: the default if our SETTINGS have not landed yet, our value if
947
+ // they have. The client cannot tell which happened, and bench/micro/probe-win.js measured both
948
+ // shapes on nghttpd: with 262144 advertised the server sent 65535 octets on its
949
+ // connection window and then stopped, because the release for sid 0 was planned
950
+ // against half of 262144 and never came due. A release planned against half of the
951
+ // lower bound is early enough for either case and, being one frame per 32767 octets,
952
+ // is exactly node's measured cadence when both sides run the default -- which is every
953
+ // benchmark and differential test here.
954
+ //
955
+ // The connection window is additionally tracked (`_connRecvWin`) so that a peer which
956
+ // really does run it at the advertised size still gets it restored before it runs
957
+ // out: the release is due when EITHER the half-window step says so or the window the
958
+ // peer may fill without hearing from us drops under the step.
959
+ _creditRecv() {
960
+ const step = Math.min(this.localSettings.initialWindowSize, DEFAULT_WINDOW) >> 1;
961
+ const debt = this._debt;
962
+ // Compacted in place: a stream whose bytes have not yet crossed the half-window
963
+ // step STAYS in the list (and stays marked), because the credit it owes is only
964
+ // paid by a later pass. Clearing the mark without releasing would drop the debt on
965
+ // the floor and leave the peer waiting for a WINDOW_UPDATE that never comes.
966
+ let k = 0;
967
+ for (let i = 0; i < debt.length; i++) {
968
+ const stream = debt[i];
969
+ if (stream._recvUsed >= step) {
970
+ const inc = stream._recvUsed;
971
+ stream._recvUsed = 0;
972
+ stream._debtMarked = false;
973
+ this._windowUpdate(stream.id, inc);
974
+ } else {
975
+ debt[k++] = stream;
976
+ }
977
+ }
978
+ debt.length = k;
979
+ if (this._connRecvUsed >= step
980
+ || this._connRecvWin - this._connRecvUsed < step) {
981
+ const inc = this._connRecvUsed;
982
+ this._connRecvUsed = 0;
983
+ this._connRecvWin += inc;
984
+ this._windowUpdate(0, inc);
985
+ }
986
+ // Everything the pass produced leaves as one unit. Two exits, chosen by whether
987
+ // anything of ours is still unflushed: with the queue and the staging buffer empty
988
+ // and a batch in flight, the credit goes straight to the socket -- through _outQ it
989
+ // would wait for _onWritten, a whole poll cycle, and the peer sits out that delay
990
+ // (bench/client measured the 256 kB download spending it on every half-window step).
991
+ // Otherwise the bytes are copied into the staging buffer, behind whatever is already
992
+ // in it, so that a HEADERS produced in this same batch cannot be overtaken by the
993
+ // WINDOW_UPDATE for its own stream.
994
+ //
995
+ // The direct write hands over a COPY, not a view of _wuBuf: that buffer is rewritten
996
+ // by the next pass, and a chunk the socket still owns (a partial write, or a TLS
997
+ // socket that encrypts at flush time) must not read bytes we changed -- the issued-
998
+ // buffer rule of AGENTS.md 5. One allocation per credit batch replaces the old one
999
+ // per update.
1000
+ if (this._wuLen !== 0) {
1001
+ if (this._writing && this._open && this._started && this._outLen === 0
1002
+ && this.socket.writable) {
1003
+ // a copy, not a view -- the reason is the comment above -- taken through
1004
+ // allocUnsafe rather than Buffer.from(subarray): the pooled allocation is the
1005
+ // cheaper way to get a fresh buffer (OPTIMIZATION.md item 18)
1006
+ const out = Buffer.allocUnsafe(this._wuLen);
1007
+ this._wuBuf.copy(out, 0, 0, this._wuLen);
1008
+ this.socket.write(out);
1009
+ } else {
1010
+ this._need(this._wuLen);
1011
+ this._wuBuf.copy(this._stage, this._stageLen, 0, this._wuLen);
1012
+ this._stageLen += this._wuLen;
1013
+ this._outLen += this._wuLen;
1014
+ this._schedule();
1015
+ }
1016
+ this._wuLen = 0;
1017
+ }
1018
+ }
1019
+
1020
+ // Returns false when the session has been torn down mid-read (so the parse loop
1021
+ // stops), true to keep going. _fatal and stream teardown set _open false.
1022
+ _handle(type, flags, sid, buf, start, len) {
1023
+ // RFC 9113 6.10: only CONTINUATION may follow a HEADERS that lacks END_HEADERS.
1024
+ if (this._hdrSid !== -1 && type !== CONTINUATION) {
1025
+ return this._fatal(PROTOCOL_ERROR, 'frame interleaved with an unfinished header block');
1026
+ }
1027
+ switch (type) {
1028
+ case SETTINGS:
1029
+ if (sid !== 0) {
1030
+ return this._fatal(PROTOCOL_ERROR, 'SETTINGS with a non-zero stream id');
1031
+ }
1032
+ if (flags & F_ACK) {
1033
+ if (len !== 0) {
1034
+ return this._fatal(FRAME_SIZE_ERROR, 'SETTINGS ACK must be empty');
1035
+ }
1036
+ this.pendingSettingsAck = false;
1037
+ return this._open;
1038
+ }
1039
+ this._applySettings(buf, start, len);
1040
+ this.emit('remoteSettings', this.remoteSettings);
1041
+ this._frame(SETTINGS, F_ACK, 0, null, false);
1042
+ return this._open;
1043
+ case PING:
1044
+ if (sid !== 0) {
1045
+ return this._fatal(PROTOCOL_ERROR, 'PING with a non-zero stream id');
1046
+ }
1047
+ if (len !== 8) {
1048
+ return this._fatal(FRAME_SIZE_ERROR, 'PING payload must be 8 octets');
1049
+ }
1050
+ if (flags & F_ACK) {
1051
+ const key = buf.toString('latin1', start, start + 8);
1052
+ const entry = this._pings.get(key);
1053
+ if (entry !== undefined) {
1054
+ this._pings.delete(key);
1055
+ // node's measured callback shape is (err, duration, pong): duration is a
1056
+ // millisecond number and pong is the echoed 8-octet buffer.
1057
+ const duration = Number(process.hrtime.bigint() - entry.at) / 1e6;
1058
+ // The pong goes to the application, which may keep it: a PING ACK that
1059
+ // arrived in pieces was put in a buffer of its own for the parse -- a
1060
+ // fresh one, not a reused one -- so the view does not move under the
1061
+ // application (AGENTS.md 5: issued buffers stay valid). Node's copy
1062
+ // comes out of the C++ layer as a fresh buffer, so it does not move
1063
+ // under the application there.
1064
+ // a fresh 8-octet buffer for the callback; allocUnsafe + copy rather
1065
+ // than Buffer.from(subarray) for the pooled allocation
1066
+ // (OPTIMIZATION.md item 18)
1067
+ const pong = Buffer.allocUnsafe(8);
1068
+ buf.copy(pong, 0, start, start + 8);
1069
+ entry.cb(null, duration, pong);
1070
+ }
1071
+ } else {
1072
+ this._frame(PING, F_ACK, 0, buf.subarray(start, start + 8), true);
1073
+ }
1074
+ return this._open;
1075
+ case HEADERS:
1076
+ this._beginHeaders(sid, flags, buf, start, len);
1077
+ return this._open;
1078
+ case CONTINUATION:
1079
+ if (this._hdrSid !== sid) {
1080
+ return this._fatal(PROTOCOL_ERROR, 'CONTINUATION without HEADERS');
1081
+ }
1082
+ if (++this._hdrCont > MAX_CONTINUATIONS) {
1083
+ return this._fatal(PROTOCOL_ERROR, 'CONTINUATION: too many frames for one block');
1084
+ }
1085
+ this._appendHdr(buf, start, len);
1086
+ if (flags & F_END_HEADERS) {
1087
+ this._finishHeaders();
1088
+ }
1089
+ return this._open;
1090
+ case DATA:
1091
+ this._onDataFrame(sid, flags, buf, start, len);
1092
+ return this._open;
1093
+ case WINDOW_UPDATE:
1094
+ if (len !== 4) {
1095
+ return this._fatal(FRAME_SIZE_ERROR, 'WINDOW_UPDATE payload');
1096
+ }
1097
+ this._onWindowUpdate(sid, buf.readUInt32BE(start) & 0x7fffffff);
1098
+ return this._open;
1099
+ case RST_STREAM:
1100
+ if (len !== 4) {
1101
+ return this._fatal(FRAME_SIZE_ERROR, 'RST_STREAM payload');
1102
+ }
1103
+ this._onRst(sid, buf.readUInt32BE(start));
1104
+ return this._open;
1105
+ case GOAWAY:
1106
+ return this._onGoaway(buf, start, len);
1107
+ case PRIORITY:
1108
+ if (len !== 5) {
1109
+ return this._fatal(FRAME_SIZE_ERROR, 'PRIORITY payload must be 5 octets');
1110
+ }
1111
+ if (sid === 0) {
1112
+ return this._fatal(PROTOCOL_ERROR, 'PRIORITY on stream 0');
1113
+ }
1114
+ return this._open; // contents deprecated (RFC 9218)
1115
+ case PUSH_PROMISE:
1116
+ // A server pushing to us is legal HTTP/2, but push is out of v1 scope (as it
1117
+ // is for the server side); RFC 9113 6.6 makes an unexpected PUSH_PROMISE a
1118
+ // connection error of type PROTOCOL_ERROR, which is what node answers too.
1119
+ return this._fatal(PROTOCOL_ERROR, 'peer sent PUSH_PROMISE');
1120
+ default:
1121
+ return this._open; // RFC 9113 5.5: discard unknown
1122
+ }
1123
+ }
1124
+
1125
+ // A frame whose payload straddled two reads is dispatched as the pieces it
1126
+ // arrived in. A DATA payload is handed to the application as those pieces; a
1127
+ // header block is decoded out of them; the control frames -- small and of a
1128
+ // fixed size -- are put in a buffer of their own for the parse, which is the
1129
+ // parse the whole-frame path uses.
1130
+ _handlePieces(type, flags, sid, pieces, len) {
1131
+ // RFC 9113 6.10: the same rule the whole-frame path applies (see _handle).
1132
+ // A header block that is unfinished may only be followed by CONTINUATION.
1133
+ if (this._hdrSid !== -1 && type !== CONTINUATION) {
1134
+ return this._fatal(PROTOCOL_ERROR, 'frame interleaved with an unfinished header block');
1135
+ }
1136
+ if (type === DATA) {
1137
+ // RFC 9113 6.1: a padded payload's views are not the payload -- the pad
1138
+ // length is at the front and the padding at the back -- so the padding
1139
+ // comes off the views and the flow control counts the data alone.
1140
+ let data = len;
1141
+ if (flags & F_PADDED) {
1142
+ if (len === 0) {
1143
+ return this._fatal(PROTOCOL_ERROR, 'padded DATA frame with an empty payload');
1144
+ }
1145
+ data = trimPadding(pieces);
1146
+ if (data < 0) {
1147
+ return this._fatal(PROTOCOL_ERROR, 'pad larger than the DATA payload');
1148
+ }
1149
+ }
1150
+ this._onDataPieces(sid, flags, pieces, data);
1151
+ return this._open;
1152
+ }
1153
+ if (type === HEADERS) {
1154
+ this._beginHeadersPieces(sid, flags, pieces, len);
1155
+ return this._open;
1156
+ }
1157
+ if (type === CONTINUATION) {
1158
+ // the same two rules the whole-frame path applies (see _handle)
1159
+ if (this._hdrSid !== sid) {
1160
+ return this._fatal(PROTOCOL_ERROR, 'CONTINUATION without HEADERS');
1161
+ }
1162
+ if (++this._hdrCont > MAX_CONTINUATIONS) {
1163
+ return this._fatal(PROTOCOL_ERROR, 'CONTINUATION: too many frames for one block');
1164
+ }
1165
+ this._appendHdrPieces(pieces, 0, len);
1166
+ if (flags & F_END_HEADERS) {
1167
+ this._finishHeaders();
1168
+ }
1169
+ return this._open;
1170
+ }
1171
+ // the control frames: the payload goes into a buffer of its own and the
1172
+ // parse is the one _handle does
1173
+ const buf = pieceConcat(pieces, 0, len);
1174
+ return this._handle(type, flags, sid, buf, 0, len);
1175
+ }
1176
+
1177
+ _applySettings(buf, start, len) {
1178
+ if (len % 6 !== 0) {
1179
+ this._fatal(FRAME_SIZE_ERROR, 'SETTINGS payload size'); return;
1180
+ }
1181
+ if (len / 6 > MAX_SETTINGS_ENTRIES) {
1182
+ this._fatal(PROTOCOL_ERROR, 'SETTINGS: too many entries'); return;
1183
+ }
1184
+ const seen = this.remoteSettings;
1185
+ for (let i = 0; i + 6 <= len; i += 6) {
1186
+ const id = buf.readUInt16BE(start + i);
1187
+ const value = buf.readUInt32BE(start + i + 2);
1188
+ const name = SETTINGS_BY_ID[id];
1189
+ if (name === undefined) {
1190
+ continue;
1191
+ }
1192
+ seen[name] = value;
1193
+ }
1194
+ // A new INITIAL_WINDOW_SIZE shifts every open stream window by the delta (RFC 9113
1195
+ // 6.9.2), and may drive it negative -- the same rule the server applies inbound.
1196
+ const delta = seen.initialWindowSize - this._prevInitialWin;
1197
+ if (delta !== 0) {
1198
+ this._prevInitialWin = seen.initialWindowSize;
1199
+ for (const s of this._streams.values()) {
1200
+ s._sendWin += delta; this._pump(s);
1201
+ }
1202
+ }
1203
+ // The peer's table size bounds the encoder that builds blocks for ITS decoder.
1204
+ this._encoder.setPeerTableSize(seen.headerTableSize);
1205
+ // A SETTINGS_MAX_CONCURRENT_STREAMS just learned may open a slot for a parked
1206
+ // request (or a raised limit frees several at once).
1207
+ this._drainQueue();
1208
+ // SETTINGS can end our pending-ack wait and unblock queued sends.
1209
+ for (const s of this._streams.values()) {
1210
+ this._pump(s);
1211
+ }
1212
+ }
1213
+
1214
+ _beginHeaders(sid, flags, buf, start, len) {
1215
+ let p = start;
1216
+ let l = len;
1217
+ // A payload with no pad length octet has no block at all: the octet the
1218
+ // pad length would be read from is not this frame's, so it is not read.
1219
+ // The split path reads the same (no octet, no pad), so the two agree.
1220
+ if (flags & F_PADDED) {
1221
+ // RFC 9113 6.2: "If the length of the padding is the length of the frame
1222
+ // payload or greater, the recipient MUST treat this as a connection error
1223
+ // of type PROTOCOL_ERROR." A padded payload with no octet at all has no
1224
+ // pad length to read, which is the same connection error -- node ends the
1225
+ // session with ERR_HTTP2_ERROR for both (measured,
1226
+ // tools/probe-client-hdr-pad.js); the server refuses them identically
1227
+ // (lib/session.js _headersPrefix).
1228
+ if (len < 1) {
1229
+ return this._fatal(PROTOCOL_ERROR, 'padded HEADERS frame with an empty payload');
1230
+ }
1231
+ const pad = buf[p];
1232
+ p += 1;
1233
+ l -= 1 + pad;
1234
+ if (l < 0) {
1235
+ return this._fatal(PROTOCOL_ERROR, 'pad larger than the HEADERS payload');
1236
+ }
1237
+ }
1238
+ if (flags & F_PRIORITY) {
1239
+ p += 5; l -= 5;
1240
+ }
1241
+ this._hdrCont = 0;
1242
+ if (flags & F_END_HEADERS) {
1243
+ this._processHeaders(sid, (flags & F_END_STREAM) !== 0, buf.subarray(p, p + l));
1244
+ return;
1245
+ }
1246
+ this._hdrSid = sid;
1247
+ this._hdrEnd = (flags & F_END_STREAM) !== 0;
1248
+ this._hdrLen = 0;
1249
+ this._appendHdr(buf, p, l);
1250
+ }
1251
+
1252
+ // A HEADERS frame whose payload straddled two reads. The pad length is the
1253
+ // payload's first octet and the priority block is the five after it; both are
1254
+ // read out of the views, and the header block stays in them.
1255
+ _beginHeadersPieces(sid, flags, pieces, len) {
1256
+ let p = 0;
1257
+ let l = len;
1258
+ if (flags & F_PADDED) {
1259
+ // The split path enforces the same rule as the whole-buffer one above:
1260
+ // no pad-length octet, or padding that claims more than the payload
1261
+ // holds, is the connection error of RFC 9113 6.2.
1262
+ if (len < 1) {
1263
+ return this._fatal(PROTOCOL_ERROR, 'padded HEADERS frame with an empty payload');
1264
+ }
1265
+ const pad = pieceByteAt(pieces, 0);
1266
+ p += 1;
1267
+ l -= 1 + pad;
1268
+ if (l < 0) {
1269
+ return this._fatal(PROTOCOL_ERROR, 'pad larger than the HEADERS payload');
1270
+ }
1271
+ }
1272
+ if (flags & F_PRIORITY) {
1273
+ p += 5; l -= 5;
1274
+ }
1275
+ this._hdrCont = 0;
1276
+ if (l < 0) {
1277
+ l = 0;
1278
+ }
1279
+ if (flags & F_END_HEADERS) {
1280
+ this._processBlockPieces(sid, (flags & F_END_STREAM) !== 0, pieces, p, p + l);
1281
+ return;
1282
+ }
1283
+ this._hdrSid = sid;
1284
+ this._hdrEnd = (flags & F_END_STREAM) !== 0;
1285
+ this._hdrLen = 0;
1286
+ this._appendHdrPieces(pieces, p, p + l);
1287
+ }
1288
+
1289
+ _appendHdr(buf, start, len) {
1290
+ if (this._hdrPieces !== null) {
1291
+ // The block is already kept as the views it arrived in, and this frame's
1292
+ // payload is a view of the chunk it came in -- so it joins them.
1293
+ this._hdrPieces.push(buf.subarray(start, start + len));
1294
+ this._hdrLen += len;
1295
+ return;
1296
+ }
1297
+ if (this._hdr === null) {
1298
+ this._hdr = Buffer.allocUnsafe(8192);
1299
+ }
1300
+ if (this._hdrLen + len > this._hdr.length) {
1301
+ let cap = this._hdr.length * 2;
1302
+ while (cap < this._hdrLen + len) {
1303
+ cap *= 2;
1304
+ }
1305
+ const bigger = Buffer.allocUnsafe(cap);
1306
+ this._hdr.copy(bigger, 0, 0, this._hdrLen);
1307
+ this._hdr = bigger;
1308
+ }
1309
+ buf.copy(this._hdr, this._hdrLen, start, start + len);
1310
+ this._hdrLen += len;
1311
+ }
1312
+
1313
+ // The block is kept as the views it arrived in as soon as one frame of it
1314
+ // straddled a read. A block whose frames all arrived whole is kept in one
1315
+ // buffer, and the first split frame turns that buffer into the first view of
1316
+ // the list: the buffer is not written to again while the block is open, and
1317
+ // the block is decoded before the next one starts.
1318
+ _appendHdrPieces(pieces, from, to) {
1319
+ if (this._hdrPieces === null) {
1320
+ this._hdrPieces = this._hdrLen === 0 ? [] : [this._hdr.subarray(0, this._hdrLen)];
1321
+ }
1322
+ const parts = pieceSlice(pieces, from, to);
1323
+ for (let i = 0; i < parts.length; i++) {
1324
+ this._hdrPieces.push(parts[i]);
1325
+ }
1326
+ this._hdrLen += to - from;
1327
+ }
1328
+
1329
+ _finishHeaders() {
1330
+ const sid = this._hdrSid, end = this._hdrEnd;
1331
+ if (this._hdrPieces !== null) {
1332
+ const pieces = this._hdrPieces;
1333
+ const len = this._hdrLen;
1334
+ this._hdrSid = -1;
1335
+ this._hdrLen = 0;
1336
+ this._hdrPieces = null;
1337
+ this._processBlockPieces(sid, end, pieces, 0, len);
1338
+ return;
1339
+ }
1340
+ const block = this._hdr.subarray(0, this._hdrLen);
1341
+ this._hdrSid = -1;
1342
+ this._hdrLen = 0;
1343
+ this._processHeaders(sid, end, block);
1344
+ }
1345
+
1346
+ // The shape cache: the server's design, ported. A block whose bytes have been
1347
+ // seen before under the same dynamic-table state is decoded from a cached field
1348
+ // list rather than through the HPACK decoder again. The cache is dropped if the
1349
+ // decoder's epoch moves (a table-size change from the peer invalidates every
1350
+ // stored index), and gives up when the miss ratio proves the workload does not
1351
+ // repeat (same heuristic as session.js _processHeaders).
1352
+ _shapeSlot(block) {
1353
+ if (this._shapeEpoch !== this._decoder.epoch) {
1354
+ this._shapes.clear();
1355
+ this._shapeEpoch = this._decoder.epoch;
1356
+ }
1357
+ return hashBlock(block);
1358
+ }
1359
+
1360
+ // The same bucket for a block that arrived in pieces: the hash is over the
1361
+ // same octets, so a block that arrives whole and one that arrives split are
1362
+ // the same cache entry.
1363
+ _shapeSlotPieces(pieces, from, to) {
1364
+ if (this._shapeEpoch !== this._decoder.epoch) {
1365
+ this._shapes.clear();
1366
+ this._shapeEpoch = this._decoder.epoch;
1367
+ }
1368
+ return hashPieces(pieces, from, to);
1369
+ }
1370
+
1371
+ // A header block that arrived whole and one that arrived in pieces are the
1372
+ // same block: the octets are the same, so the hash, the cache entry and the
1373
+ // fields are the same. `block` is null for the split one, and the range
1374
+ // [from, to) of `pieces` is where the block sits in the views.
1375
+ _processHeaders(sid, end, block) {
1376
+ this._processBlock(sid, end, block, null, 0, 0);
1377
+ }
1378
+
1379
+ _processBlockPieces(sid, end, pieces, from, to) {
1380
+ this._processBlock(sid, end, null, pieces, from, to);
1381
+ }
1382
+
1383
+ _processBlock(sid, end, block, pieces, from, to) {
1384
+ const stream = this._streams.get(sid);
1385
+ const flat = this._flat;
1386
+ // The cache is disabled (null) when stream === undefined (we must decode for
1387
+ // HPACK state but there is nothing to cache it for) and when the application
1388
+ // opted out via shapeCache: false.
1389
+ const shapes = stream === undefined ? null : this._shapes;
1390
+ const slot = shapes === null ? 0
1391
+ : (block !== null ? this._shapeSlot(block) : this._shapeSlotPieces(pieces, from, to));
1392
+ const hit = shapes === null ? undefined : shapes.get(slot);
1393
+ let fields = flat;
1394
+ try {
1395
+ if (hit !== undefined && (block !== null
1396
+ ? hit.block.equals(block)
1397
+ : pieceEquals(hit.block, pieces, from, to))) {
1398
+ this._shapeHits++;
1399
+ fields = hit.flat;
1400
+ } else {
1401
+ if (block !== null) {
1402
+ this._decoder.decode(block, 0, block.length, flat);
1403
+ } else {
1404
+ this._decoder.decodePieces(pieces, from, to, flat);
1405
+ }
1406
+ if (shapes !== null) {
1407
+ if (++this._shapeMisses > 64 && this._shapeHits * 4 < this._shapeMisses) {
1408
+ this._shapes = null;
1409
+ } else {
1410
+ if (shapes.size >= MAX_SHAPES) {
1411
+ shapes.clear();
1412
+ }
1413
+ shapes.set(slot, {
1414
+ block: block !== null ? Buffer.from(block) : pieceConcat(pieces, from, to),
1415
+ flat: flat.slice(0)
1416
+ });
1417
+ }
1418
+ }
1419
+ }
1420
+ } catch (err) {
1421
+ if (err instanceof HPackError) {
1422
+ this._fatal(COMPRESSION_ERROR, err.message);
1423
+ } else {
1424
+ throw err;
1425
+ }
1426
+ return;
1427
+ }
1428
+ if (stream === undefined) {
1429
+ // HPACK is in sync (the block decoded above). nghttp2 owns the verdict for
1430
+ // a block naming no live stream (session_process_headers_frame falls
1431
+ // through to nghttp2_session_on_request_headers_received,
1432
+ // nghttp2_session.c): for a client an idle stream -- any id the client has
1433
+ // not sent, odd or even -- is a connection error (NGHTTP2_ERR_PROTO, hence
1434
+ // GOAWAY(PROTOCOL_ERROR) on the wire and ERR_HTTP2_ERROR on the stream and
1435
+ // the session), while a closed stream at or below last_sent_stream_id is
1436
+ // silently ignored (NGHTTP2_ERR_IGN_HEADER_BLOCK). Both measured on node
1437
+ // v26.12.1 (tools/probe-client-unopened-headers.js). The client allocates
1438
+ // odd ids sequentially, so the odd ids below _nextSid are exactly the
1439
+ // opened-and-closed ones.
1440
+ if ((sid & 1) === 1 && sid < this._nextSid) {
1441
+ return;
1442
+ }
1443
+ return this._fatal(PROTOCOL_ERROR, 'response HEADERS on an unopened stream id');
1444
+ }
1445
+ if (this._refuseHeaders(sid, stream, fields)) {
1446
+ return;
1447
+ }
1448
+ const headers = this._toObject(fields);
1449
+ stream._onHeaders(headers, end);
1450
+ if (end) {
1451
+ this._dropStream(sid);
1452
+ }
1453
+ }
1454
+
1455
+ // The response pseudo-header rules node enforces in nghttp2's C layer
1456
+ // (nghttp2_http.c nghttp2_http_on_response_headers); node's JS carries no
1457
+ // such check, so the verdicts are measured (tools/probe-client-status.js):
1458
+ // a response block without exactly one :status, or carrying a request-only
1459
+ // pseudo-header, and a trailer block with any pseudo-header at all (RFC 9113
1460
+ // 8.1) are all the same per-stream verdict -- ERR_HTTP2_STREAM_ERROR on the
1461
+ // stream, RST_STREAM(PROTOCOL_ERROR) on the wire, the session stays up.
1462
+ // Both shapes used to reach the application unvalidated. Returns true when
1463
+ // the block was refused, so the caller does not build or hand over the
1464
+ // header object.
1465
+ _refuseHeaders(sid, stream, fields) {
1466
+ let status = 0;
1467
+ let bad = null;
1468
+ for (let i = 0; i < fields.length; i += 2) {
1469
+ const name = fields[i];
1470
+ if (name.charCodeAt(0) !== 58) {
1471
+ continue;
1472
+ }
1473
+ // The first block is the response and must carry exactly one :status;
1474
+ // every block after it is trailers, where any pseudo-header at all is
1475
+ // malformed (RFC 9113 8.1).
1476
+ if (!stream.responseEmitted && name === ':status') {
1477
+ status++;
1478
+ } else {
1479
+ bad = name;
1480
+ break;
1481
+ }
1482
+ }
1483
+ if (stream.responseEmitted) {
1484
+ // Trailers: legal whenever no pseudo-header appears at all -- in
1485
+ // particular they carry no :status (the node:http2 server's own
1486
+ // addTrailers blocks are the differential case,
1487
+ // test/client/client-stream.test.js).
1488
+ if (bad === null) {
1489
+ return false;
1490
+ }
1491
+ } else if (bad === null && status === 1) {
1492
+ // The response: exactly one :status and no other pseudo-header.
1493
+ return false;
1494
+ }
1495
+ // The per-stream verdict: RST_STREAM on the wire, ERR_HTTP2_STREAM_ERROR
1496
+ // on the stream, no 'response'/'trailers' event, the connection lives on.
1497
+ this.rstStream(sid, PROTOCOL_ERROR);
1498
+ stream._onRst(PROTOCOL_ERROR);
1499
+ return true;
1500
+ }
1501
+
1502
+ _toObject(flat) {
1503
+ const o = nullProto();
1504
+ for (let i = 0; i < flat.length; i += 2) {
1505
+ const name = flat[i];
1506
+ let value = flat[i + 1];
1507
+ if (name === ':status') {
1508
+ value = value | 0;
1509
+ }
1510
+ const have = o[name];
1511
+ if (have === undefined) {
1512
+ o[name] = name === 'set-cookie' ? [value] : value;
1513
+ } else if (name === 'cookie') {
1514
+ o[name] = `${have}; ${value}`;
1515
+ } else if (name === 'set-cookie') {
1516
+ have.push(value);
1517
+ } else {
1518
+ o[name] = `${have}, ${value}`;
1519
+ }
1520
+ }
1521
+ return o;
1522
+ }
1523
+
1524
+ _onDataFrame(sid, flags, buf, start, len) {
1525
+ let p = start;
1526
+ let l = len;
1527
+ if (flags & F_PADDED) {
1528
+ // RFC 9113 6.1: the payload is the pad length, the data, and the padding.
1529
+ // A padded frame with no payload has no pad length to read, and node
1530
+ // answers that with GOAWAY(PROTOCOL_ERROR) (measured, tools/probe-pad-bad.js).
1531
+ if (len === 0) {
1532
+ this._fatal(PROTOCOL_ERROR, 'padded DATA frame with an empty payload'); return;
1533
+ }
1534
+ const pad = buf[p];
1535
+ p += 1;
1536
+ l -= 1 + pad;
1537
+ if (l < 0) {
1538
+ this._fatal(PROTOCOL_ERROR, 'pad larger than the DATA payload'); return;
1539
+ }
1540
+ }
1541
+ const stream = this._streams.get(sid);
1542
+ if (stream === undefined) {
1543
+ return;
1544
+ }
1545
+ // Handed over as a view, the way the server hands DATA to its application: it is
1546
+ // a slice of the chunk node gave us, which node never rewrites after delivery.
1547
+ // A padded frame's payload is a view too -- the pad length and the padding are
1548
+ // taken off the views before this point (frames.js trimPadding). The old
1549
+ // copy-out existed only because the client used to accumulate every read into a
1550
+ // shared buffer it reused -- that buffer is gone.
1551
+ stream._onData(buf.subarray(p, p + l));
1552
+ // Book the consumed bytes as credit owed to BOTH windows; the frames themselves
1553
+ // wait for the half-window threshold at the end of this read batch. Marking the
1554
+ // stream in _debt is what tells that pass which streams to visit.
1555
+ if (l > 0) {
1556
+ this._connRecvUsed += l;
1557
+ stream._recvUsed += l;
1558
+ if (!stream._debtMarked) {
1559
+ stream._debtMarked = true; this._debt.push(stream);
1560
+ }
1561
+ }
1562
+ if (flags & F_END_STREAM) {
1563
+ stream._endRead(); this._dropStream(sid);
1564
+ }
1565
+ }
1566
+
1567
+ // A payload that straddled two reads: one piece per read, each a view of the
1568
+ // chunk node read off the socket. No copy, and the pieces are the frame's bytes
1569
+ // in the order the peer sent them.
1570
+ _onDataPieces(sid, flags, pieces, total) {
1571
+ const stream = this._streams.get(sid);
1572
+ if (stream === undefined) {
1573
+ return;
1574
+ }
1575
+ for (let i = 0; i < pieces.length; i++) {
1576
+ // A piece the padding took all of is empty, and an empty chunk is not a
1577
+ // chunk: node emits no 'data' for one.
1578
+ if (pieces[i] !== null) {
1579
+ stream._onData(pieces[i]);
1580
+ }
1581
+ }
1582
+ // Book the consumed bytes as credit owed to BOTH windows; the frames themselves
1583
+ // wait for the half-window threshold at the end of this read batch. Marking the
1584
+ // stream in _debt is what tells that pass which streams to visit.
1585
+ if (total > 0) {
1586
+ this._connRecvUsed += total;
1587
+ stream._recvUsed += total;
1588
+ if (!stream._debtMarked) {
1589
+ stream._debtMarked = true; this._debt.push(stream);
1590
+ }
1591
+ }
1592
+ if (flags & F_END_STREAM) {
1593
+ stream._endRead(); this._dropStream(sid);
1594
+ }
1595
+ }
1596
+
1597
+ _onWindowUpdate(sid, inc) {
1598
+ if (inc === 0) {
1599
+ this._fatal(PROTOCOL_ERROR, 'WINDOW_UPDATE increment of 0'); return;
1600
+ }
1601
+ if (sid === 0) {
1602
+ this._connSendWin += inc;
1603
+ for (const s of this._streams.values()) {
1604
+ this._pump(s);
1605
+ }
1606
+ return;
1607
+ }
1608
+ const stream = this._streams.get(sid);
1609
+ if (stream === undefined) {
1610
+ return;
1611
+ }
1612
+ stream._sendWin += inc;
1613
+ this._pump(stream);
1614
+ }
1615
+
1616
+ _onRst(sid, code) {
1617
+ const stream = this._streams.get(sid);
1618
+ if (stream === undefined) {
1619
+ return;
1620
+ }
1621
+ this._dropStream(sid);
1622
+ stream._onRst(code);
1623
+ }
1624
+
1625
+ // Outbound reset: a stream the application abandons mid-flight (destroy/close)
1626
+ // owes the peer an RST_STREAM so its resources are freed. No-ops once the stream
1627
+ // is gone -- including the inbound-RST path, where _onRst has already dropped it,
1628
+ // so we never answer a reset with a reset of our own.
1629
+ rstStream(sid, code) {
1630
+ if (this.destroyed || !this._open || !this._streams.has(sid)) {
1631
+ return;
1632
+ }
1633
+ const body = Buffer.allocUnsafe(4);
1634
+ body.writeUInt32BE((code === undefined ? CANCEL : code) >>> 0, 0);
1635
+ this._frame(RST_STREAM, 0, sid, body, true);
1636
+ this._dropStream(sid);
1637
+ }
1638
+
1639
+ _onGoaway(buf, start, len) {
1640
+ if (len < 8) {
1641
+ this._fatal(FRAME_SIZE_ERROR, 'GOAWAY payload too short'); return this._open;
1642
+ }
1643
+ const lastStreamID = buf.readUInt32BE(start) & 0x7fffffff;
1644
+ const code = buf.readUInt32BE(start + 4);
1645
+ let debug = null;
1646
+ if (len > 8) {
1647
+ // allocUnsafe + copy rather than Buffer.from(subarray) for the pooled
1648
+ // allocation (OPTIMIZATION.md item 18)
1649
+ debug = Buffer.allocUnsafe(len - 8);
1650
+ buf.copy(debug, 0, start + 8, start + len);
1651
+ }
1652
+ this._goawayCode = code;
1653
+ this._goawayLastStreamID = lastStreamID;
1654
+ // Streams the peer refused to process (id above lastStreamID) may be retried;
1655
+ // for M1 we report them and stop opening new ones past the mark.
1656
+ if (lastStreamID < this._nextSid - 2) {
1657
+ this.closed = true;
1658
+ }
1659
+ // node hands the third goaway argument as undefined when the peer sent no debug
1660
+ // data (not an empty buffer), so the measured event shape matches only if we do.
1661
+ this.emit('goaway', code, lastStreamID, debug === null || debug.length === 0
1662
+ ? undefined : debug);
1663
+ // A GOAWAY with no live streams left ends the connection for a client too.
1664
+ if (this._streams.size === 0) {
1665
+ this.close();
1666
+ }
1667
+ return this._open;
1668
+ }
1669
+
1670
+ // Credit release. Every update due from one read batch is serialised into the
1671
+ // session's own `_wuBuf` and leaves there as ONE write at the end of the pass (see
1672
+ // _creditRecv); this method only appends. Two properties make it safe.
1673
+ //
1674
+ // The buffer is owned by this session and never handed to the application, so it may
1675
+ // be kept and rewritten the way the per-frame allocation used to be: one 13-octet
1676
+ // memcpy replaces one Buffer.allocUnsafe plus one copy per update, and an eight-step
1677
+ // download closes with one small write instead of eight.
1678
+ //
1679
+ // Callers must be inside a credit pass: the bytes only reach the socket when
1680
+ // _creditRecv() empties _wuBuf at the end of the sweep.
1681
+ _windowUpdate(sid, inc) {
1682
+ if (inc <= 0) {
1683
+ return;
1684
+ }
1685
+ let buf = this._wuBuf;
1686
+ if (buf === null) {
1687
+ buf = this._wuBuf = Buffer.allocUnsafe(13 * 8);
1688
+ }
1689
+ const len = this._wuLen;
1690
+ if (len + HEADER_LEN + 4 > buf.length) {
1691
+ const bigger = Buffer.allocUnsafe((len + HEADER_LEN + 4) * 2);
1692
+ buf.copy(bigger, 0, 0, len);
1693
+ buf = this._wuBuf = bigger;
1694
+ }
1695
+ writeHeader(buf, len, 4, WINDOW_UPDATE, 0, sid);
1696
+ buf.writeUInt32BE(inc >>> 0, len + HEADER_LEN);
1697
+ this._wuLen = len + HEADER_LEN + 4;
1698
+ }
1699
+
1700
+ // ---- session API ------------------------------------------------------------
1701
+ ping(payload, cb) {
1702
+ // node lets a caller pass the callback as the only argument: ping(cb).
1703
+ if (typeof payload === 'function') {
1704
+ cb = payload; payload = undefined;
1705
+ }
1706
+ const body = Buffer.allocUnsafe(8);
1707
+ if (payload === undefined) {
1708
+ const seq = this._pingSeq++;
1709
+ body.writeUInt32BE(seq >>> 0, 0);
1710
+ body.writeUInt32BE(0, 4);
1711
+ } else {
1712
+ // Buffer.from(payload) would clone the caller's whole buffer just to take the
1713
+ // first eight octets out of it; when the caller already passed a Buffer, copy
1714
+ // straight out of it. The fallback keeps whatever else a caller may pass
1715
+ // (a Uint8Array, a string) working the way Buffer.from decoded it.
1716
+ if (Buffer.isBuffer(payload)) {
1717
+ payload.copy(body, 0, 0, 8);
1718
+ } else {
1719
+ Buffer.from(payload).copy(body, 0, 0, 8);
1720
+ }
1721
+ }
1722
+ if (typeof cb === 'function') {
1723
+ this._pings.set(body.toString('latin1'), { cb, at: process.hrtime.bigint() });
1724
+ }
1725
+ this._frame(PING, 0, 0, body, true);
1726
+ return body;
1727
+ }
1728
+
1729
+ settings(payload, cb) {
1730
+ if (payload !== null && payload !== undefined) {
1731
+ const body = Buffer.from(payload);
1732
+ this._frame(SETTINGS, 0, 0, body, true);
1733
+ if (typeof cb === 'function') {
1734
+ process.nextTick(cb);
1735
+ }
1736
+ } else if (typeof payload === 'function') {
1737
+ process.nextTick(payload);
1738
+ }
1739
+ }
1740
+
1741
+ goaway(code, lastStreamID, reason) {
1742
+ if (code === undefined) {
1743
+ code = NO_ERROR;
1744
+ }
1745
+ if (lastStreamID === undefined || lastStreamID === null) {
1746
+ lastStreamID = this._nextSid % 2 === 0 ? this._nextSid - 1 : this._nextSid - 2;
1747
+ if (lastStreamID < 0) {
1748
+ lastStreamID = 0;
1749
+ }
1750
+ }
1751
+ const tail = reason ? Buffer.byteLength(reason) : 0;
1752
+ const body = Buffer.alloc(8 + tail);
1753
+ body.writeUInt32BE(lastStreamID >>> 0, 0);
1754
+ body.writeUInt32BE(code >>> 0, 4);
1755
+ if (tail) {
1756
+ body.write(reason, 8);
1757
+ }
1758
+ this._frame(GOAWAY, 0, 0, body, true);
1759
+ this.closed = true;
1760
+ }
1761
+
1762
+ close(code, cb) {
1763
+ if (typeof code === 'function') {
1764
+ cb = code; code = undefined;
1765
+ }
1766
+ this._closeRequested = true;
1767
+ if (!this.destroyed) {
1768
+ this.goaway(code === undefined ? NO_ERROR : code);
1769
+ }
1770
+ this._maybeClose();
1771
+ if (typeof cb === 'function') {
1772
+ if (this.closed && this._streams.size === 0) {
1773
+ process.nextTick(cb);
1774
+ } else {
1775
+ this.once('close', cb);
1776
+ }
1777
+ }
1778
+ }
1779
+
1780
+ destroy(err) {
1781
+ if (this.destroyed) {
1782
+ return;
1783
+ }
1784
+ this.destroyed = true;
1785
+ this.closed = true;
1786
+ this._open = false;
1787
+ for (const s of this._streams.values()) {
1788
+ if (!s.destroyed) {
1789
+ s.destroy(err);
1790
+ }
1791
+ }
1792
+ this._streams.clear();
1793
+ // Requests whose HEADERS never went on the wire are cancelled, not handed the
1794
+ // session's error: node distinguishes the two (internal/http2/core.js:1254-1260,
1795
+ // state.pendingStreams.forEach((stream) => stream.destroy(cancel))) so an
1796
+ // application can tell "my request was never sent" from "the connection broke
1797
+ // under it". Measured on a TLS handshake failure: node answers a queued request
1798
+ // with stream error ERR_HTTP2_STREAM_CANCEL before the session error, h2lite used
1799
+ // to leave it silent.
1800
+ if (this._queuedReq !== null && this._queuedReq.length > 0) {
1801
+ for (const r of this._queuedReq) {
1802
+ if (!r.stream.destroyed) {
1803
+ r.stream.destroy(cancelErr(err));
1804
+ }
1805
+ }
1806
+ this._queuedReq = null;
1807
+ }
1808
+ for (const r of this._outboundQueue) {
1809
+ if (!r.stream.destroyed) {
1810
+ r.stream.destroy(cancelErr(err));
1811
+ }
1812
+ }
1813
+ this._outboundQueue.length = 0;
1814
+ const socket = this.socket;
1815
+ this._discardOut();
1816
+ if (!socket.destroyed) {
1817
+ // Bytes already handed to the socket -- the GOAWAY a fatal inbound frame
1818
+ // leaves with (_fatal) -- are flushed by end() before the FIN; an outright
1819
+ // destroy() drops them whenever a write is still in flight. With nothing
1820
+ // queued the destroy is the immediate kill it always was.
1821
+ if (socket.writable && !socket.writableEnded && socket.writableLength > 0) {
1822
+ socket.end();
1823
+ } else {
1824
+ socket.destroy();
1825
+ }
1826
+ }
1827
+ process.nextTick(() => {
1828
+ if (!this._closeEmitted) {
1829
+ this._emitClose(err);
1830
+ }
1831
+ });
1832
+ }
1833
+
1834
+ ref() {
1835
+ this.socket.ref(); return this;
1836
+ }
1837
+ unref() {
1838
+ this.socket.unref(); return this;
1839
+ }
1840
+
1841
+ setTimeout(msecs, callback) {
1842
+ // node's session.setTimeout sets an idle timer; with no activity it emits
1843
+ // 'timeout' and, absent a listener, destroys the session. Kept faithful here.
1844
+ if (this._idleTimer) {
1845
+ clearTimeout(this._idleTimer);
1846
+ }
1847
+ this._idleCb = callback || null;
1848
+ if (msecs > 0 && !this.destroyed) {
1849
+ this._idleTimer = setTimeout(() => {
1850
+ this._idleTimer = null;
1851
+ this.emit('timeout');
1852
+ if (this._idleCb === null && !this.destroyed) {
1853
+ this.destroy();
1854
+ }
1855
+ }, msecs);
1856
+ this._idleTimer.unref();
1857
+ }
1858
+ return this;
1859
+ }
1860
+
1861
+ setNextStreamID(id) {
1862
+ this._nextSid = id; return this;
1863
+ }
1864
+ setLocalWindowSize(size) {
1865
+ // RFC 9113 6.9.2: the change rescales the peer's send windows retroactively, so the
1866
+ // window we maintain moves by the same delta rather than only applying to streams
1867
+ // opened from here on -- and _creditRecv()'s threshold is derived from it per call.
1868
+ this._connRecvWin += size - this.localSettings.initialWindowSize;
1869
+ this.localSettings.initialWindowSize = size;
1870
+ return this;
1871
+ }
1872
+
1873
+ // ---- teardown ---------------------------------------------------------------
1874
+ // A client connection is keep-alive: finishing the last stream must NOT tear the
1875
+ // session down, or the next request() on the same session would find it destroyed.
1876
+ // Only an explicit close()/goaway (which sets _closeRequested), a peer GOAWAY that
1877
+ // left nothing live, or destroy() ends it -- then it ends once the outbound queue and
1878
+ // the open streams have both drained.
1879
+ _maybeClose() {
1880
+ if (this.destroyed || this._closeEmitted) {
1881
+ return;
1882
+ }
1883
+ if (this._closeRequested && this._streams.size === 0 && this._outboundQueue.length === 0
1884
+ && this._outLen === 0) {
1885
+ this.destroy();
1886
+ }
1887
+ }
1888
+
1889
+ // _debt is drained at every point that ends a stream's life -- _dropStream (inbound
1890
+ // END_STREAM, RST, outbound reset, and the destroy path, which goes through
1891
+ // rstStream) -- so the list never grows across a long-lived connection and never
1892
+ // visits a closed stream. The mark is the guard: a stream that took no unsent credit
1893
+ // costs one property read here, and the scan only runs for the few that did.
1894
+ //
1895
+ // Dropping a stream that still owes stream-level credit is deliberate, not a leak: the
1896
+ // connection's share of those octets was booked into _connRecvUsed when they arrived
1897
+ // (as on the server, where _abandon() moves them), and the stream itself is closed, so
1898
+ // nobody can send another DATA on it that the missing WINDOW_UPDATE could unblock.
1899
+ _removeDebt(stream) {
1900
+ const debt = this._debt;
1901
+ for (let i = 0; i < debt.length; i++) {
1902
+ if (debt[i] === stream) {
1903
+ debt[i] = debt[debt.length - 1];
1904
+ debt.pop();
1905
+ stream._debtMarked = false;
1906
+ return;
1907
+ }
1908
+ }
1909
+ }
1910
+
1911
+ _dropStream(sid) {
1912
+ const stream = this._streams.get(sid);
1913
+ if (stream !== undefined && stream._debtMarked) {
1914
+ this._removeDebt(stream);
1915
+ }
1916
+ this._streams.delete(sid);
1917
+ this._drainQueue();
1918
+ this._maybeClose();
1919
+ }
1920
+
1921
+ _fatal(code, reason) {
1922
+ if (!this._open) {
1923
+ return false;
1924
+ }
1925
+ this._open = false;
1926
+ // node's client answers a fatal inbound frame with GOAWAY on the wire and
1927
+ // ERR_HTTP2_ERROR on the stream and on the session (measured,
1928
+ // tools/probe-client-pad-bad.js and tools/probe-client-hdr-pad.js).
1929
+ // destroy() drops the staged batch and resets the socket, so the batch --
1930
+ // the GOAWAY and anything staged behind it -- is handed to the socket here,
1931
+ // and destroy() leaves a queued batch alive (it ends the socket instead of
1932
+ // resetting it while bytes are still queued, so a write already in flight
1933
+ // drains first).
1934
+ this.goaway(code, undefined, reason);
1935
+ this._commit();
1936
+ const batch = this._outQ;
1937
+ this._outQ = [];
1938
+ this._outLen = 0;
1939
+ const socket = this.socket;
1940
+ if (batch.length !== 0 && socket.writable && !socket.destroyed) {
1941
+ if (batch.length === 1) {
1942
+ socket.write(batch[0]);
1943
+ } else {
1944
+ socket.cork();
1945
+ for (let i = 0; i < batch.length - 1; i++) {
1946
+ socket.write(batch[i]);
1947
+ }
1948
+ socket.write(batch[batch.length - 1]);
1949
+ socket.uncork();
1950
+ }
1951
+ }
1952
+ const err = httpErr('ERR_HTTP2_ERROR', reason);
1953
+ err.http2Code = code;
1954
+ this.destroy(err);
1955
+ return false;
1956
+ }
1957
+
1958
+ _onSocketError(err) {
1959
+ if (err && err.code === 'ECONNRESET' && this._goawayCode !== null) {
1960
+ return this.destroy();
1961
+ }
1962
+ this.destroy(err);
1963
+ }
1964
+
1965
+ _onSocketClose() {
1966
+ if (this._closeEmitted) {
1967
+ return;
1968
+ }
1969
+ this.destroyed = true;
1970
+ this._emitClose();
1971
+ }
1972
+
1973
+ _emitClose(err) {
1974
+ if (this._closeEmitted) {
1975
+ return;
1976
+ }
1977
+ this._closeEmitted = true;
1978
+ if (err) {
1979
+ this.emit('error', err);
1980
+ }
1981
+ this.emit('close');
1982
+ }
1983
+
1984
+ // ---- read-only surface (node-compatible) ------------------------------------
1985
+ // The last GOAWAY this side received. Before any GOAWAY node reports the code 0
1986
+ // (NO_ERROR) and lastStreamID 0; we mirror that default rather than leaking null.
1987
+ get goawayCode() {
1988
+ return this._goawayCode === null ? 0 : this._goawayCode;
1989
+ }
1990
+ get goawayLastStreamID() {
1991
+ return this._goawayLastStreamID === null ? 0 : this._goawayLastStreamID;
1992
+ }
1993
+
1994
+ // node's `state` is an nghttp2-internal snapshot. The fields an application can
1995
+ // legitimately read are the window sizes and the stream id bookkeeping, so those
1996
+ // are provided; the dynamic-table figures are reported live off the codecs.
1997
+ get state() {
1998
+ return {
1999
+ effectiveLocalWindowSize: this.localSettings.initialWindowSize,
2000
+ effectiveRecvDataLength: 0,
2001
+ nextStreamID: this._nextSid,
2002
+ localWindowSize: this.localSettings.initialWindowSize,
2003
+ lastProcStreamID: this._goawayLastStreamID === null ? 0 : this._goawayLastStreamID,
2004
+ remoteWindowSize: this._connSendWin,
2005
+ outboundQueueSize: this._outboundQueue.length,
2006
+ deflateDynamicTableSize: this._encoder.size,
2007
+ inflateDynamicTableSize: this._decoder.size
2008
+ };
2009
+ }
2010
+ }
2011
+
2012
+ // Helpers ---------------------------------------------------------------------
2013
+
2014
+ // Pseudo-headers must precede regular fields (RFC 9113 8.1); the application may
2015
+ // have interleaved them, so re-order :method, :path, :scheme, :authority, :protocol
2016
+ // to the front and leave the rest in place.
2017
+ function orderPseudo(list) {
2018
+ const pseudo = [];
2019
+ const fields = [];
2020
+ for (let i = 0; i < list.length; i += 2) {
2021
+ (list[i].charCodeAt(0) === 58 ? pseudo : fields).push(list[i], list[i + 1]);
2022
+ }
2023
+ const byName = (n) => {
2024
+ for (let i = 0; i < pseudo.length; i += 2) {
2025
+ if (pseudo[i] === n) {
2026
+ return pseudo[i + 1];
2027
+ }
2028
+ }
2029
+ return undefined;
2030
+ };
2031
+ const out = [];
2032
+ for (const n of [':method', ':path', ':scheme', ':authority', ':protocol']) {
2033
+ const v = byName(n);
2034
+ if (v !== undefined) {
2035
+ out.push(n, v);
2036
+ }
2037
+ }
2038
+ for (let i = 0; i < pseudo.length; i += 2) {
2039
+ if (out.indexOf(pseudo[i]) === -1) {
2040
+ out.push(pseudo[i], pseudo[i + 1]);
2041
+ }
2042
+ }
2043
+ for (let i = 0; i < fields.length; i += 2) {
2044
+ out.push(fields[i], fields[i + 1]);
2045
+ }
2046
+ return out;
2047
+ }
2048
+
2049
+ function httpErr(code, msg) {
2050
+ const err = new Error(msg);
2051
+ err.code = code;
2052
+ return err;
2053
+ }
2054
+
2055
+ // The shape of node's ERR_HTTP2_STREAM_CANCEL (internal/errors.js:1335): the cause
2056
+ // rides along as .cause, and the message names it the way util did when node built
2057
+ // the string there. With no cause (a plain destroy()) the bare message is what node
2058
+ // emits too.
2059
+ function cancelErr(cause) {
2060
+ const err = httpErr('ERR_HTTP2_STREAM_CANCEL', 'The pending stream has been canceled'
2061
+ + (cause ? ` (caused by: ${cause.message})` : ''));
2062
+ if (cause) {
2063
+ err.cause = cause;
2064
+ }
2065
+ return err;
2066
+ }
2067
+
2068
+ module.exports = { ClientHttp2Session };