nlcurl 0.1.0 → 0.2.0

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