nlcurl 0.1.0 → 0.3.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 (197) hide show
  1. package/README.md +5 -13
  2. package/dist/cli/args.d.ts +37 -5
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/args.js +6 -17
  5. package/dist/cli/args.js.map +1 -1
  6. package/dist/cli/index.d.ts +3 -3
  7. package/dist/cli/index.js +25 -10
  8. package/dist/cli/index.js.map +1 -1
  9. package/dist/cli/output.d.ts +24 -7
  10. package/dist/cli/output.d.ts.map +1 -1
  11. package/dist/cli/output.js +24 -12
  12. package/dist/cli/output.js.map +1 -1
  13. package/dist/cookies/jar.d.ts +45 -13
  14. package/dist/cookies/jar.d.ts.map +1 -1
  15. package/dist/cookies/jar.js +88 -29
  16. package/dist/cookies/jar.js.map +1 -1
  17. package/dist/cookies/parser.d.ts +25 -3
  18. package/dist/cookies/parser.d.ts.map +1 -1
  19. package/dist/cookies/parser.js +12 -7
  20. package/dist/cookies/parser.js.map +1 -1
  21. package/dist/core/client.d.ts +49 -33
  22. package/dist/core/client.d.ts.map +1 -1
  23. package/dist/core/client.js +65 -38
  24. package/dist/core/client.js.map +1 -1
  25. package/dist/core/errors.d.ts +94 -6
  26. package/dist/core/errors.d.ts.map +1 -1
  27. package/dist/core/errors.js +95 -6
  28. package/dist/core/errors.js.map +1 -1
  29. package/dist/core/request.d.ts +99 -32
  30. package/dist/core/request.d.ts.map +1 -1
  31. package/dist/core/request.js +0 -3
  32. package/dist/core/request.js.map +1 -1
  33. package/dist/core/response.d.ts +92 -8
  34. package/dist/core/response.d.ts.map +1 -1
  35. package/dist/core/response.js +92 -7
  36. package/dist/core/response.js.map +1 -1
  37. package/dist/core/session.d.ts +109 -14
  38. package/dist/core/session.d.ts.map +1 -1
  39. package/dist/core/session.js +143 -49
  40. package/dist/core/session.js.map +1 -1
  41. package/dist/fingerprints/akamai.d.ts +11 -11
  42. package/dist/fingerprints/akamai.d.ts.map +1 -1
  43. package/dist/fingerprints/akamai.js +10 -14
  44. package/dist/fingerprints/akamai.js.map +1 -1
  45. package/dist/fingerprints/database.d.ts +14 -15
  46. package/dist/fingerprints/database.d.ts.map +1 -1
  47. package/dist/fingerprints/database.js +14 -19
  48. package/dist/fingerprints/database.js.map +1 -1
  49. package/dist/fingerprints/extensions.d.ts +121 -27
  50. package/dist/fingerprints/extensions.d.ts.map +1 -1
  51. package/dist/fingerprints/extensions.js +132 -49
  52. package/dist/fingerprints/extensions.js.map +1 -1
  53. package/dist/fingerprints/ja3.d.ts +34 -18
  54. package/dist/fingerprints/ja3.d.ts.map +1 -1
  55. package/dist/fingerprints/ja3.js +34 -18
  56. package/dist/fingerprints/ja3.js.map +1 -1
  57. package/dist/fingerprints/profiles/chrome.d.ts +21 -10
  58. package/dist/fingerprints/profiles/chrome.d.ts.map +1 -1
  59. package/dist/fingerprints/profiles/chrome.js +25 -22
  60. package/dist/fingerprints/profiles/chrome.js.map +1 -1
  61. package/dist/fingerprints/profiles/edge.d.ts +10 -7
  62. package/dist/fingerprints/profiles/edge.d.ts.map +1 -1
  63. package/dist/fingerprints/profiles/edge.js +10 -10
  64. package/dist/fingerprints/profiles/edge.js.map +1 -1
  65. package/dist/fingerprints/profiles/firefox.d.ts +11 -3
  66. package/dist/fingerprints/profiles/firefox.d.ts.map +1 -1
  67. package/dist/fingerprints/profiles/firefox.js +15 -14
  68. package/dist/fingerprints/profiles/firefox.js.map +1 -1
  69. package/dist/fingerprints/profiles/safari.d.ts +14 -3
  70. package/dist/fingerprints/profiles/safari.d.ts.map +1 -1
  71. package/dist/fingerprints/profiles/safari.js +16 -13
  72. package/dist/fingerprints/profiles/safari.js.map +1 -1
  73. package/dist/fingerprints/profiles/tor.d.ts +8 -7
  74. package/dist/fingerprints/profiles/tor.d.ts.map +1 -1
  75. package/dist/fingerprints/profiles/tor.js +8 -14
  76. package/dist/fingerprints/profiles/tor.js.map +1 -1
  77. package/dist/fingerprints/types.d.ts +70 -47
  78. package/dist/fingerprints/types.d.ts.map +1 -1
  79. package/dist/fingerprints/types.js +0 -7
  80. package/dist/fingerprints/types.js.map +1 -1
  81. package/dist/http/h1/client.d.ts +30 -9
  82. package/dist/http/h1/client.d.ts.map +1 -1
  83. package/dist/http/h1/client.js +153 -20
  84. package/dist/http/h1/client.js.map +1 -1
  85. package/dist/http/h1/encoder.d.ts +9 -6
  86. package/dist/http/h1/encoder.d.ts.map +1 -1
  87. package/dist/http/h1/encoder.js +14 -13
  88. package/dist/http/h1/encoder.js.map +1 -1
  89. package/dist/http/h1/parser.d.ts +68 -14
  90. package/dist/http/h1/parser.d.ts.map +1 -1
  91. package/dist/http/h1/parser.js +92 -37
  92. package/dist/http/h1/parser.js.map +1 -1
  93. package/dist/http/h2/client.d.ts +87 -14
  94. package/dist/http/h2/client.d.ts.map +1 -1
  95. package/dist/http/h2/client.js +496 -74
  96. package/dist/http/h2/client.js.map +1 -1
  97. package/dist/http/h2/frames.d.ts +103 -6
  98. package/dist/http/h2/frames.d.ts.map +1 -1
  99. package/dist/http/h2/frames.js +96 -17
  100. package/dist/http/h2/frames.js.map +1 -1
  101. package/dist/http/h2/hpack.d.ts +40 -5
  102. package/dist/http/h2/hpack.d.ts.map +1 -1
  103. package/dist/http/h2/hpack.js +50 -36
  104. package/dist/http/h2/hpack.js.map +1 -1
  105. package/dist/http/negotiator.d.ts +36 -12
  106. package/dist/http/negotiator.d.ts.map +1 -1
  107. package/dist/http/negotiator.js +96 -24
  108. package/dist/http/negotiator.js.map +1 -1
  109. package/dist/http/pool.d.ts +66 -17
  110. package/dist/http/pool.d.ts.map +1 -1
  111. package/dist/http/pool.js +47 -20
  112. package/dist/http/pool.js.map +1 -1
  113. package/dist/index.d.ts +2 -3
  114. package/dist/index.d.ts.map +1 -1
  115. package/dist/index.js +0 -13
  116. package/dist/index.js.map +1 -1
  117. package/dist/middleware/interceptor.d.ts +40 -8
  118. package/dist/middleware/interceptor.d.ts.map +1 -1
  119. package/dist/middleware/interceptor.js +28 -6
  120. package/dist/middleware/interceptor.js.map +1 -1
  121. package/dist/middleware/rate-limiter.d.ts +18 -5
  122. package/dist/middleware/rate-limiter.d.ts.map +1 -1
  123. package/dist/middleware/rate-limiter.js +12 -7
  124. package/dist/middleware/rate-limiter.js.map +1 -1
  125. package/dist/middleware/retry.d.ts +17 -5
  126. package/dist/middleware/retry.d.ts.map +1 -1
  127. package/dist/middleware/retry.js +13 -11
  128. package/dist/middleware/retry.js.map +1 -1
  129. package/dist/proxy/http-proxy.d.ts +17 -9
  130. package/dist/proxy/http-proxy.d.ts.map +1 -1
  131. package/dist/proxy/http-proxy.js +9 -13
  132. package/dist/proxy/http-proxy.js.map +1 -1
  133. package/dist/proxy/socks.d.ts +20 -9
  134. package/dist/proxy/socks.d.ts.map +1 -1
  135. package/dist/proxy/socks.js +20 -31
  136. package/dist/proxy/socks.js.map +1 -1
  137. package/dist/tls/constants.d.ts +74 -4
  138. package/dist/tls/constants.d.ts.map +1 -1
  139. package/dist/tls/constants.js +75 -21
  140. package/dist/tls/constants.js.map +1 -1
  141. package/dist/tls/node-engine.d.ts +17 -16
  142. package/dist/tls/node-engine.d.ts.map +1 -1
  143. package/dist/tls/node-engine.js +20 -27
  144. package/dist/tls/node-engine.js.map +1 -1
  145. package/dist/tls/stealth/client-hello.d.ts +32 -16
  146. package/dist/tls/stealth/client-hello.d.ts.map +1 -1
  147. package/dist/tls/stealth/client-hello.js +13 -37
  148. package/dist/tls/stealth/client-hello.js.map +1 -1
  149. package/dist/tls/stealth/engine.d.ts +18 -10
  150. package/dist/tls/stealth/engine.d.ts.map +1 -1
  151. package/dist/tls/stealth/engine.js +55 -40
  152. package/dist/tls/stealth/engine.js.map +1 -1
  153. package/dist/tls/stealth/handshake.d.ts +31 -17
  154. package/dist/tls/stealth/handshake.d.ts.map +1 -1
  155. package/dist/tls/stealth/handshake.js +173 -74
  156. package/dist/tls/stealth/handshake.js.map +1 -1
  157. package/dist/tls/stealth/key-schedule.d.ts +90 -32
  158. package/dist/tls/stealth/key-schedule.d.ts.map +1 -1
  159. package/dist/tls/stealth/key-schedule.js +80 -46
  160. package/dist/tls/stealth/key-schedule.js.map +1 -1
  161. package/dist/tls/stealth/record-layer.d.ts +76 -25
  162. package/dist/tls/stealth/record-layer.d.ts.map +1 -1
  163. package/dist/tls/stealth/record-layer.js +66 -36
  164. package/dist/tls/stealth/record-layer.js.map +1 -1
  165. package/dist/tls/types.d.ts +33 -25
  166. package/dist/tls/types.d.ts.map +1 -1
  167. package/dist/tls/types.js +0 -4
  168. package/dist/tls/types.js.map +1 -1
  169. package/dist/utils/buffer-reader.d.ts +99 -7
  170. package/dist/utils/buffer-reader.d.ts.map +1 -1
  171. package/dist/utils/buffer-reader.js +99 -7
  172. package/dist/utils/buffer-reader.js.map +1 -1
  173. package/dist/utils/buffer-writer.d.ts +99 -10
  174. package/dist/utils/buffer-writer.d.ts.map +1 -1
  175. package/dist/utils/buffer-writer.js +101 -12
  176. package/dist/utils/buffer-writer.js.map +1 -1
  177. package/dist/utils/encoding.d.ts +33 -8
  178. package/dist/utils/encoding.d.ts.map +1 -1
  179. package/dist/utils/encoding.js +58 -13
  180. package/dist/utils/encoding.js.map +1 -1
  181. package/dist/utils/logger.d.ts +61 -2
  182. package/dist/utils/logger.d.ts.map +1 -1
  183. package/dist/utils/logger.js +52 -4
  184. package/dist/utils/logger.js.map +1 -1
  185. package/dist/utils/url.d.ts +47 -7
  186. package/dist/utils/url.d.ts.map +1 -1
  187. package/dist/utils/url.js +47 -7
  188. package/dist/utils/url.js.map +1 -1
  189. package/dist/ws/client.d.ts +60 -15
  190. package/dist/ws/client.d.ts.map +1 -1
  191. package/dist/ws/client.js +38 -27
  192. package/dist/ws/client.js.map +1 -1
  193. package/dist/ws/frame.d.ts +43 -9
  194. package/dist/ws/frame.d.ts.map +1 -1
  195. package/dist/ws/frame.js +35 -19
  196. package/dist/ws/frame.js.map +1 -1
  197. package/package.json +4 -4
