undici 8.1.0 → 8.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 (61) hide show
  1. package/README.md +8 -5
  2. package/docs/docs/api/Dispatcher.md +2 -2
  3. package/docs/docs/api/GlobalInstallation.md +7 -5
  4. package/docs/docs/api/SnapshotAgent.md +23 -0
  5. package/lib/api/api-connect.js +1 -1
  6. package/lib/api/api-pipeline.js +6 -2
  7. package/lib/api/api-request.js +2 -2
  8. package/lib/api/api-stream.js +52 -6
  9. package/lib/api/api-upgrade.js +8 -2
  10. package/lib/api/readable.js +3 -2
  11. package/lib/cache/memory-cache-store.js +1 -1
  12. package/lib/cache/sqlite-cache-store.js +6 -4
  13. package/lib/core/connect.js +16 -0
  14. package/lib/core/constants.js +1 -24
  15. package/lib/core/errors.js +2 -2
  16. package/lib/core/request.js +17 -2
  17. package/lib/core/socks5-client.js +24 -9
  18. package/lib/core/socks5-utils.js +32 -23
  19. package/lib/core/symbols.js +1 -0
  20. package/lib/core/util.js +30 -5
  21. package/lib/dispatcher/agent.js +37 -39
  22. package/lib/dispatcher/balanced-pool.js +21 -23
  23. package/lib/dispatcher/client-h1.js +93 -34
  24. package/lib/dispatcher/client-h2.js +602 -270
  25. package/lib/dispatcher/client.js +3 -1
  26. package/lib/dispatcher/h2c-client.js +4 -4
  27. package/lib/dispatcher/pool-base.js +27 -9
  28. package/lib/dispatcher/pool.js +30 -2
  29. package/lib/dispatcher/proxy-agent.js +23 -4
  30. package/lib/dispatcher/round-robin-pool.js +31 -6
  31. package/lib/dispatcher/socks5-proxy-agent.js +42 -33
  32. package/lib/handler/cache-handler.js +1 -1
  33. package/lib/handler/redirect-handler.js +4 -0
  34. package/lib/handler/retry-handler.js +14 -0
  35. package/lib/interceptor/redirect.js +3 -3
  36. package/lib/llhttp/llhttp-wasm.js +1 -1
  37. package/lib/llhttp/llhttp_simd-wasm.js +1 -1
  38. package/lib/mock/mock-agent.js +8 -8
  39. package/lib/mock/mock-call-history.js +15 -15
  40. package/lib/mock/mock-utils.js +3 -1
  41. package/lib/mock/snapshot-agent.js +2 -0
  42. package/lib/mock/snapshot-recorder.js +38 -3
  43. package/lib/util/cache.js +1 -1
  44. package/lib/web/eventsource/eventsource-stream.js +245 -150
  45. package/lib/web/fetch/body.js +2 -7
  46. package/lib/web/fetch/formdata-parser.js +17 -6
  47. package/lib/web/fetch/formdata.js +21 -2
  48. package/lib/web/fetch/index.js +40 -28
  49. package/lib/web/webidl/index.js +5 -5
  50. package/lib/web/websocket/frame.js +1 -7
  51. package/lib/web/websocket/stream/websocketstream.js +6 -5
  52. package/package.json +4 -4
  53. package/types/client.d.ts +7 -7
  54. package/types/dispatcher.d.ts +4 -6
  55. package/types/formdata.d.ts +0 -6
  56. package/types/header.d.ts +5 -0
  57. package/types/interceptors.d.ts +1 -1
  58. package/types/proxy-agent.d.ts +2 -2
  59. package/types/snapshot-agent.d.ts +4 -0
  60. package/types/socks5-proxy-agent.d.ts +2 -2
  61. package/lib/llhttp/.gitkeep +0 -0
