undici 8.4.1 → 8.5.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.
@@ -25,6 +25,7 @@ Returns: `Client`
25
25
  * **maxHeaderSize** `number | null` (optional) - Default: `--max-http-header-size` or `16384` - The maximum length of request headers in bytes. Defaults to Node.js' --max-http-header-size or 16KiB.
26
26
  * **maxResponseSize** `number | null` (optional) - Default: `-1` - The maximum length of response body in bytes. Set to `-1` to disable.
27
27
  * **webSocket** `WebSocketOptions` (optional) - WebSocket-specific configuration options.
28
+ * **maxFragments** `number` (optional) - Default: `131072` - Maximum number of fragments in a message. Set to 0 to disable the limit.
28
29
  * **maxPayloadSize** `number` (optional) - Default: `134217728` (128 MB) - Maximum allowed payload size in bytes for WebSocket messages. Applied to uncompressed messages, compressed frame payloads, and decompressed (permessage-deflate) messages. Set to 0 to disable the limit.
29
30
  * **pipelining** `number | null` (optional) - Default: `1` - The amount of concurrent requests to be sent over the single TCP/TLS connection according to [RFC7230](https://tools.ietf.org/html/rfc7230#section-6.3.2). Carefully consider your workload and environment before enabling concurrent requests as pipelining may reduce performance if used incorrectly. Pipelining is sensitive to network stack settings as well as head of line blocking caused by e.g. long running requests. Set to `0` to disable keep-alive connections. This option has no effect once HTTP/2 is negotiated — see `maxConcurrentStreams` for the h2 dispatch ceiling.
30
31
  * **connect** `ConnectOptions | Function | null` (optional) - Default: `null` - Configures how undici establishes TCP/TLS connections. Accepts two forms:
@@ -80,6 +80,33 @@ Arguments:
80
80
 
81
81
  Returns: `Cookie[]`
82
82
 
83
+ ## `parseCookie(cookie)`
84
+
85
+ Parses a single `Set-Cookie` header value into a `Cookie` object.
86
+
87
+ ```js
88
+ import { parseCookie } from 'undici'
89
+
90
+ console.log(parseCookie('undici=getSetCookies; Secure; SameSite=Lax'))
91
+ // {
92
+ // name: 'undici',
93
+ // value: 'getSetCookies',
94
+ // secure: true,
95
+ // sameSite: 'Lax'
96
+ // }
97
+ ```
98
+
99
+ Notes:
100
+
101
+ * The cookie value is returned as it appears in the header. Percent-encoded sequences such as `%20` or `%0D%0A` are **not** decoded.
102
+ * `sameSite` is only set for exact case-insensitive matches of `Strict`, `Lax`, or `None`.
103
+
104
+ Arguments:
105
+
106
+ * **cookie** `string`
107
+
108
+ Returns: `Cookie | null`
109
+
83
110
  ## `setCookie(headers, cookie)`
84
111
 
85
112
  Appends a cookie to the `Set-Cookie` header.
@@ -1376,14 +1376,23 @@ When using the array header format (`string[]`), Undici processes only indexed e
1376
1376
 
1377
1377
  Response headers will derive a `host` from the `url` of the [Client](/docs/docs/api/Client.md#class-client) instance if no `host` header was previously specified.
1378
1378
 
1379
+ ### Request header validation
1380
+
1381
+ Request headers that are managed by the HTTP connection are handled differently from ordinary headers:
1382
+
1383
+ * `transfer-encoding`, `keep-alive`, and `upgrade` cannot be set through `options.headers`; Undici throws an `InvalidArgumentError`.
1384
+ * `expect` is not supported; Undici throws a `NotSupportedError`.
1385
+ * `connection` must be a string containing comma-separated valid HTTP tokens. Undici rejects malformed tokens with `InvalidArgumentError: invalid connection header` and uses the `close` token to request connection reset behavior.
1386
+ * `host` and `content-length` are tracked separately from the raw header list. Duplicate `host` or `content-length` values are rejected, and `content-length` must contain only decimal digits.
1387
+
1379
1388
  ### Example 1 - Object
1380
1389
 
1381
1390
  ```js
1382
1391
  {
1383
1392
  'content-length': '123',
1384
1393
  'content-type': 'text/plain',
1385
- connection: 'keep-alive',
1386
1394
  host: 'mysite.com',
1395
+ 'accept-language': 'en',
1387
1396
  accept: '*/*'
1388
1397
  }
1389
1398
  ```
@@ -22,6 +22,7 @@ Extends: [`PoolOptions`](/docs/docs/api/Pool.md#parameter-pooloptions)
22
22
  * **password** `string` (optional) - SOCKS5 proxy password for authentication. Can also be provided in the proxy URL.
23
23
  * **connect** `Function` (optional) - Custom connector function for the proxy connection.
24
24
  * **proxyTls** `BuildOptions` (optional) - TLS options for the proxy connection (when using SOCKS5 over TLS).
25
+ * **requestTls** `BuildOptions` (optional) - TLS options applied to the HTTPS connection to the target server through the SOCKS5 tunnel. Use this to configure `ca`, `cert`, `key`, `rejectUnauthorized`, `servername`, etc. for the target HTTPS endpoint.
25
26
 
26
27
  Examples:
27
28
 
@@ -57,6 +57,9 @@ const constants = require('../llhttp/constants.js')
57
57
  const EMPTY_BUF = Buffer.alloc(0)
58
58
  const FastBuffer = Buffer[Symbol.species]
59
59
  const removeAllListeners = util.removeAllListeners
60
+ const kIdleSocketValidation = Symbol('kIdleSocketValidation')
61
+ const kIdleSocketValidationTimeout = Symbol('kIdleSocketValidationTimeout')
62
+ const kSocketUsed = Symbol('kSocketUsed')
60
63
 
61
64
  let extractBody
62
65
 
@@ -449,6 +452,11 @@ class Parser {
449
452
  return -1
450
453
  }
451
454
 
455
+ if (client[kRunning] === 0) {
456
+ util.destroy(socket, new SocketError('bad response', util.getSocketInfo(socket)))
457
+ return -1
458
+ }
459
+
452
460
  const request = client[kQueue][client[kRunningIdx]]
453
461
  if (!request) {
454
462
  return -1
@@ -584,6 +592,11 @@ class Parser {
584
592
  return -1
585
593
  }
586
594
 
595
+ if (client[kRunning] === 0) {
596
+ util.destroy(socket, new SocketError('bad response', util.getSocketInfo(socket)))
597
+ return -1
598
+ }
599
+
587
600
  const request = client[kQueue][client[kRunningIdx]]
588
601
 
589
602
  if (!request) {
@@ -762,6 +775,7 @@ class Parser {
762
775
  request.onResponseEnd(headers)
763
776
 
764
777
  client[kQueue][client[kRunningIdx]++] = null
778
+ socket[kSocketUsed] = client[kPending] === 0
765
779
 
766
780
  if (socket[kWriting]) {
767
781
  assert(client[kRunning] === 0)
@@ -838,6 +852,9 @@ function connectH1 (client, socket) {
838
852
  socket[kWriting] = false
839
853
  socket[kReset] = false
840
854
  socket[kBlocking] = false
855
+ socket[kIdleSocketValidation] = 0
856
+ socket[kIdleSocketValidationTimeout] = null
857
+ socket[kSocketUsed] = false
841
858
  socket[kParser] = new Parser(client, socket, llhttpInstance)
842
859
 
843
860
  util.addListener(socket, 'error', onHttpSocketError)
@@ -880,7 +897,7 @@ function connectH1 (client, socket) {
880
897
  * @returns {boolean}
881
898
  */
882
899
  busy (request) {
883
- if (socket[kWriting] || socket[kReset] || socket[kBlocking]) {
900
+ if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) {
884
901
  return true
885
902
  }
886
903
 
@@ -960,6 +977,8 @@ function onHttpSocketEnd () {
960
977
  function onHttpSocketClose () {
961
978
  const parser = this[kParser]
962
979
 
980
+ clearIdleSocketValidation(this)
981
+
963
982
  if (parser) {
964
983
  if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) {
965
984
  this[kError] = parser.finish() || this[kError]
@@ -1006,6 +1025,28 @@ function onSocketClose () {
1006
1025
  this[kClosed] = true
1007
1026
  }
1008
1027
 
1028
+ function clearIdleSocketValidation (socket) {
1029
+ if (socket[kIdleSocketValidationTimeout]) {
1030
+ clearTimeout(socket[kIdleSocketValidationTimeout])
1031
+ socket[kIdleSocketValidationTimeout] = null
1032
+ }
1033
+
1034
+ socket[kIdleSocketValidation] = 0
1035
+ }
1036
+
1037
+ function scheduleIdleSocketValidation (client, socket) {
1038
+ socket[kIdleSocketValidation] = 1
1039
+ socket[kIdleSocketValidationTimeout] = setTimeout(() => {
1040
+ socket[kIdleSocketValidationTimeout] = null
1041
+ socket[kIdleSocketValidation] = 2
1042
+
1043
+ if (client[kSocket] === socket && !socket.destroyed) {
1044
+ client[kResume]()
1045
+ }
1046
+ }, 0)
1047
+ socket[kIdleSocketValidationTimeout].unref?.()
1048
+ }
1049
+
1009
1050
  /**
1010
1051
  * @param {import('./client.js')} client
1011
1052
  */
@@ -1023,6 +1064,32 @@ function resumeH1 (client) {
1023
1064
  socket[kNoRef] = false
1024
1065
  }
1025
1066
 
1067
+ if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) {
1068
+ if (socket[kIdleSocketValidation] === 0) {
1069
+ scheduleIdleSocketValidation(client, socket)
1070
+ socket[kParser].readMore()
1071
+ if (socket.destroyed) {
1072
+ return
1073
+ }
1074
+ return
1075
+ }
1076
+
1077
+ if (socket[kIdleSocketValidation] === 1) {
1078
+ socket[kParser].readMore()
1079
+ if (socket.destroyed) {
1080
+ return
1081
+ }
1082
+ return
1083
+ }
1084
+ }
1085
+
1086
+ if (client[kRunning] === 0) {
1087
+ socket[kParser].readMore()
1088
+ if (socket.destroyed) {
1089
+ return
1090
+ }
1091
+ }
1092
+
1026
1093
  if (client[kSize] === 0) {
1027
1094
  if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) {
1028
1095
  socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE)
@@ -1121,6 +1188,7 @@ function writeH1 (client, request) {
1121
1188
  }
1122
1189
 
1123
1190
  const socket = client[kSocket]
1191
+ clearIdleSocketValidation(socket)
1124
1192
 
1125
1193
  /**
1126
1194
  * @param {Error} [err]
@@ -35,6 +35,7 @@ const {
35
35
  kSize,
36
36
  kHTTPContext,
37
37
  kClosed,
38
+ kKeepAliveDefaultTimeout,
38
39
  kHeadersTimeout,
39
40
  kBodyTimeout,
40
41
  kEnableConnectProtocol,
@@ -152,6 +153,21 @@ function requeueUnsentRequest (client, request) {
152
153
  client[kQueue].splice(client[kPendingIdx] + 1, 0, request)
153
154
  }
154
155
 
156
+ function completeRequest (client, request, resetPendingIdx = false) {
157
+ const index = client[kQueue].indexOf(request, client[kRunningIdx])
158
+
159
+ if (index === -1 || index >= client[kPendingIdx]) {
160
+ return
161
+ }
162
+
163
+ client[kQueue].splice(index, 1)
164
+ client[kPendingIdx]--
165
+
166
+ if (resetPendingIdx && client[kPendingIdx] < client[kRunningIdx]) {
167
+ client[kPendingIdx] = client[kRunningIdx]
168
+ }
169
+ }
170
+
155
171
  function canRetryRequestAfterGoAway (request) {
156
172
  const { body } = request
157
173
 
@@ -191,6 +207,7 @@ function connectH2 (client, socket) {
191
207
  session[kClient] = client
192
208
  session[kSocket] = socket
193
209
  session[kHTTP2SessionState] = {
210
+ idleTimeout: null,
194
211
  ping: {
195
212
  interval: client[kPingInterval] === 0 ? null : setInterval(onHttp2SendPing, client[kPingInterval], session).unref()
196
213
  }
@@ -279,10 +296,10 @@ function connectH2 (client, socket) {
279
296
  if (client[kRunning] > 0) {
280
297
  // We are already processing requests
281
298
 
282
- // Non-idempotent request cannot be retried.
283
- // Ensure that no other requests are inflight and
284
- // could cause failure.
285
- if (request.idempotent === false) return true
299
+ // Unlike HTTP/1.1 pipelining, HTTP/2 multiplexes requests on
300
+ // independent streams, so non-idempotent requests can be dispatched
301
+ // concurrently. Retry eligibility is handled by stream/session error
302
+ // handling instead of by serializing all non-idempotent requests.
286
303
  // Don't dispatch an upgrade until all preceding requests have completed.
287
304
  // Possibly, we do not have remote settings confirmed yet.
288
305
  if ((request.upgrade === 'websocket' || request.method === 'CONNECT') && session[kRemoteSettings] === false) return true
@@ -308,18 +325,68 @@ function connectH2 (client, socket) {
308
325
 
309
326
  function resumeH2 (client) {
310
327
  const socket = client[kSocket]
328
+ const session = client[kHTTP2Session]
311
329
 
312
330
  if (socket?.destroyed === false) {
313
331
  if (client[kSize] === 0 || client[kMaxConcurrentStreams] === 0) {
314
332
  socket.unref()
315
- client[kHTTP2Session].unref()
333
+ session.unref()
316
334
  } else {
317
335
  socket.ref()
318
- client[kHTTP2Session].ref()
336
+ session.ref()
319
337
  }
338
+
339
+ if (client[kSize] === 0 && session[kOpenStreams] === 0) {
340
+ setHttp2IdleTimeout(session)
341
+ } else {
342
+ clearHttp2IdleTimeout(session)
343
+ }
344
+ }
345
+ }
346
+
347
+ function clearHttp2IdleTimeout (session) {
348
+ const state = session[kHTTP2SessionState]
349
+
350
+ if (state?.idleTimeout != null) {
351
+ clearTimeout(state.idleTimeout)
352
+ state.idleTimeout = null
353
+ }
354
+ }
355
+
356
+ function setHttp2IdleTimeout (session) {
357
+ const client = session[kClient]
358
+
359
+ if (client[kHTTP2Session] !== session || session.closed || session.destroyed) {
360
+ return
361
+ }
362
+
363
+ if (session[kOpenStreams] !== 0 || client[kSize] !== 0) {
364
+ clearHttp2IdleTimeout(session)
365
+ return
366
+ }
367
+
368
+ const state = session[kHTTP2SessionState]
369
+ if (state.idleTimeout == null) {
370
+ state.idleTimeout = setTimeout(onHttp2SessionIdleTimeout, client[kKeepAliveDefaultTimeout], session).unref()
320
371
  }
321
372
  }
322
373
 
374
+ function onHttp2SessionIdleTimeout (session) {
375
+ const client = session[kClient]
376
+ const socket = session[kSocket]
377
+ const state = session[kHTTP2SessionState]
378
+
379
+ state.idleTimeout = null
380
+
381
+ if (client[kHTTP2Session] !== session || session[kOpenStreams] !== 0 || client[kSize] !== 0 || session.closed || session.destroyed) {
382
+ return
383
+ }
384
+
385
+ const err = new InformationalError('socket idle timeout')
386
+ socket[kError] = err
387
+ util.destroy(socket, err)
388
+ }
389
+
323
390
  function applyConnectionWindowSize (connectionWindowSize) {
324
391
  try {
325
392
  if (typeof this.setLocalWindowSize === 'function') {
@@ -445,6 +512,8 @@ function onHttp2SessionGoAway (errorCode, lastStreamID) {
445
512
  client[kHTTP2Session] = null
446
513
  }
447
514
 
515
+ clearHttp2IdleTimeout(this)
516
+
448
517
  if (!this.closed && !this.destroyed) {
449
518
  this.close()
450
519
  }
@@ -467,6 +536,8 @@ function onHttp2SessionClose () {
467
536
  client[kHTTP2Session] = null
468
537
  }
469
538
 
539
+ clearHttp2IdleTimeout(this)
540
+
470
541
  if (state.ping.interval != null) {
471
542
  clearInterval(state.ping.interval)
472
543
  state.ping.interval = null
@@ -479,7 +550,9 @@ function onHttp2SessionClose () {
479
550
  const requests = client[kQueue].splice(client[kRunningIdx])
480
551
  for (let i = 0; i < requests.length; i++) {
481
552
  const request = requests[i]
482
- util.errorRequest(client, request, err)
553
+ if (request != null) {
554
+ util.errorRequest(client, request, err)
555
+ }
483
556
  }
484
557
  }
485
558
  }
@@ -542,6 +615,7 @@ function closeStreamSession (stream) {
542
615
  session[kOpenStreams] -= 1
543
616
  if (session[kOpenStreams] === 0) {
544
617
  session.unref()
618
+ setHttp2IdleTimeout(session)
545
619
  }
546
620
  }
547
621
 
@@ -711,6 +785,7 @@ function setupUpgradeStream (stream, state) {
711
785
  stream.on('timeout', onUpgradeStreamTimeout)
712
786
  stream.once('close', onUpgradeStreamClose)
713
787
 
788
+ clearHttp2IdleTimeout(session)
714
789
  ++session[kOpenStreams]
715
790
  stream.setTimeout(headersTimeout)
716
791
  }
@@ -742,11 +817,7 @@ function writeH2 (client, request) {
742
817
  }
743
818
 
744
819
  requestFinalized = true
745
- client[kQueue][client[kRunningIdx]++] = null
746
-
747
- if (resetPendingIdx) {
748
- client[kPendingIdx] = client[kRunningIdx]
749
- }
820
+ completeRequest(client, request, resetPendingIdx)
750
821
 
751
822
  client[kResume]()
752
823
  }
@@ -983,6 +1054,7 @@ function writeH2 (client, request) {
983
1054
  state.stream = stream
984
1055
 
985
1056
  // Increment counter as we have new streams open
1057
+ clearHttp2IdleTimeout(session)
986
1058
  ++session[kOpenStreams]
987
1059
  stream.setTimeout(headersTimeout)
988
1060
 
@@ -395,7 +395,9 @@ class Client extends DispatcherBase {
395
395
  const requests = this[kQueue].splice(this[kPendingIdx])
396
396
  for (let i = 0; i < requests.length; i++) {
397
397
  const request = requests[i]
398
- util.errorRequest(this, request, err)
398
+ if (request != null) {
399
+ util.errorRequest(this, request, err)
400
+ }
399
401
  }
400
402
 
401
403
  const callback = () => {
@@ -434,7 +436,9 @@ function onError (client, err) {
434
436
 
435
437
  for (let i = 0; i < requests.length; i++) {
436
438
  const request = requests[i]
437
- util.errorRequest(client, request, err)
439
+ if (request != null) {
440
+ util.errorRequest(client, request, err)
441
+ }
438
442
  }
439
443
  assert(client[kSize] === 0)
440
444
  }
@@ -38,6 +38,7 @@ class DispatcherBase extends Dispatcher {
38
38
  */
39
39
  get webSocketOptions () {
40
40
  return {
41
+ maxFragments: this[kWebSocketOptions].maxFragments ?? 131072,
41
42
  maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 // 128 MB default
42
43
  }
43
44
  }
@@ -147,7 +147,8 @@ class ProxyAgent extends DispatcherBase {
147
147
  factory: agentFactory,
148
148
  username: opts.username || username,
149
149
  password: opts.password || password,
150
- proxyTls: opts.proxyTls
150
+ proxyTls: opts.proxyTls,
151
+ requestTls: opts.requestTls
151
152
  })
152
153
  }
153
154
 
@@ -19,6 +19,7 @@ const kProxyAuth = Symbol('proxy auth')
19
19
  const kProxyProtocol = Symbol('proxy protocol')
20
20
  const kPools = Symbol('pools')
21
21
  const kConnector = Symbol('connector')
22
+ const kRequestTls = Symbol('request tls settings')
22
23
 
23
24
  // Static flag to ensure warning is only emitted once per process
24
25
  let experimentalWarningEmitted = false
@@ -53,6 +54,7 @@ class Socks5ProxyAgent extends DispatcherBase {
53
54
  this[kProxyUrl] = url
54
55
  this[kProxyHeaders] = options.headers || {}
55
56
  this[kProxyProtocol] = options.proxyTls ? 'https:' : 'http:'
57
+ this[kRequestTls] = options.requestTls
56
58
 
57
59
  // Extract auth from URL or options
58
60
  this[kProxyAuth] = {
@@ -205,9 +207,9 @@ class Socks5ProxyAgent extends DispatcherBase {
205
207
  }
206
208
  debug('upgrading to TLS')
207
209
  finalSocket = tls.connect({
210
+ ...this[kRequestTls],
208
211
  socket,
209
- servername: targetHost,
210
- ...connectOpts.tls || {}
212
+ servername: this[kRequestTls]?.servername || targetHost
211
213
  })
212
214
 
213
215
  const tlsReady = Promise.withResolvers()
package/lib/util/cache.js CHANGED
@@ -228,6 +228,10 @@ function parseCacheControlHeader (header) {
228
228
  headers[headers.length - 1] = lastHeader
229
229
  }
230
230
 
231
+ for (let j = 0; j < headers.length; j++) {
232
+ headers[j] = headers[j].trim()
233
+ }
234
+
231
235
  if (key in output) {
232
236
  output[key] = output[key].concat(headers)
233
237
  } else {
@@ -236,10 +240,12 @@ function parseCacheControlHeader (header) {
236
240
  }
237
241
  } else {
238
242
  // Something like `no-cache="some-header"`
243
+ const fieldName = value.trim()
244
+
239
245
  if (key in output) {
240
- output[key] = output[key].concat(value)
246
+ output[key] = output[key].concat(fieldName)
241
247
  } else {
242
- output[key] = [value]
248
+ output[key] = [fieldName]
243
249
  }
244
250
  }
245
251
 
@@ -4,7 +4,6 @@ const { collectASequenceOfCodePointsFast } = require('../infra')
4
4
  const { maxNameValuePairSize, maxAttributeValueSize } = require('./constants')
5
5
  const { isCTLExcludingHtab } = require('./util')
6
6
  const assert = require('node:assert')
7
- const { unescape: qsUnescape } = require('node:querystring')
8
7
 
9
8
  /**
10
9
  * @description Parses the field-value attributes of a set-cookie header string.
@@ -82,7 +81,7 @@ function parseSetCookie (header) {
82
81
  // store arbitrary data in a cookie-value SHOULD encode that data, for
83
82
  // example, using Base64 [RFC4648].
84
83
  return {
85
- name, value: qsUnescape(value), ...parseUnparsedAttributes(unparsedAttributes)
84
+ name, value, ...parseUnparsedAttributes(unparsedAttributes)
86
85
  }
87
86
  }
88
87
 
@@ -280,32 +279,25 @@ function parseUnparsedAttributes (unparsedAttributes, cookieAttributeList = {})
280
279
  // If the attribute-name case-insensitively matches the string
281
280
  // "SameSite", the user agent MUST process the cookie-av as follows:
282
281
 
283
- // 1. Let enforcement be "Default".
284
- let enforcement = 'Default'
285
-
286
282
  const attributeValueLowercase = attributeValue.toLowerCase()
287
- // 2. If cookie-av's attribute-value is a case-insensitive match for
288
- // "None", set enforcement to "None".
289
- if (attributeValueLowercase.includes('none')) {
290
- enforcement = 'None'
291
- }
292
283
 
293
- // 3. If cookie-av's attribute-value is a case-insensitive match for
294
- // "Strict", set enforcement to "Strict".
295
- if (attributeValueLowercase.includes('strict')) {
296
- enforcement = 'Strict'
284
+ // 1. If cookie-av's attribute-value is a case-insensitive match for
285
+ // "None", append an attribute to the cookie-attribute-list with an
286
+ // attribute-name of "SameSite" and an attribute-value of "None".
287
+ if (attributeValueLowercase === 'none') {
288
+ cookieAttributeList.sameSite = 'None'
289
+ } else if (attributeValueLowercase === 'strict') {
290
+ // 2. If cookie-av's attribute-value is a case-insensitive match for
291
+ // "Strict", append an attribute to the cookie-attribute-list with
292
+ // an attribute-name of "SameSite" and an attribute-value of
293
+ // "Strict".
294
+ cookieAttributeList.sameSite = 'Strict'
295
+ } else if (attributeValueLowercase === 'lax') {
296
+ // 3. If cookie-av's attribute-value is a case-insensitive match for
297
+ // "Lax", append an attribute to the cookie-attribute-list with an
298
+ // attribute-name of "SameSite" and an attribute-value of "Lax".
299
+ cookieAttributeList.sameSite = 'Lax'
297
300
  }
298
-
299
- // 4. If cookie-av's attribute-value is a case-insensitive match for
300
- // "Lax", set enforcement to "Lax".
301
- if (attributeValueLowercase.includes('lax')) {
302
- enforcement = 'Lax'
303
- }
304
-
305
- // 5. Append an attribute to the cookie-attribute-list with an
306
- // attribute-name of "SameSite" and an attribute-value of
307
- // enforcement.
308
- cookieAttributeList.sameSite = enforcement
309
301
  } else {
310
302
  cookieAttributeList.unparsed ??= []
311
303
 
@@ -2,7 +2,6 @@
2
2
 
3
3
  const { pipeline } = require('node:stream')
4
4
  const { fetching } = require('../fetch')
5
- const { makeRequest } = require('../fetch/request')
6
5
  const { webidl } = require('../webidl')
7
6
  const { EventSourceStream } = require('./eventsource-stream')
8
7
  const { parseMIMEType } = require('../fetch/data-url')
@@ -10,6 +9,7 @@ const { createFastMessageEvent } = require('../websocket/events')
10
9
  const { isNetworkError } = require('../fetch/response')
11
10
  const { kEnumerableProperty } = require('../../core/util')
12
11
  const { environmentSettingsObject } = require('../fetch/util')
12
+ const { createPotentialCORSRequest } = require('./util')
13
13
 
14
14
  let experimentalWarned = false
15
15
 
@@ -160,33 +160,22 @@ class EventSource extends EventTarget {
160
160
 
161
161
  // 8. Let request be the result of creating a potential-CORS request given
162
162
  // urlRecord, the empty string, and corsAttributeState.
163
- const initRequest = {
164
- redirect: 'follow',
165
- keepalive: true,
166
- // @see https://html.spec.whatwg.org/multipage/urls-and-fetching.html#cors-settings-attributes
167
- mode: 'cors',
168
- credentials: corsAttributeState === 'anonymous'
169
- ? 'same-origin'
170
- : 'omit',
171
- referrer: 'no-referrer'
172
- }
163
+ const request = createPotentialCORSRequest(urlRecord, '', corsAttributeState)
173
164
 
174
165
  // 9. Set request's client to settings.
175
- initRequest.client = environmentSettingsObject.settingsObject
166
+ request.client = environmentSettingsObject.settingsObject
176
167
 
177
168
  // 10. User agents may set (`Accept`, `text/event-stream`) in request's header list.
178
- initRequest.headersList = [['accept', { name: 'accept', value: 'text/event-stream' }]]
169
+ request.headersList.set('Accept', 'text/event-stream')
179
170
 
180
171
  // 11. Set request's cache mode to "no-store".
181
- initRequest.cache = 'no-store'
172
+ request.cache = 'no-store'
182
173
 
183
174
  // 12. Set request's initiator type to "other".
184
- initRequest.initiator = 'other'
185
-
186
- initRequest.urlList = [new URL(this.#url)]
175
+ request.initiator = 'other'
187
176
 
188
177
  // 13. Set ev's request to request.
189
- this.#request = makeRequest(initRequest)
178
+ this.#request = request
190
179
 
191
180
  this.#connect()
192
181
  }
@@ -1,5 +1,7 @@
1
1
  'use strict'
2
2
 
3
+ const { makeRequest } = require('../fetch/request')
4
+
3
5
  /**
4
6
  * Checks if the given value is a valid LastEventId.
5
7
  * @param {string} value
@@ -23,7 +25,36 @@ function isASCIINumber (value) {
23
25
  return true
24
26
  }
25
27
 
28
+ function createPotentialCORSRequest (url, destination, corsAttributeState, sameOriginFallback) {
29
+ // 1. Let mode be "no-cors" if corsAttributeState is No CORS, and "cors" otherwise.
30
+ let mode = corsAttributeState === 'no cors' ? 'no-cors' : 'cors'
31
+
32
+ // 2. If same-origin fallback flag is set and mode is "no-cors", set mode to "same-origin".
33
+ if (sameOriginFallback && mode === 'no-cors') {
34
+ mode = 'same-origin'
35
+ }
36
+
37
+ // 3. Let credentialsMode be "include".
38
+ let credentialsMode = 'include'
39
+
40
+ // 4. If corsAttributeState is Anonymous, set credentialsMode to "same-origin".
41
+ if (corsAttributeState === 'anonymous') {
42
+ credentialsMode = 'same-origin'
43
+ }
44
+
45
+ // 5. Return a new request whose URL is url, destination is destination, mode is mode,
46
+ // credentials mode is credentialsMode, and whose use-URL-credentials flag is set.
47
+ return makeRequest({
48
+ urlList: [url],
49
+ destination,
50
+ mode,
51
+ credentials: credentialsMode,
52
+ useCredentials: true
53
+ })
54
+ }
55
+
26
56
  module.exports = {
27
57
  isValidLastEventId,
28
- isASCIINumber
58
+ isASCIINumber,
59
+ createPotentialCORSRequest
29
60
  }
@@ -392,6 +392,49 @@ function bodyMixinMethods (instance, getInternalState) {
392
392
  return consumeBody(this, (bytes) => {
393
393
  return new Uint8Array(bytes)
394
394
  }, instance, getInternalState)
395
+ },
396
+
397
+ textStream () {
398
+ const this_ = getInternalState(this)
399
+
400
+ // 1. If this is unusable, then throw a TypeError.
401
+ if (bodyUnusable(this_)) {
402
+ throw new TypeError('Body is unusable: Body has already been read')
403
+ }
404
+
405
+ // 2. If this’s body is null:
406
+ if (this_.body == null) {
407
+ // 2.1. Let emptyStream be a new ReadableStream in this’s relevant realm.
408
+ // 2.2. Set up emptyStream.
409
+ /** @type {ReadableStreamDefaultController<any>} */
410
+ let controller
411
+ const emptyStream = new ReadableStream({
412
+ start: (c) => {
413
+ controller = c
414
+ },
415
+ pull: () => Promise.resolve(),
416
+ cancel: () => Promise.resolve()
417
+ }, {
418
+ size: () => 1
419
+ })
420
+
421
+ // 2.3. Close emptyStream.
422
+ controller.close()
423
+
424
+ // 2.4. Return emptyStream.
425
+ return emptyStream
426
+ }
427
+
428
+ // 3. Let stream be this’s body’s stream.
429
+ /** @type {ReadableStream} */
430
+ const stream = this_.body.stream
431
+
432
+ // 4. Let decoder be a new TextDecoderStream object in this’s relevant realm.
433
+ // 5. Set up decoder with UTF-8.
434
+ const decoder = new TextDecoderStream('UTF-8')
435
+
436
+ // 6. Return the result of stream, piped through decoder.
437
+ return stream.pipeThrough(decoder)
395
438
  }
396
439
  }
397
440
 
@@ -930,6 +930,7 @@ function makeRequest (init) {
930
930
  referrerPolicy: init.referrerPolicy ?? '',
931
931
  mode: init.mode ?? 'no-cors',
932
932
  useCORSPreflightFlag: init.useCORSPreflightFlag ?? false,
933
+ // TODO: is this credentials mode? https://fetch.spec.whatwg.org/#concept-request-credentials-mode
933
934
  credentials: init.credentials ?? 'same-origin',
934
935
  useCredentials: init.useCredentials ?? false,
935
936
  cache: init.cache ?? 'default',
@@ -39,6 +39,9 @@ class ByteParser extends Writable {
39
39
  /** @type {import('./websocket').Handler} */
40
40
  #handler
41
41
 
42
+ /** @type {number} */
43
+ #maxFragments
44
+
42
45
  /** @type {number} */
43
46
  #maxPayloadSize
44
47
 
@@ -52,6 +55,7 @@ class ByteParser extends Writable {
52
55
 
53
56
  this.#handler = handler
54
57
  this.#extensions = extensions == null ? new Map() : extensions
58
+ this.#maxFragments = options.maxFragments ?? 0
55
59
  this.#maxPayloadSize = options.maxPayloadSize ?? 0
56
60
 
57
61
  if (this.#extensions.has('permessage-deflate')) {
@@ -75,7 +79,7 @@ class ByteParser extends Writable {
75
79
  if (
76
80
  this.#maxPayloadSize > 0 &&
77
81
  !isControlFrame(this.#info.opcode) &&
78
- this.#info.payloadLength > this.#maxPayloadSize
82
+ this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize
79
83
  ) {
80
84
  failWebsocketConnection(this.#handler, 1009, 'Payload size exceeds maximum allowed size')
81
85
  return false
@@ -242,7 +246,9 @@ class ByteParser extends Writable {
242
246
  this.#state = parserStates.INFO
243
247
  } else {
244
248
  if (!this.#info.compressed) {
245
- this.writeFragments(body)
249
+ if (!this.writeFragments(body)) {
250
+ return
251
+ }
246
252
 
247
253
  // If the frame is not fragmented, a message has been received.
248
254
  // If the frame is fragmented, it will terminate with a fin bit set
@@ -264,7 +270,9 @@ class ByteParser extends Writable {
264
270
  return
265
271
  }
266
272
 
267
- this.writeFragments(data)
273
+ if (!this.writeFragments(data)) {
274
+ return
275
+ }
268
276
 
269
277
  // Check cumulative fragment size
270
278
  if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) {
@@ -345,8 +353,17 @@ class ByteParser extends Writable {
345
353
  }
346
354
 
347
355
  writeFragments (fragment) {
356
+ if (
357
+ this.#maxFragments > 0 &&
358
+ this.#fragments.length === this.#maxFragments
359
+ ) {
360
+ failWebsocketConnection(this.#handler, 1008, 'Too many message fragments')
361
+ return false
362
+ }
363
+
348
364
  this.#fragmentsBytes += fragment.length
349
365
  this.#fragments.push(fragment)
366
+ return true
350
367
  }
351
368
 
352
369
  consumeFragments () {
@@ -258,7 +258,14 @@ class WebSocketStream {
258
258
  #onConnectionEstablished (response, parsedExtensions) {
259
259
  this.#handler.socket = response.socket
260
260
 
261
- const parser = new ByteParser(this.#handler, parsedExtensions)
261
+ // Get options from dispatcher options
262
+ const maxFragments = this.#handler.controller.dispatcher?.webSocketOptions?.maxFragments
263
+ const maxPayloadSize = this.#handler.controller.dispatcher?.webSocketOptions?.maxPayloadSize
264
+
265
+ const parser = new ByteParser(this.#handler, parsedExtensions, {
266
+ maxFragments,
267
+ maxPayloadSize
268
+ })
262
269
  parser.on('drain', () => this.#handler.onParserDrain())
263
270
  parser.on('error', (err) => this.#handler.onParserError(err))
264
271
 
@@ -468,10 +468,12 @@ class WebSocket extends EventTarget {
468
468
  // once this happens, the connection is open
469
469
  this.#handler.socket = response.socket
470
470
 
471
- // Get maxPayloadSize from dispatcher options
471
+ // Get options from dispatcher options
472
+ const maxFragments = this.#handler.controller.dispatcher?.webSocketOptions?.maxFragments
472
473
  const maxPayloadSize = this.#handler.controller.dispatcher?.webSocketOptions?.maxPayloadSize
473
474
 
474
475
  const parser = new ByteParser(this.#handler, parsedExtensions, {
476
+ maxFragments,
475
477
  maxPayloadSize
476
478
  })
477
479
  parser.on('drain', () => this.#handler.onParserDrain())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "undici",
3
- "version": "8.4.1",
3
+ "version": "8.5.0",
4
4
  "description": "An HTTP/1.1 client, written from scratch for Node.js",
5
5
  "homepage": "https://undici.nodejs.org",
6
6
  "bugs": {
package/types/client.d.ts CHANGED
@@ -116,6 +116,11 @@ export declare namespace Client {
116
116
  bytesRead?: number
117
117
  }
118
118
  export interface WebSocketOptions {
119
+ /**
120
+ * Maximum number of fragments in a message. Set to 0 to disable the limit.
121
+ * @default 131072
122
+ */
123
+ maxFragments?: number;
119
124
  /**
120
125
  * Maximum allowed payload size in bytes for WebSocket messages.
121
126
  * Applied to uncompressed messages, compressed frame payloads, and decompressed (permessage-deflate) messages.
package/types/fetch.d.ts CHANGED
@@ -57,6 +57,7 @@ export class BodyMixin {
57
57
  readonly formData: () => Promise<FormData>
58
58
  readonly json: () => Promise<unknown>
59
59
  readonly text: () => Promise<string>
60
+ readonly textStream: () => ReadableStream<string>
60
61
  }
61
62
 
62
63
  export interface SpecIterator<T, TReturn = any, TNext = undefined> {