@@ -1,16 +1,20 @@
1
- /**
2
- * HTTP/2 client.
3
- *
4
- * Multiplexed HTTP/2 client that runs over a TLS Duplex stream.
5
- * Sends the connection preface with browser-profile-matched SETTINGS
6
- * and WINDOW_UPDATE for Akamai HTTP/2 fingerprint control.
7
- */
1
+ import { PassThrough } from 'node:stream';
8
2
  import { NLcURLResponse } from '../../core/response.js';
9
3
  import { HTTPError, TimeoutError, ProtocolError } from '../../core/errors.js';
10
- import { decompressBody } from '../../utils/encoding.js';
4
+ import { decompressBody, createDecompressStream } from '../../utils/encoding.js';
11
5
  import { HPACKEncoder, HPACKDecoder } from './hpack.js';
12
- import { readFrame, writeFrame, buildSettingsFrame, buildWindowUpdateFrame, buildHeadersFrame, buildDataFrame, buildPingFrame, buildGoawayFrame, H2_PREFACE, FrameType, Flags, } from './frames.js';
13
- // ---- Client ----
6
+ import { readFrame, writeFrame, buildSettingsFrame, buildWindowUpdateFrame, buildHeadersFrame, buildDataFrame, buildPingFrame, buildGoawayFrame, buildRstStreamFrame, H2_PREFACE, FrameType, Flags, } from './frames.js';
7
+ const DEFAULT_INITIAL_WINDOW_SIZE = 65535;
8
+ const WINDOW_UPDATE_THRESHOLD_RATIO = 0.5;
9
+ /**
10
+ * Multiplexed HTTP/2 client that operates over an existing duplex stream.
11
+ * Handles all aspects of the HTTP/2 connection lifecycle: connection preface,
12
+ * settings negotiation, flow control (both stream-level and connection-level),
13
+ * HPACK header compression, and stream multiplexing.
14
+ *
15
+ * Create one `H2Client` per underlying TCP/TLS connection; each call to
16
+ * {@link H2Client.request} or {@link H2Client.streamRequest} opens a new HTTP/2 stream.
17
+ */
14
18
  export class H2Client {
15
19
  stream;
16
20
  encoder;
@@ -21,7 +25,35 @@ export class H2Client {
21
25
  streams = new Map();
22
26
  readBuffer = Buffer.alloc(0);
23
27
  prefaceSent = false;
24
- closed = false;
28
+ _closed = false;
29
+ _goawayReceived = false;
30
+ connectionRecvWindow = DEFAULT_INITIAL_WINDOW_SIZE;
31
+ initialStreamRecvWindow = DEFAULT_INITIAL_WINDOW_SIZE;
32
+ streamRecvWindows = new Map();
33
+ serverMaxConcurrentStreams = Infinity;
34
+ serverMaxFrameSize = 16384;
35
+ connectionSendWindow = DEFAULT_INITIAL_WINDOW_SIZE;
36
+ initialStreamSendWindow = DEFAULT_INITIAL_WINDOW_SIZE;
37
+ streamSendWindows = new Map();
38
+ pendingSendData = new Map();
39
+ pendingHeaderStreamId = null;
40
+ pendingHeaderBlock = null;
41
+ pendingHeaderFlags = 0;
42
+ /**
43
+ * Optional callback invoked when the underlying connection closes, regardless
44
+ * of the reason (graceful GOAWAY, remote reset, or network error).
45
+ *
46
+ * @type {(() => void) | undefined}
47
+ */
48
+ onClose;
49
+ /**
50
+ * Creates a new H2Client and begins listening for frames on `stream`.
51
+ *
52
+ * @param {Duplex} stream - Connected transport stream (TLS or plain TCP).
53
+ * @param {H2Profile} [h2Profile] - Browser HTTP/2 fingerprint settings (SETTINGS frame values,
54
+ * window update sizes, and priority frames). Defaults to Chrome-like settings when omitted.
55
+ * @param {Array<[string, string]>} [defaultHeaders=[]] - Profile-level request headers prepended to every request.
56
+ */
25
57
  constructor(stream, h2Profile, defaultHeaders = []) {
26
58
  this.stream = stream;
27
59
  this.encoder = new HPACKEncoder();
@@ -29,28 +61,38 @@ export class H2Client {
29
61
  this.h2Profile = h2Profile;
30
62
  this.defaultHeaders = defaultHeaders;
31
63
  stream.on('data', (chunk) => this.onData(chunk));
32
- stream.once('error', (err) => this.onError(err));
33
- stream.once('close', () => this.onClose());
64
+ stream.once('error', (err) => this.handleError(err));
65
+ stream.once('close', () => this.handleClose());
34
66
  }
35
67
  /**
36
- * Send the HTTP/2 connection preface.
68
+ * Returns `true` if the connection has been closed or destroyed and is no
69
+ * longer usable.
37
70
  *
38
- * Must be called before sending any requests.
71
+ * @returns {boolean} Whether the connection is closed.
72
+ */
73
+ get isClosed() {
74
+ return this._closed;
75
+ }
76
+ /**
77
+ * Sends the HTTP/2 client connection preface (magic PRI bytes) and the
78
+ * initial SETTINGS frame derived from the browser profile. Idempotent —
79
+ * calling more than once has no effect.
39
80
  */
40
81
  sendPreface() {
41
82
  if (this.prefaceSent)
42
83
  return;
43
84
  this.prefaceSent = true;
44
- // 1. Magic octets
45
85
  this.write(H2_PREFACE);
46
- // 2. SETTINGS frame (ordered per profile)
47
86
  if (this.h2Profile) {
48
87
  this.write(buildSettingsFrame(this.h2Profile.settings));
49
- // 3. WINDOW_UPDATE on stream 0
88
+ for (const s of this.h2Profile.settings) {
89
+ if (s.id === 4)
90
+ this.initialStreamRecvWindow = s.value;
91
+ }
50
92
  if (this.h2Profile.windowUpdate > 0) {
93
+ this.connectionRecvWindow = DEFAULT_INITIAL_WINDOW_SIZE + this.h2Profile.windowUpdate;
51
94
  this.write(buildWindowUpdateFrame(0, this.h2Profile.windowUpdate));
52
95
  }
53
- // 4. PRIORITY frames
54
96
  if (this.h2Profile.priorityFrames) {
55
97
  for (const pf of this.h2Profile.priorityFrames) {
56
98
  const payload = Buffer.allocUnsafe(5);
@@ -69,21 +111,29 @@ export class H2Client {
69
111
  }
70
112
  }
71
113
  else {
72
- // Default settings
114
+ this.initialStreamRecvWindow = 6291456;
73
115
  this.write(buildSettingsFrame([
74
116
  { id: 1, value: 65536 },
75
117
  { id: 2, value: 0 },
76
118
  { id: 4, value: 6291456 },
77
119
  { id: 6, value: 262144 },
78
120
  ]));
121
+ this.connectionRecvWindow = DEFAULT_INITIAL_WINDOW_SIZE + 15663105;
79
122
  this.write(buildWindowUpdateFrame(0, 15663105));
80
123
  }
81
124
  }
82
125
  /**
83
- * Send an HTTP/2 request and return the response.
126
+ * Sends an HTTP/2 request and buffers the entire response body before
127
+ * resolving. This is the standard (non-streaming) request path.
128
+ *
129
+ * @param {NLcURLRequest} req - Request descriptor.
130
+ * @param {Partial<RequestTimings>} [timings={}] - Partial timings object populated with `firstByte`.
131
+ * @returns {Promise<NLcURLResponse>} Resolves with the fully received, decompressed response.
132
+ * @throws {ProtocolError} If the connection is already closed or stream IDs are exhausted.
133
+ * @throws {TimeoutError} If the per-request timeout elapses before a response is received.
84
134
  */
85
135
  async request(req, timings = {}) {
86
- if (this.closed) {
136
+ if (this._closed) {
87
137
  throw new ProtocolError('HTTP/2 connection is closed');
88
138
  }
89
139
  if (!this.prefaceSent)
@@ -92,7 +142,7 @@ export class H2Client {
92
142
  if (streamId > 0x7fffffff) {
93
143
  throw new ProtocolError('HTTP/2 stream ID exhausted; open a new connection');
94
144
  }
95
- this.nextStreamId += 2; // Client streams are odd
145
+ this.nextStreamId += 2;
96
146
  return new Promise((resolve, reject) => {
97
147
  const h2stream = {
98
148
  id: streamId,
@@ -107,27 +157,93 @@ export class H2Client {
107
157
  timings,
108
158
  };
109
159
  this.streams.set(streamId, h2stream);
110
- // Build headers
160
+ this.streamRecvWindows.set(streamId, this.initialStreamRecvWindow);
161
+ this.streamSendWindows.set(streamId, this.initialStreamSendWindow);
162
+ const headers = this.buildRequestHeaders(req);
163
+ const headerBlock = this.encoder.encode(headers);
164
+ const hasBody = req.body !== undefined &&
165
+ req.body !== null &&
166
+ req.method !== 'GET' &&
167
+ req.method !== 'HEAD';
168
+ this.write(buildHeadersFrame(streamId, headerBlock, !hasBody, true));
169
+ if (hasBody) {
170
+ const bodyBuf = serializeBody(req.body);
171
+ this.sendDataWithFlowControl(streamId, bodyBuf, true);
172
+ }
173
+ const timeout = req.timeout;
174
+ const timeoutMs = typeof timeout === 'number' ? timeout : (timeout?.total ?? timeout?.response ?? 0);
175
+ if (timeoutMs > 0) {
176
+ h2stream.timer = setTimeout(() => {
177
+ if (this.streams.has(streamId)) {
178
+ this.streams.delete(streamId);
179
+ reject(new TimeoutError('HTTP/2 request timed out', 'response'));
180
+ }
181
+ }, timeoutMs);
182
+ }
183
+ });
184
+ }
185
+ /**
186
+ * Sends an HTTP/2 request and returns a streaming response. The returned
187
+ * `NLcURLResponse` resolves once the status and headers are received;
188
+ * the response body is exposed as a `PassThrough` readable stream via
189
+ * `response.body`.
190
+ *
191
+ * @param {NLcURLRequest} req - Request descriptor.
192
+ * @param {Partial<RequestTimings>} [timings={}] - Partial timings object populated with `firstByte`.
193
+ * @returns {Promise<NLcURLResponse>} Resolves once headers are received; body is streamed via `response.body`.
194
+ * @throws {ProtocolError} If the connection is already closed or stream IDs are exhausted.
195
+ * @throws {TimeoutError} If the per-request timeout elapses before the stream begins.
196
+ */
197
+ async streamRequest(req, timings = {}) {
198
+ if (this._closed) {
199
+ throw new ProtocolError('HTTP/2 connection is closed');
200
+ }
201
+ if (!this.prefaceSent)
202
+ this.sendPreface();
203
+ const streamId = this.nextStreamId;
204
+ if (streamId > 0x7fffffff) {
205
+ throw new ProtocolError('HTTP/2 stream ID exhausted; open a new connection');
206
+ }
207
+ this.nextStreamId += 2;
208
+ return new Promise((resolve, reject) => {
209
+ const bodyStream = new PassThrough();
210
+ const h2stream = {
211
+ id: streamId,
212
+ request: req,
213
+ responseHeaders: new Map(),
214
+ responseRawHeaders: [],
215
+ status: 0,
216
+ dataChunks: [],
217
+ endStream: false,
218
+ resolve: (_resp) => { },
219
+ reject,
220
+ timings,
221
+ bodyStream,
222
+ };
223
+ this.streams.set(streamId, h2stream);
224
+ this.streamRecvWindows.set(streamId, this.initialStreamRecvWindow);
225
+ this.streamSendWindows.set(streamId, this.initialStreamSendWindow);
111
226
  const headers = this.buildRequestHeaders(req);
112
227
  const headerBlock = this.encoder.encode(headers);
113
228
  const hasBody = req.body !== undefined &&
114
229
  req.body !== null &&
115
230
  req.method !== 'GET' &&
116
231
  req.method !== 'HEAD';
117
- // Send HEADERS frame
118
232
  this.write(buildHeadersFrame(streamId, headerBlock, !hasBody, true));
119
- // Send body if present
120
233
  if (hasBody) {
121
234
  const bodyBuf = serializeBody(req.body);
122
- this.write(buildDataFrame(streamId, bodyBuf, true));
235
+ this.sendDataWithFlowControl(streamId, bodyBuf, true);
123
236
  }
124
- // Timeout
237
+ h2stream.resolve = (resp) => {
238
+ resolve(resp);
239
+ };
125
240
  const timeout = req.timeout;
126
241
  const timeoutMs = typeof timeout === 'number' ? timeout : (timeout?.total ?? timeout?.response ?? 0);
127
242
  if (timeoutMs > 0) {
128
243
  h2stream.timer = setTimeout(() => {
129
244
  if (this.streams.has(streamId)) {
130
245
  this.streams.delete(streamId);
246
+ bodyStream.destroy(new TimeoutError('HTTP/2 request timed out', 'response'));
131
247
  reject(new TimeoutError('HTTP/2 request timed out', 'response'));
132
248
  }
133
249
  }, timeoutMs);
@@ -135,17 +251,23 @@ export class H2Client {
135
251
  });
136
252
  }
137
253
  /**
138
- * Gracefully close the HTTP/2 connection.
254
+ * Initiates a graceful shutdown by sending a GOAWAY frame and ending the
255
+ * underlying stream. In-flight requests will fail with `ProtocolError`.
139
256
  */
140
257
  close() {
141
- if (this.closed)
258
+ if (this._closed)
142
259
  return;
143
- this.closed = true;
260
+ this._closed = true;
144
261
  this.write(buildGoawayFrame(0, 0));
145
262
  this.stream.end();
263
+ this.onClose?.();
146
264
  }
265
+ /**
266
+ * Immediately destroys the underlying transport stream, aborting all
267
+ * pending requests with a `ProtocolError`.
268
+ */
147
269
  destroy() {
148
- this.closed = true;
270
+ this._closed = true;
149
271
  this.stream.destroy();
150
272
  for (const [, s] of this.streams) {
151
273
  if (s.timer)
@@ -153,13 +275,15 @@ export class H2Client {
153
275
  s.reject(new ProtocolError('HTTP/2 connection destroyed'));
154
276
  }
155
277
  this.streams.clear();
278
+ this.streamRecvWindows.clear();
279
+ this.streamSendWindows.clear();
280
+ this.pendingSendData.clear();
281
+ this.onClose?.();
156
282
  }
157
- // ---- Internal ----
158
283
  buildRequestHeaders(req) {
159
284
  const url = new URL(req.url);
160
285
  const authority = url.port ? `${url.hostname}:${url.port}` : url.hostname;
161
286
  const path = url.pathname + url.search;
162
- // Pseudo headers in profile-defined order
163
287
  const order = this.h2Profile?.pseudoHeaderOrder ?? [
164
288
  ':method',
165
289
  ':authority',
@@ -173,36 +297,37 @@ export class H2Client {
173
297
  ':path': path || '/',
174
298
  };
175
299
  const headers = [];
176
- // Emit pseudo headers in profile order
177
300
  for (const ph of order) {
178
301
  const v = pseudoMap[ph];
179
302
  if (v !== undefined) {
180
303
  headers.push([ph, v]);
181
304
  }
182
305
  }
183
- // Default headers
184
306
  const seen = new Set(order);
307
+ const reqLower = new Map();
308
+ if (req.headers) {
309
+ for (const [k, v] of Object.entries(req.headers)) {
310
+ reqLower.set(k.toLowerCase(), v);
311
+ }
312
+ }
185
313
  for (const [k, v] of this.defaultHeaders) {
186
314
  const lower = k.toLowerCase();
187
315
  if (!seen.has(lower)) {
188
316
  seen.add(lower);
189
- headers.push([lower, v]);
317
+ headers.push([lower, reqLower.get(lower) ?? v]);
318
+ reqLower.delete(lower);
190
319
  }
191
320
  }
192
- // Request headers
193
- if (req.headers) {
194
- for (const [k, v] of Object.entries(req.headers)) {
195
- const lower = k.toLowerCase();
196
- if (!seen.has(lower)) {
197
- seen.add(lower);
198
- headers.push([lower, v]);
199
- }
321
+ for (const [lower, v] of reqLower) {
322
+ if (!seen.has(lower)) {
323
+ seen.add(lower);
324
+ headers.push([lower, v]);
200
325
  }
201
326
  }
202
327
  return headers;
203
328
  }
204
329
  write(data) {
205
- if (!this.closed) {
330
+ if (!this._closed) {
206
331
  this.stream.write(data);
207
332
  }
208
333
  }
@@ -220,10 +345,22 @@ export class H2Client {
220
345
  }
221
346
  }
222
347
  handleFrame(frame) {
348
+ if (this.pendingHeaderStreamId !== null && frame.type !== FrameType.CONTINUATION) {
349
+ this._closed = true;
350
+ this.write(buildGoawayFrame(0, 1));
351
+ for (const [, s] of this.streams) {
352
+ if (s.timer)
353
+ clearTimeout(s.timer);
354
+ s.reject(new ProtocolError('Protocol error: expected CONTINUATION frame', 1));
355
+ }
356
+ this.streams.clear();
357
+ this.onClose?.();
358
+ return;
359
+ }
223
360
  switch (frame.type) {
224
361
  case FrameType.SETTINGS:
225
362
  if (!(frame.flags & Flags.ACK)) {
226
- // Acknowledge server settings
363
+ this.applyServerSettings(frame.payload);
227
364
  this.write(buildSettingsFrame([], true));
228
365
  }
229
366
  break;
@@ -231,22 +368,56 @@ export class H2Client {
231
368
  const s = this.streams.get(frame.streamId);
232
369
  if (!s)
233
370
  break;
234
- const headers = this.decoder.decode(frame.payload);
235
- for (const [name, value] of headers) {
236
- if (name === ':status') {
237
- s.status = parseInt(value, 10);
371
+ let headerPayload = frame.payload;
372
+ if (frame.flags & Flags.PADDED) {
373
+ if (headerPayload.length < 1) {
374
+ s.reject(new ProtocolError('HEADERS frame with PADDED flag but no pad length'));
375
+ this.streams.delete(frame.streamId);
376
+ break;
238
377
  }
239
- s.responseRawHeaders.push([name, value]);
240
- const existing = s.responseHeaders.get(name);
241
- if (existing !== undefined) {
242
- s.responseHeaders.set(name, existing + ', ' + value);
378
+ const padLength = headerPayload[0];
379
+ if (padLength >= headerPayload.length) {
380
+ s.reject(new ProtocolError('HEADERS pad length exceeds payload'));
381
+ this.streams.delete(frame.streamId);
382
+ break;
243
383
  }
244
- else {
245
- s.responseHeaders.set(name, value);
384
+ headerPayload = headerPayload.subarray(1, headerPayload.length - padLength);
385
+ }
386
+ if (frame.flags & Flags.PRIORITY) {
387
+ if (headerPayload.length < 5) {
388
+ s.reject(new ProtocolError('HEADERS frame with PRIORITY but insufficient data'));
389
+ this.streams.delete(frame.streamId);
390
+ break;
246
391
  }
392
+ headerPayload = headerPayload.subarray(5);
247
393
  }
248
- if (frame.flags & Flags.END_STREAM) {
249
- this.finalizeStream(s);
394
+ if (!(frame.flags & Flags.END_HEADERS)) {
395
+ this.pendingHeaderStreamId = frame.streamId;
396
+ this.pendingHeaderBlock = Buffer.from(headerPayload);
397
+ this.pendingHeaderFlags = frame.flags;
398
+ break;
399
+ }
400
+ this.processDecodedHeaders(s, headerPayload, frame.flags);
401
+ break;
402
+ }
403
+ case FrameType.CONTINUATION: {
404
+ if (this.pendingHeaderStreamId === null || frame.streamId !== this.pendingHeaderStreamId) {
405
+ this._closed = true;
406
+ this.write(buildGoawayFrame(0, 1));
407
+ this.onClose?.();
408
+ return;
409
+ }
410
+ this.pendingHeaderBlock = Buffer.concat([this.pendingHeaderBlock, frame.payload]);
411
+ if (frame.flags & Flags.END_HEADERS) {
412
+ const s = this.streams.get(this.pendingHeaderStreamId);
413
+ const headerBlock = this.pendingHeaderBlock;
414
+ const flags = this.pendingHeaderFlags;
415
+ this.pendingHeaderStreamId = null;
416
+ this.pendingHeaderBlock = null;
417
+ this.pendingHeaderFlags = 0;
418
+ if (s) {
419
+ this.processDecodedHeaders(s, headerBlock, flags);
420
+ }
250
421
  }
251
422
  break;
252
423
  }
@@ -254,7 +425,29 @@ export class H2Client {
254
425
  const s = this.streams.get(frame.streamId);
255
426
  if (!s)
256
427
  break;
257
- s.dataChunks.push(Buffer.from(frame.payload));
428
+ let dataPayload = frame.payload;
429
+ if (frame.flags & Flags.PADDED) {
430
+ if (dataPayload.length < 1) {
431
+ s.reject(new ProtocolError('DATA frame with PADDED flag but no pad length'));
432
+ this.streams.delete(frame.streamId);
433
+ break;
434
+ }
435
+ const padLength = dataPayload[0];
436
+ if (padLength >= dataPayload.length) {
437
+ s.reject(new ProtocolError('DATA pad length exceeds payload'));
438
+ this.streams.delete(frame.streamId);
439
+ break;
440
+ }
441
+ dataPayload = dataPayload.subarray(1, dataPayload.length - padLength);
442
+ }
443
+ const data = Buffer.from(dataPayload);
444
+ if (s.bodyStream) {
445
+ s.bodyStream.write(data);
446
+ }
447
+ else {
448
+ s.dataChunks.push(data);
449
+ }
450
+ this.consumeRecvWindow(frame.streamId, frame.payload.length);
258
451
  if (frame.flags & Flags.END_STREAM) {
259
452
  this.finalizeStream(s);
260
453
  }
@@ -266,8 +459,14 @@ export class H2Client {
266
459
  if (s.timer)
267
460
  clearTimeout(s.timer);
268
461
  this.streams.delete(frame.streamId);
462
+ this.streamRecvWindows.delete(frame.streamId);
463
+ this.streamSendWindows.delete(frame.streamId);
464
+ this.pendingSendData.delete(frame.streamId);
269
465
  const errorCode = frame.payload.readUInt32BE(0);
270
- s.reject(new ProtocolError(`HTTP/2 stream reset: error code ${errorCode}`));
466
+ const err = new ProtocolError(`HTTP/2 stream reset: error code ${errorCode}`, errorCode);
467
+ if (s.bodyStream)
468
+ s.bodyStream.destroy(err);
469
+ s.reject(err);
271
470
  }
272
471
  break;
273
472
  }
@@ -277,28 +476,124 @@ export class H2Client {
277
476
  }
278
477
  break;
279
478
  case FrameType.GOAWAY: {
280
- this.closed = true;
479
+ this._closed = true;
480
+ this._goawayReceived = true;
481
+ const lastStreamId = frame.payload.readUInt32BE(0) & 0x7fffffff;
281
482
  const errorCode = frame.payload.readUInt32BE(4);
282
- if (errorCode !== 0) {
283
- for (const [, s] of this.streams) {
483
+ for (const [id, s] of this.streams) {
484
+ if (id > lastStreamId) {
284
485
  if (s.timer)
285
486
  clearTimeout(s.timer);
286
- s.reject(new ProtocolError(`HTTP/2 GOAWAY: error code ${errorCode}`));
487
+ if (errorCode !== 0) {
488
+ s.reject(new ProtocolError(`HTTP/2 GOAWAY: error code ${errorCode}`, errorCode));
489
+ }
490
+ else {
491
+ s.reject(new ProtocolError('HTTP/2 GOAWAY: graceful shutdown', 0));
492
+ }
493
+ this.streams.delete(id);
287
494
  }
288
- this.streams.clear();
495
+ }
496
+ if (this.streams.size === 0) {
497
+ this.onClose?.();
289
498
  }
290
499
  break;
291
500
  }
292
- case FrameType.WINDOW_UPDATE:
293
- // Flow control -- in a full implementation we would track
294
- // per-stream and connection-level windows. For now, accepted.
501
+ case FrameType.WINDOW_UPDATE: {
502
+ const increment = frame.payload.readUInt32BE(0) & 0x7fffffff;
503
+ if (increment === 0) {
504
+ if (frame.streamId === 0) {
505
+ this._closed = true;
506
+ this.write(buildGoawayFrame(0, 1));
507
+ this.onClose?.();
508
+ }
509
+ else {
510
+ this.write(buildRstStreamFrame(frame.streamId, 1));
511
+ }
512
+ break;
513
+ }
514
+ if (frame.streamId === 0) {
515
+ this.connectionSendWindow += increment;
516
+ }
517
+ else {
518
+ const current = this.streamSendWindows.get(frame.streamId) ?? this.initialStreamSendWindow;
519
+ this.streamSendWindows.set(frame.streamId, current + increment);
520
+ }
521
+ this.flushPendingSendData(frame.streamId);
295
522
  break;
523
+ }
296
524
  }
297
525
  }
526
+ processDecodedHeaders(s, headerBlock, flags) {
527
+ const headers = this.decoder.decode(headerBlock);
528
+ for (const [name, value] of headers) {
529
+ if (name === ':status') {
530
+ s.status = parseInt(value, 10);
531
+ }
532
+ s.responseRawHeaders.push([name, value]);
533
+ const existing = s.responseHeaders.get(name);
534
+ if (existing !== undefined) {
535
+ const sep = name === 'set-cookie' ? '; ' : ', ';
536
+ s.responseHeaders.set(name, existing + sep + value);
537
+ }
538
+ else {
539
+ s.responseHeaders.set(name, value);
540
+ }
541
+ }
542
+ if (flags & Flags.END_STREAM) {
543
+ this.finalizeStream(s);
544
+ }
545
+ else if (s.bodyStream && s.status > 0) {
546
+ this.resolveStreamingResponse(s);
547
+ }
548
+ }
549
+ resolveStreamingResponse(s) {
550
+ const responseHeaders = {};
551
+ for (const [k, v] of s.responseHeaders) {
552
+ if (!k.startsWith(':')) {
553
+ responseHeaders[k] = v;
554
+ }
555
+ }
556
+ const encoding = s.responseHeaders.get('content-encoding');
557
+ const decompressor = createDecompressStream(encoding);
558
+ const outputStream = decompressor ? s.bodyStream.pipe(decompressor) : s.bodyStream;
559
+ const response = new NLcURLResponse({
560
+ status: s.status,
561
+ statusText: '',
562
+ headers: responseHeaders,
563
+ rawHeaders: s.responseRawHeaders.filter(([k]) => !k.startsWith(':')),
564
+ rawBody: Buffer.alloc(0),
565
+ body: outputStream,
566
+ httpVersion: 'h2',
567
+ url: s.request.url,
568
+ redirectCount: 0,
569
+ timings: {
570
+ dns: s.timings.dns ?? 0,
571
+ connect: s.timings.connect ?? 0,
572
+ tls: s.timings.tls ?? 0,
573
+ firstByte: s.timings.firstByte ?? 0,
574
+ total: 0,
575
+ },
576
+ request: {
577
+ url: s.request.url,
578
+ method: s.request.method ?? 'GET',
579
+ headers: s.request.headers ?? {},
580
+ },
581
+ });
582
+ s.resolve(response);
583
+ }
298
584
  async finalizeStream(s) {
299
585
  if (s.timer)
300
586
  clearTimeout(s.timer);
301
587
  this.streams.delete(s.id);
588
+ this.streamRecvWindows.delete(s.id);
589
+ if (s.bodyStream) {
590
+ if (s.status > 0) {
591
+ this.resolveStreamingResponse(s);
592
+ }
593
+ s.bodyStream.end();
594
+ this.checkGoawayDrain();
595
+ return;
596
+ }
302
597
  const rawBody = Buffer.concat(s.dataChunks);
303
598
  const encoding = s.responseHeaders.get('content-encoding');
304
599
  let body;
@@ -337,27 +632,154 @@ export class H2Client {
337
632
  },
338
633
  });
339
634
  s.resolve(response);
635
+ this.checkGoawayDrain();
636
+ }
637
+ /**
638
+ * After a GOAWAY has been received, fires `onClose` once the last remaining
639
+ * stream (with ID ≤ lastStreamId) has been finalized.
640
+ */
641
+ checkGoawayDrain() {
642
+ if (this._goawayReceived && this.streams.size === 0) {
643
+ this.onClose?.();
644
+ }
645
+ }
646
+ applyServerSettings(payload) {
647
+ for (let i = 0; i + 5 < payload.length; i += 6) {
648
+ const id = payload.readUInt16BE(i);
649
+ const value = payload.readUInt32BE(i + 2);
650
+ switch (id) {
651
+ case 3:
652
+ this.serverMaxConcurrentStreams = value;
653
+ break;
654
+ case 4:
655
+ {
656
+ const recvDelta = value - this.initialStreamRecvWindow;
657
+ this.initialStreamRecvWindow = value;
658
+ for (const [streamId, win] of this.streamRecvWindows) {
659
+ this.streamRecvWindows.set(streamId, win + recvDelta);
660
+ }
661
+ const sendDelta = value - this.initialStreamSendWindow;
662
+ this.initialStreamSendWindow = value;
663
+ for (const [streamId, win] of this.streamSendWindows) {
664
+ this.streamSendWindows.set(streamId, win + sendDelta);
665
+ }
666
+ }
667
+ break;
668
+ case 5:
669
+ this.serverMaxFrameSize = value;
670
+ break;
671
+ }
672
+ }
340
673
  }
341
- onError(err) {
674
+ consumeRecvWindow(streamId, length) {
675
+ this.connectionRecvWindow -= length;
676
+ const connMax = this.h2Profile
677
+ ? DEFAULT_INITIAL_WINDOW_SIZE + (this.h2Profile.windowUpdate || 0)
678
+ : DEFAULT_INITIAL_WINDOW_SIZE + 15663105;
679
+ const connThreshold = Math.floor(connMax * WINDOW_UPDATE_THRESHOLD_RATIO);
680
+ if (this.connectionRecvWindow < connThreshold) {
681
+ const increment = connMax - this.connectionRecvWindow;
682
+ this.connectionRecvWindow += increment;
683
+ this.write(buildWindowUpdateFrame(0, increment));
684
+ }
685
+ const streamWin = this.streamRecvWindows.get(streamId);
686
+ if (streamWin !== undefined) {
687
+ const newWin = streamWin - length;
688
+ const streamThreshold = Math.floor(this.initialStreamRecvWindow * WINDOW_UPDATE_THRESHOLD_RATIO);
689
+ if (newWin < streamThreshold) {
690
+ const increment = this.initialStreamRecvWindow - newWin;
691
+ this.streamRecvWindows.set(streamId, newWin + increment);
692
+ this.write(buildWindowUpdateFrame(streamId, increment));
693
+ }
694
+ else {
695
+ this.streamRecvWindows.set(streamId, newWin);
696
+ }
697
+ }
698
+ }
699
+ handleError(err) {
342
700
  for (const [, s] of this.streams) {
343
701
  if (s.timer)
344
702
  clearTimeout(s.timer);
345
703
  s.reject(new HTTPError(err.message, 0));
346
704
  }
347
705
  this.streams.clear();
348
- this.closed = true;
706
+ this.streamRecvWindows.clear();
707
+ this.streamSendWindows.clear();
708
+ this.pendingSendData.clear();
709
+ this._closed = true;
710
+ this.onClose?.();
349
711
  }
350
- onClose() {
712
+ handleClose() {
351
713
  for (const [, s] of this.streams) {
352
714
  if (s.timer)
353
715
  clearTimeout(s.timer);
354
716
  s.reject(new HTTPError('HTTP/2 connection closed', 0));
355
717
  }
356
718
  this.streams.clear();
357
- this.closed = true;
719
+ this.streamRecvWindows.clear();
720
+ this.streamSendWindows.clear();
721
+ this.pendingSendData.clear();
722
+ this._closed = true;
723
+ this.onClose?.();
724
+ }
725
+ sendDataWithFlowControl(streamId, data, endStream) {
726
+ let offset = 0;
727
+ while (offset < data.length) {
728
+ const streamWin = this.streamSendWindows.get(streamId) ?? this.initialStreamSendWindow;
729
+ const maxSend = Math.min(this.connectionSendWindow, streamWin, this.serverMaxFrameSize);
730
+ if (maxSend <= 0) {
731
+ const remaining = data.subarray(offset);
732
+ const pending = this.pendingSendData.get(streamId) ?? [];
733
+ pending.push({ data: remaining, endStream, resolve: () => { } });
734
+ this.pendingSendData.set(streamId, pending);
735
+ return;
736
+ }
737
+ const chunkSize = Math.min(maxSend, data.length - offset);
738
+ const chunk = data.subarray(offset, offset + chunkSize);
739
+ offset += chunkSize;
740
+ const isLast = offset >= data.length && endStream;
741
+ this.write(buildDataFrame(streamId, chunk, isLast));
742
+ this.connectionSendWindow -= chunkSize;
743
+ const currentStreamWin = this.streamSendWindows.get(streamId) ?? this.initialStreamSendWindow;
744
+ this.streamSendWindows.set(streamId, currentStreamWin - chunkSize);
745
+ }
746
+ }
747
+ flushPendingSendData(streamId) {
748
+ const streamIds = streamId === 0
749
+ ? [...this.pendingSendData.keys()]
750
+ : [streamId];
751
+ for (const sid of streamIds) {
752
+ const pending = this.pendingSendData.get(sid);
753
+ if (!pending || pending.length === 0)
754
+ continue;
755
+ while (pending.length > 0) {
756
+ const item = pending[0];
757
+ const streamWin = this.streamSendWindows.get(sid) ?? this.initialStreamSendWindow;
758
+ const maxSend = Math.min(this.connectionSendWindow, streamWin, this.serverMaxFrameSize);
759
+ if (maxSend <= 0)
760
+ break;
761
+ const chunkSize = Math.min(maxSend, item.data.length);
762
+ const chunk = item.data.subarray(0, chunkSize);
763
+ const remaining = item.data.subarray(chunkSize);
764
+ const isLast = remaining.length === 0 && item.endStream;
765
+ this.write(buildDataFrame(sid, chunk, isLast));
766
+ this.connectionSendWindow -= chunkSize;
767
+ const currentStreamWin = this.streamSendWindows.get(sid) ?? this.initialStreamSendWindow;
768
+ this.streamSendWindows.set(sid, currentStreamWin - chunkSize);
769
+ if (remaining.length === 0) {
770
+ pending.shift();
771
+ }
772
+ else {
773
+ item.data = remaining;
774
+ break;
775
+ }
776
+ }
777
+ if (pending.length === 0) {
778
+ this.pendingSendData.delete(sid);
779
+ }
780
+ }
358
781
  }
359
782
  }
360
- // ---- Body serialization ----
361
783
  function serializeBody(body) {
362
784
  if (body === null || body === undefined)
363
785
  return Buffer.alloc(0);