package/README.md CHANGED
@@ -200,7 +200,9 @@ await fetch('https://example.com', {
200
200
  ```
201
201
 
202
202
  `install()` replaces the global `fetch`, `Headers`, `Response`, `Request`, and
203
- `FormData` implementations with undici's versions, so they all match.
203
+ `FormData` implementations with undici's versions, so they all match. It also
204
+ installs undici's `WebSocket`, `CloseEvent`, `ErrorEvent`, `MessageEvent`, and
205
+ `EventSource` globals.
204
206
 
205
207
  Avoid mixing a global `FormData` with `undici.fetch()`, or `undici.FormData`
206
208
  with the built-in global `fetch()`.
@@ -283,12 +285,12 @@ const data2 = await getData();
283
285
 
284
286
  ## Global Installation
285
287
 
286
- Undici provides an `install()` function to add all WHATWG fetch classes to `globalThis`, making them available globally:
288
+ Undici provides an `install()` function to add fetch-related and other web API classes to `globalThis`, making them available globally:
287
289
 
288
290
  ```js
289
291
  import { install } from 'undici'
290
292
 
291
- // Install all WHATWG fetch classes globally
293
+ // Install undici's global web APIs
292
294
  install()
293
295
 
294
296
  // Now you can use fetch classes globally without importing
@@ -316,8 +318,9 @@ The `install()` function adds the following classes to `globalThis`:
316
318
 
317
319
  When you call `install()`, these globals come from the same undici
318
320
  implementation. For example, global `fetch` and global `FormData` will both be
319
- undici's versions, which is the recommended setup if you want to use undici
320
- through globals.
321
+ undici's versions, and `WebSocket` and `EventSource` will also come from
322
+ undici, which is the recommended setup if you want to use undici through
323
+ globals.
321
324
 
322
325
  This is useful for:
323
326
  - Polyfilling environments that don't have fetch
@@ -1354,10 +1354,10 @@ Emitted when dispatcher is no longer busy.
1354
1354
 
1355
1355
  ## Parameter: `UndiciHeaders`
1356
1356
 
1357
- * `Record<string, string | string[] | undefined> | string[] | Iterable<[string, string | string[] | undefined]> | null`
1357
+ * `Record<string, number | string | string[] | undefined> | string[] | Iterable<[string, string | string[] | undefined]> | null`
1358
1358
 
1359
1359
  Header arguments such as `options.headers` in [`Client.dispatch`](/docs/docs/api/Client.md#clientdispatchoptions-handlers) can be specified in three forms:
1360
- * As an object specified by the `Record<string, string | string[] | undefined>` (`IncomingHttpHeaders`) type.
1360
+ * As an object specified by the `Record<string, number | string | string[] | undefined>` (`OutgoingHttpHeaders`) type.
1361
1361
  * As an array of strings. An array representation of a header list must have an even length, or an `InvalidArgumentError` will be thrown.
1362
1362
  * As an iterable that can encompass `Headers`, `Map`, or a custom iterator returning key-value pairs.
1363
1363
  Keys are lowercase and values are not modified.
@@ -1,17 +1,17 @@
1
1
  # Global Installation
2
2
 
3
- Undici provides an `install()` function to add all WHATWG fetch classes to `globalThis`, making them available globally without requiring imports.
3
+ Undici provides an `install()` function to add fetch-related and other web API classes to `globalThis`, making them available globally without requiring imports.
4
4
 
5
5
  ## `install()`
6
6
 
7
- Install all WHATWG fetch classes globally on `globalThis`.
7
+ Install undici's global web APIs on `globalThis`.
8
8
 
9
9
  **Example:**
10
10
 
11
11
  ```js
12
12
  import { install } from 'undici'
13
13
 
14
- // Install all WHATWG fetch classes globally
14
+ // Install undici's global web APIs
15
15
  install()
16
16
 
17
17
  // Now you can use fetch classes globally without importing
@@ -74,6 +74,8 @@ await fetch('https://example.com', {
74
74
 
75
75
  After `install()`, `fetch`, `Headers`, `Response`, `Request`, and `FormData`
76
76
  all come from the installed `undici` package, so they work as a matching set.
77
+ `WebSocket`, `CloseEvent`, `ErrorEvent`, `MessageEvent`, and `EventSource`
78
+ also come from the installed `undici` package.
77
79
 
78
80
  If you do not want to install globals, import both from `undici` instead:
79
81
 
@@ -135,5 +137,5 @@ test('fetch API test', async () => {
135
137
 
136
138
  - The `install()` function overwrites any existing global implementations
137
139
  - Classes installed are undici's implementations, not Node.js built-ins
138
- - This provides access to undici's latest features and performance improvements
139
- - The global installation persists for the lifetime of the process
140
+ - This provides access to undici's latest fetch, WebSocket, and EventSource features and performance improvements
141
+ - The global installation persists for the lifetime of the process
@@ -27,7 +27,9 @@ new SnapshotAgent([options])
27
27
  - **ignoreHeaders** `Array<String>` - Headers to ignore during request matching
28
28
  - **excludeHeaders** `Array<String>` - Headers to exclude from snapshots (for security)
29
29
  - **matchBody** `Boolean` - Whether to include request body in matching. Default: `true`
30
+ - **normalizeBody** `Function` - Optional function `(body) => string` to normalize the request body before matching (e.g. strip volatile fields like timestamps). Only used when `matchBody` is `true`.
30
31
  - **matchQuery** `Boolean` - Whether to include query parameters in matching. Default: `true`
32
+ - **normalizeQuery** `Function` - Optional function `(query: URLSearchParams) => string` to normalize query parameters before matching (e.g. strip volatile params like cache-busters). Only used when `matchQuery` is `true`.
31
33
  - **caseSensitive** `Boolean` - Whether header matching is case-sensitive. Default: `false`
32
34
  - **shouldRecord** `Function` - Callback to determine if a request should be recorded
33
35
  - **shouldPlayback** `Function` - Callback to determine if a request should be played back
@@ -108,6 +110,27 @@ await agent.saveSnapshots('./custom-snapshots.json')
108
110
 
109
111
  ## Advanced Configuration
110
112
 
113
+ ### Body Matching
114
+
115
+ By default (`matchBody: true`) the full request body string is included in the snapshot key. Set it to `false` to ignore the body entirely, or use `normalizeBody` to strip volatile fields (like timestamps) before matching:
116
+
117
+ ```javascript
118
+ const agent = new SnapshotAgent({
119
+ mode: 'playback',
120
+ snapshotPath: './snapshots.json',
121
+
122
+ // Match on everything except the timestamp field
123
+ normalizeBody: (body) => {
124
+ if (!body) return ''
125
+ const parsed = JSON.parse(String(body))
126
+ delete parsed.timestamp
127
+ return JSON.stringify(parsed)
128
+ }
129
+ })
130
+ ```
131
+
132
+ `normalizeBody` receives the raw body (`string | Buffer | null | undefined`) and must return a `string`. It runs at both record and playback time so the hash is consistent. Two requests match the same snapshot whenever their normalized strings are identical.
133
+
111
134
  ### Header Filtering
112
135
 
113
136
  Control which headers are used for request matching and what gets stored in snapshots:
@@ -60,7 +60,7 @@ class ConnectHandler extends AsyncResource {
60
60
  // Indicates is an HTTP2Session
61
61
  if (responseHeaders != null) {
62
62
  responseHeaders = this.responseHeaders === 'raw'
63
- ? (Array.isArray(rawHeaders) ? util.parseRawHeaders(rawHeaders) : [])
63
+ ? util.parseRawHeaders(rawHeaders)
64
64
  : headers
65
65
  }
66
66
 
@@ -13,6 +13,7 @@ const {
13
13
  RequestAbortedError
14
14
  } = require('../core/errors')
15
15
  const util = require('../core/util')
16
+ const { kBodyUsed } = require('../core/symbols')
16
17
  const { addSignal, removeSignal } = require('./abort-signal')
17
18
 
18
19
  function noop () {}
@@ -24,6 +25,9 @@ class PipelineRequest extends Readable {
24
25
  super({ autoDestroy: true })
25
26
 
26
27
  this[kResume] = null
28
+ // Pipeline request bodies come from a live writable side and cannot be
29
+ // replayed across redirects or retries, even before any bytes are read.
30
+ this[kBodyUsed] = true
27
31
  }
28
32
 
29
33
  _read () {
@@ -167,7 +171,7 @@ class PipelineHandler extends AsyncResource {
167
171
  if (this.onInfo) {
168
172
  const rawHeaders = controller?.rawHeaders
169
173
  const responseHeaders = this.responseHeaders === 'raw'
170
- ? (Array.isArray(rawHeaders) ? util.parseRawHeaders(rawHeaders) : [])
174
+ ? util.parseRawHeaders(rawHeaders)
171
175
  : headers
172
176
  this.onInfo({ statusCode, headers: responseHeaders })
173
177
  }
@@ -181,7 +185,7 @@ class PipelineHandler extends AsyncResource {
181
185
  this.handler = null
182
186
  const rawHeaders = controller?.rawHeaders
183
187
  const responseHeaders = this.responseHeaders === 'raw'
184
- ? (Array.isArray(rawHeaders) ? util.parseRawHeaders(rawHeaders) : [])
188
+ ? util.parseRawHeaders(rawHeaders)
185
189
  : headers
186
190
  body = this.runInAsyncScope(handler, null, {
187
191
  statusCode,
@@ -21,7 +21,7 @@ class RequestHandler extends AsyncResource {
21
21
  throw new InvalidArgumentError('invalid callback')
22
22
  }
23
23
 
24
- if (highWaterMark && (typeof highWaterMark !== 'number' || highWaterMark < 0)) {
24
+ if (highWaterMark != null && (!Number.isFinite(highWaterMark) || highWaterMark < 0)) {
25
25
  throw new InvalidArgumentError('invalid highWaterMark')
26
26
  }
27
27
 
@@ -92,7 +92,7 @@ class RequestHandler extends AsyncResource {
92
92
 
93
93
  const rawHeaders = controller?.rawHeaders
94
94
  const responseHeaderData = responseHeaders === 'raw'
95
- ? (Array.isArray(rawHeaders) ? util.parseRawHeaders(rawHeaders) : [])
95
+ ? util.parseRawHeaders(rawHeaders)
96
96
  : headers
97
97
 
98
98
  if (statusCode < 200) {
@@ -1,7 +1,6 @@
1
1
  'use strict'
2
2
 
3
3
  const assert = require('node:assert')
4
- const { finished } = require('node:stream')
5
4
  const { AsyncResource } = require('node:async_hooks')
6
5
  const { InvalidArgumentError, InvalidReturnValueError } = require('../core/errors')
7
6
  const util = require('../core/util')
@@ -9,6 +8,54 @@ const { addSignal, removeSignal } = require('./abort-signal')
9
8
 
10
9
  function noop () {}
11
10
 
11
+ function getWritableError (stream) {
12
+ return stream.errored ?? stream.writableErrored ?? stream._writableState?.errored
13
+ }
14
+
15
+ function createPrematureCloseError () {
16
+ const err = new Error('Premature close')
17
+ err.code = 'ERR_STREAM_PREMATURE_CLOSE'
18
+ return err
19
+ }
20
+
21
+ function trackWritableLifecycle (stream, callback) {
22
+ let done = false
23
+
24
+ const cleanup = () => {
25
+ stream.removeListener('close', onClose)
26
+ stream.removeListener('error', onError)
27
+ stream.removeListener('finish', onFinish)
28
+ }
29
+
30
+ const finish = (err, fromErrorEvent = false) => {
31
+ if (done) {
32
+ return
33
+ }
34
+
35
+ done = true
36
+ cleanup()
37
+ callback(err, fromErrorEvent)
38
+ }
39
+
40
+ const onClose = () => {
41
+ const err = getWritableError(stream)
42
+ finish(err ?? (!stream.writableFinished ? createPrematureCloseError() : undefined))
43
+ }
44
+
45
+ const onError = (err) => finish(err, true)
46
+ const onFinish = () => finish()
47
+
48
+ stream.on('close', onClose)
49
+ stream.on('error', onError)
50
+ stream.on('finish', onFinish)
51
+
52
+ if (stream.closed) {
53
+ process.nextTick(onClose)
54
+ } else if (stream.writableFinished) {
55
+ process.nextTick(onFinish)
56
+ }
57
+ }
58
+
12
59
  class StreamHandler extends AsyncResource {
13
60
  constructor (opts, factory, callback) {
14
61
  if (!opts || typeof opts !== 'object') {
@@ -85,7 +132,7 @@ class StreamHandler extends AsyncResource {
85
132
 
86
133
  const rawHeaders = controller?.rawHeaders
87
134
  const responseHeaderData = responseHeaders === 'raw'
88
- ? (Array.isArray(rawHeaders) ? util.parseRawHeaders(rawHeaders) : [])
135
+ ? util.parseRawHeaders(rawHeaders)
89
136
  : headers
90
137
 
91
138
  if (statusCode < 200) {
@@ -117,20 +164,19 @@ class StreamHandler extends AsyncResource {
117
164
  throw new InvalidReturnValueError('expected Writable')
118
165
  }
119
166
 
120
- // TODO: Avoid finished. It registers an unnecessary amount of listeners.
121
- finished(res, { readable: false }, (err) => {
167
+ trackWritableLifecycle(res, (err, fromErrorEvent) => {
122
168
  const { callback, res, opaque, trailers, abort } = this
123
169
 
124
170
  this.res = null
125
171
  if (err || !res?.readable) {
126
- util.destroy(res, err)
172
+ util.destroy(res, fromErrorEvent ? undefined : err)
127
173
  }
128
174
 
129
175
  this.callback = null
130
176
  this.runInAsyncScope(callback, null, err || null, { opaque, trailers })
131
177
 
132
178
  if (err) {
133
- abort()
179
+ abort(err)
134
180
  }
135
181
  })
136
182
 
@@ -51,7 +51,13 @@ class UpgradeHandler extends AsyncResource {
51
51
  }
52
52
 
53
53
  onRequestUpgrade (controller, statusCode, headers, socket) {
54
- assert(socket[kHTTP2Stream] === true ? statusCode === 200 : statusCode === 101)
54
+ const expectedStatusCode = socket[kHTTP2Stream] === true ? 200 : 101
55
+
56
+ if (statusCode !== expectedStatusCode) {
57
+ const socketInfo = socket[kHTTP2Stream] === true ? null : util.getSocketInfo(socket)
58
+ controller.abort(new SocketError('bad upgrade', socketInfo))
59
+ return
60
+ }
55
61
 
56
62
  const { callback, opaque, context } = this
57
63
 
@@ -61,7 +67,7 @@ class UpgradeHandler extends AsyncResource {
61
67
 
62
68
  const rawHeaders = controller?.rawHeaders
63
69
  const responseHeaders = this.responseHeaders === 'raw'
64
- ? (Array.isArray(rawHeaders) ? util.parseRawHeaders(rawHeaders) : [])
70
+ ? util.parseRawHeaders(rawHeaders)
65
71
  : headers
66
72
 
67
73
  this.runInAsyncScope(callback, null, null, {
@@ -1,6 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const assert = require('node:assert')
4
+ const { addAbortListener } = require('node:events')
4
5
  const { Readable } = require('node:stream')
5
6
  const { RequestAbortedError, NotSupportedError, InvalidArgumentError, AbortError } = require('../core/errors')
6
7
  const util = require('../core/util')
@@ -293,10 +294,10 @@ class BodyReadable extends Readable {
293
294
  const onAbort = () => {
294
295
  this.destroy(signal.reason ?? new AbortError())
295
296
  }
296
- signal.addEventListener('abort', onAbort)
297
+ const abortListener = addAbortListener(signal, onAbort)
297
298
  this
298
299
  .on('close', function () {
299
- signal.removeEventListener('abort', onAbort)
300
+ abortListener[Symbol.dispose]()
300
301
  if (signal.aborted) {
301
302
  reject(signal.reason ?? new AbortError())
302
303
  } else {
@@ -138,7 +138,7 @@ class MemoryCacheStore extends EventEmitter {
138
138
 
139
139
  entry.size += chunk.byteLength
140
140
 
141
- if (entry.size >= store.#maxEntrySize) {
141
+ if (entry.size > store.#maxEntrySize) {
142
142
  this.destroy()
143
143
  } else {
144
144
  entry.body.push(chunk)
@@ -173,6 +173,7 @@ module.exports = class SqliteCacheStore {
173
173
  headers = ?,
174
174
  etag = ?,
175
175
  cacheControlDirectives = ?,
176
+ vary = ?,
176
177
  cachedAt = ?,
177
178
  staleAt = ?
178
179
  WHERE
@@ -216,7 +217,7 @@ module.exports = class SqliteCacheStore {
216
217
  SELECT
217
218
  id
218
219
  FROM cacheInterceptorV${VERSION}
219
- ORDER BY cachedAt DESC
220
+ ORDER BY cachedAt ASC
220
221
  LIMIT ?
221
222
  )
222
223
  `)
@@ -278,12 +279,12 @@ module.exports = class SqliteCacheStore {
278
279
  value.headers ? JSON.stringify(value.headers) : null,
279
280
  value.etag ? value.etag : null,
280
281
  value.cacheControlDirectives ? JSON.stringify(value.cacheControlDirectives) : null,
282
+ value.vary ? JSON.stringify(value.vary) : null,
281
283
  value.cachedAt,
282
284
  value.staleAt,
283
285
  existingValue.id
284
286
  )
285
287
  } else {
286
- this.#prune()
287
288
  // New response, let's insert it
288
289
  this.#insertValueQuery.run(
289
290
  url,
@@ -299,6 +300,7 @@ module.exports = class SqliteCacheStore {
299
300
  value.cachedAt,
300
301
  value.staleAt
301
302
  )
303
+ this.#prune()
302
304
  }
303
305
  }
304
306
 
@@ -323,7 +325,7 @@ module.exports = class SqliteCacheStore {
323
325
  write (chunk, encoding, callback) {
324
326
  size += chunk.byteLength
325
327
 
326
- if (size < store.#maxEntrySize) {
328
+ if (size <= store.#maxEntrySize) {
327
329
  body.push(chunk)
328
330
  } else {
329
331
  this.destroy()
@@ -409,7 +411,7 @@ module.exports = class SqliteCacheStore {
409
411
  const now = Date.now()
410
412
  for (const value of values) {
411
413
  if (now >= value.deleteAt && !canBeExpired) {
412
- return undefined
414
+ continue
413
415
  }
414
416
 
415
417
  let matches = true
@@ -38,6 +38,22 @@ const SessionCache = class WeakSessionCache {
38
38
  return
39
39
  }
40
40
 
41
+ if (this._sessionCache.has(sessionKey)) {
42
+ this._sessionCache.delete(sessionKey)
43
+ } else if (this._sessionCache.size >= this._maxCachedSessions) {
44
+ for (const [key, ref] of this._sessionCache) {
45
+ if (ref.deref() === undefined) {
46
+ this._sessionCache.delete(key)
47
+ return
48
+ }
49
+ }
50
+
51
+ const oldest = this._sessionCache.keys().next()
52
+ if (!oldest.done) {
53
+ this._sessionCache.delete(oldest.value)
54
+ }
55
+ }
56
+
41
57
  this._sessionCache.set(sessionKey, new WeakRef(session))
42
58
  this._sessionRegistry.register(session, sessionKey)
43
59
  }
@@ -107,28 +107,6 @@ const headerNameLowerCasedRecord = {}
107
107
  // Note: object prototypes should not be able to be referenced. e.g. `Object#hasOwnProperty`.
108
108
  Object.setPrototypeOf(headerNameLowerCasedRecord, null)
109
109
 
110
- /**
111
- * @type {Record<Lowercase<typeof wellknownHeaderNames[number]>, Buffer>}
112
- */
113
- const wellknownHeaderNameBuffers = {}
114
-
115
- // Note: object prototypes should not be able to be referenced. e.g. `Object#hasOwnProperty`.
116
- Object.setPrototypeOf(wellknownHeaderNameBuffers, null)
117
-
118
- /**
119
- * @param {string} header Lowercased header
120
- * @returns {Buffer}
121
- */
122
- function getHeaderNameAsBuffer (header) {
123
- let buffer = wellknownHeaderNameBuffers[header]
124
-
125
- if (buffer === undefined) {
126
- buffer = Buffer.from(header)
127
- }
128
-
129
- return buffer
130
- }
131
-
132
110
  for (let i = 0; i < wellknownHeaderNames.length; ++i) {
133
111
  const key = wellknownHeaderNames[i]
134
112
  const lowerCasedKey = key.toLowerCase()
@@ -138,6 +116,5 @@ for (let i = 0; i < wellknownHeaderNames.length; ++i) {
138
116
 
139
117
  module.exports = {
140
118
  wellknownHeaderNames,
141
- headerNameLowerCasedRecord,
142
- getHeaderNameAsBuffer
119
+ headerNameLowerCasedRecord
143
120
  }
@@ -163,8 +163,8 @@ class RequestAbortedError extends AbortError {
163
163
 
164
164
  const kInformationalError = Symbol.for('undici.error.UND_ERR_INFO')
165
165
  class InformationalError extends UndiciError {
166
- constructor (message) {
167
- super(message)
166
+ constructor (message, options) {
167
+ super(message, options)
168
168
  this.name = 'InformationalError'
169
169
  this.message = message || 'Request information'
170
170
  this.code = 'UND_ERR_INFO'
@@ -28,6 +28,21 @@ const { headerNameLowerCasedRecord } = require('./constants')
28
28
  // Verifies that a given path is valid does not contain control chars \x00 to \x20
29
29
  const invalidPathRegex = /[^\u0021-\u00ff]/
30
30
 
31
+ function isValidContentLengthHeaderValue (val) {
32
+ if (typeof val !== 'string' || val.length === 0) {
33
+ return false
34
+ }
35
+
36
+ for (let i = 0; i < val.length; i++) {
37
+ const charCode = val.charCodeAt(i)
38
+ if (charCode < 48 || charCode > 57) {
39
+ return false
40
+ }
41
+ }
42
+
43
+ return true
44
+ }
45
+
31
46
  const kHandler = Symbol('handler')
32
47
  const kController = Symbol('controller')
33
48
  const kResume = Symbol('resume')
@@ -484,10 +499,10 @@ function processHeader (request, key, val) {
484
499
  if (request.contentLength !== null) {
485
500
  throw new InvalidArgumentError('duplicate content-length header')
486
501
  }
487
- request.contentLength = parseInt(val, 10)
488
- if (!Number.isFinite(request.contentLength)) {
502
+ if (!isValidContentLengthHeaderValue(val)) {
489
503
  throw new InvalidArgumentError('invalid content-length header')
490
504
  }
505
+ request.contentLength = parseInt(val, 10)
491
506
  } else if (request.contentType === null && headerName === 'content-type') {
492
507
  request.contentType = val
493
508
  request.headers.push(key, val)
@@ -7,6 +7,7 @@ const { debuglog } = require('node:util')
7
7
  const { parseAddress } = require('./socks5-utils')
8
8
 
9
9
  const debug = debuglog('undici:socks5')
10
+ const EMPTY_BUFFER = Buffer.alloc(0)
10
11
 
11
12
  // SOCKS5 constants
12
13
  const SOCKS_VERSION = 0x05
@@ -51,6 +52,7 @@ const STATES = {
51
52
  INITIAL: 'initial',
52
53
  HANDSHAKING: 'handshaking',
53
54
  AUTHENTICATING: 'authenticating',
55
+ AUTHENTICATED: 'authenticated',
54
56
  CONNECTING: 'connecting',
55
57
  CONNECTED: 'connected',
56
58
  ERROR: 'error',
@@ -72,7 +74,10 @@ class Socks5Client extends EventEmitter {
72
74
  this.socket = socket
73
75
  this.options = options
74
76
  this.state = STATES.INITIAL
75
- this.buffer = Buffer.alloc(0)
77
+ this.buffer = EMPTY_BUFFER
78
+ this.onSocketData = this.onData.bind(this)
79
+ this.onSocketError = this.onError.bind(this)
80
+ this.onSocketClose = this.onClose.bind(this)
76
81
 
77
82
  // Authentication settings
78
83
  this.authMethods = []
@@ -82,9 +87,9 @@ class Socks5Client extends EventEmitter {
82
87
  this.authMethods.push(AUTH_METHODS.NO_AUTH)
83
88
 
84
89
  // Socket event handlers
85
- this.socket.on('data', this.onData.bind(this))
86
- this.socket.on('error', this.onError.bind(this))
87
- this.socket.on('close', this.onClose.bind(this))
90
+ this.socket.on('data', this.onSocketData)
91
+ this.socket.on('error', this.onSocketError)
92
+ this.socket.on('close', this.onSocketClose)
88
93
  }
89
94
 
90
95
  /**
@@ -139,6 +144,11 @@ class Socks5Client extends EventEmitter {
139
144
  }
140
145
  }
141
146
 
147
+ markAuthenticated () {
148
+ this.state = STATES.AUTHENTICATED
149
+ this.emit('authenticated')
150
+ }
151
+
142
152
  /**
143
153
  * Start the SOCKS5 handshake
144
154
  */
@@ -189,7 +199,7 @@ class Socks5Client extends EventEmitter {
189
199
  debug('server selected auth method', method)
190
200
 
191
201
  if (method === AUTH_METHODS.NO_AUTH) {
192
- this.emit('authenticated')
202
+ this.markAuthenticated()
193
203
  } else if (method === AUTH_METHODS.USERNAME_PASSWORD) {
194
204
  this.state = STATES.AUTHENTICATING
195
205
  this.sendAuthRequest()
@@ -254,7 +264,7 @@ class Socks5Client extends EventEmitter {
254
264
 
255
265
  this.buffer = this.buffer.subarray(2)
256
266
  debug('authentication successful')
257
- this.emit('authenticated')
267
+ this.markAuthenticated()
258
268
  }
259
269
 
260
270
  /**
@@ -263,8 +273,12 @@ class Socks5Client extends EventEmitter {
263
273
  * @param {number} port - Target port
264
274
  */
265
275
  connect (address, port) {
266
- if (this.state === STATES.CONNECTED) {
267
- throw new InvalidArgumentError('Already connected')
276
+ if (this.state === STATES.CONNECTING || this.state === STATES.CONNECTED) {
277
+ throw new InvalidArgumentError('Connection already in progress')
278
+ }
279
+
280
+ if (this.state !== STATES.AUTHENTICATED) {
281
+ throw new InvalidArgumentError('Client must be authenticated before CONNECT')
268
282
  }
269
283
 
270
284
  debug('connecting to', address, port)
@@ -363,8 +377,9 @@ class Socks5Client extends EventEmitter {
363
377
 
364
378
  const boundPort = this.buffer.readUInt16BE(offset)
365
379
 
366
- this.buffer = this.buffer.subarray(responseLength)
380
+ this.buffer = EMPTY_BUFFER
367
381
  this.state = STATES.CONNECTED
382
+ this.socket.removeListener('data', this.onSocketData)
368
383
 
369
384
  debug('connected, bound address:', boundAddress, 'port:', boundPort)
370
385
  this.emit('connected', { address: boundAddress, port: boundPort })
@@ -46,34 +46,43 @@ function parseAddress (address) {
46
46
  */
47
47
  function parseIPv6 (address) {
48
48
  const buffer = Buffer.alloc(16)
49
- const parts = address.split(':')
50
- let partIndex = 0
51
- let bufferIndex = 0
49
+ let normalizedAddress = address
50
+
51
+ // Expand an embedded IPv4 tail into the last two IPv6 groups.
52
+ if (address.includes('.')) {
53
+ const lastColonIndex = address.lastIndexOf(':')
54
+ const ipv4Part = address.slice(lastColonIndex + 1)
55
+
56
+ if (net.isIPv4(ipv4Part)) {
57
+ const octets = ipv4Part.split('.').map(Number)
58
+ const high = ((octets[0] << 8) | octets[1]).toString(16)
59
+ const low = ((octets[2] << 8) | octets[3]).toString(16)
60
+ normalizedAddress = `${address.slice(0, lastColonIndex)}:${high}:${low}`
61
+ }
62
+ }
52
63
 
53
64
  // Handle compressed notation (::)
54
- const doubleColonIndex = address.indexOf('::')
65
+ const doubleColonIndex = normalizedAddress.indexOf('::')
55
66
  if (doubleColonIndex !== -1) {
56
- // Count non-empty parts
57
- const nonEmptyParts = parts.filter(p => p.length > 0).length
58
- const skipParts = 8 - nonEmptyParts
59
-
60
- for (let i = 0; i < parts.length; i++) {
61
- if (parts[i] === '' && i === doubleColonIndex / 3) {
62
- // Skip empty parts for ::
63
- bufferIndex += skipParts * 2
64
- } else if (parts[i] !== '') {
65
- const value = parseInt(parts[i], 16)
66
- buffer.writeUInt16BE(value, bufferIndex)
67
- bufferIndex += 2
68
- }
67
+ const before = normalizedAddress.slice(0, doubleColonIndex)
68
+ const after = normalizedAddress.slice(doubleColonIndex + 2)
69
+ const beforeParts = before === '' ? [] : before.split(':')
70
+ const afterParts = after === '' ? [] : after.split(':')
71
+
72
+ let bufferIndex = 0
73
+ for (const part of beforeParts) {
74
+ buffer.writeUInt16BE(parseInt(part, 16), bufferIndex)
75
+ bufferIndex += 2
76
+ }
77
+ bufferIndex = 16 - afterParts.length * 2
78
+ for (const part of afterParts) {
79
+ buffer.writeUInt16BE(parseInt(part, 16), bufferIndex)
80
+ bufferIndex += 2
69
81
  }
70
82
  } else {
71
- // No compression, parse normally
72
- for (const part of parts) {
73
- if (part === '') continue
74
- const value = parseInt(part, 16)
75
- buffer.writeUInt16BE(value, partIndex * 2)
76
- partIndex++
83
+ const parts = normalizedAddress.split(':')
84
+ for (let i = 0; i < parts.length; i++) {
85
+ buffer.writeUInt16BE(parseInt(parts[i], 16), i * 2)
77
86
  }
78
87
  }
79
88