undici 6.28.1 → 6.29.1
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.
- package/docs/docs/api/Client.md +2 -0
- package/docs/docs/api/DiagnosticsChannel.md +3 -1
- package/lib/core/request.js +68 -2
- package/lib/dispatcher/agent.js +6 -2
- package/lib/dispatcher/client-h1.js +18 -9
- package/lib/dispatcher/client-h2.js +70 -22
- package/lib/dispatcher/client.js +3 -2
- package/lib/dispatcher/dispatcher-base.js +9 -0
- package/lib/handler/retry-handler.js +4 -1
- package/lib/web/eventsource/eventsource-stream.js +91 -10
- package/lib/web/eventsource/eventsource.js +4 -1
- package/lib/web/fetch/index.js +7 -9
- package/lib/web/websocket/receiver.js +14 -0
- package/package.json +1 -1
- package/types/client.d.ts +14 -0
package/docs/docs/api/Client.md
CHANGED
|
@@ -29,6 +29,8 @@ Returns: `Client`
|
|
|
29
29
|
* **webSocket** `WebSocketOptions` (optional) - WebSocket-specific configuration options.
|
|
30
30
|
* **maxFragments** `number` (optional) - Default: `131072` - Maximum number of fragments in a message. Set to 0 to disable the limit.
|
|
31
31
|
* **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.
|
|
32
|
+
* **eventSource** `EventSourceOptions` (optional) - EventSource-specific configuration options.
|
|
33
|
+
* **maxEventSize** `number` (optional) - The maximum total bytes of data and metadata field values retained for a pending EventSource event. Each unfinished line is separately limited to this value plus seven syntax bytes. The number of `data:` lines per event and input chunks buffered for an unfinished line are each limited to `min(131072, max(1024, floor(maxEventSize / 128)))` to bound object overhead. Set to `0` to disable these limits. Default: `buffer.kStringMaxLength`.
|
|
32
34
|
* **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.
|
|
33
35
|
* **connect** `ConnectOptions | Function | null` (optional) - Default: `null`.
|
|
34
36
|
* **strictContentLength** `Boolean` (optional) - Default: `true` - Whether to treat request content length mismatches as errors. If true, an error is thrown when the request content-length header doesn't match the length of the request body.
|
|
@@ -40,7 +40,8 @@ diagnosticsChannel.channel('undici:request:bodySent').subscribe(({ request }) =>
|
|
|
40
40
|
|
|
41
41
|
## `undici:request:headers`
|
|
42
42
|
|
|
43
|
-
This message is published after the response headers have been received
|
|
43
|
+
This message is published after the response headers have been received. This includes a successful CONNECT or
|
|
44
|
+
protocol upgrade response.
|
|
44
45
|
|
|
45
46
|
```js
|
|
46
47
|
import diagnosticsChannel from 'diagnostics_channel'
|
|
@@ -57,6 +58,7 @@ diagnosticsChannel.channel('undici:request:headers').subscribe(({ request, respo
|
|
|
57
58
|
## `undici:request:trailers`
|
|
58
59
|
|
|
59
60
|
This message is published after the response body and trailers have been received, i.e. the response has been completed.
|
|
61
|
+
After an upgraded socket is passed to the request handler, this message is published with an empty `trailers` array.
|
|
60
62
|
|
|
61
63
|
```js
|
|
62
64
|
import diagnosticsChannel from 'diagnostics_channel'
|
package/lib/core/request.js
CHANGED
|
@@ -263,11 +263,77 @@ class Request {
|
|
|
263
263
|
}
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
-
|
|
266
|
+
/**
|
|
267
|
+
* @param {number|null} statusCode
|
|
268
|
+
* @param {Buffer[]|null} headers
|
|
269
|
+
* @param {import('node:stream').Duplex} socket
|
|
270
|
+
* @param {string} [statusText]
|
|
271
|
+
*/
|
|
272
|
+
onUpgrade (statusCode, headers, socket, statusText = '') {
|
|
273
|
+
this.onFinally()
|
|
274
|
+
|
|
267
275
|
assert(!this.aborted)
|
|
268
276
|
assert(!this.completed)
|
|
269
277
|
|
|
270
|
-
|
|
278
|
+
if (statusCode !== null) {
|
|
279
|
+
this.#publishUpgradeHeaders(statusCode, headers, statusText)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const result = this[kHandler].onUpgrade(statusCode, headers, socket)
|
|
283
|
+
|
|
284
|
+
if (!this.aborted) {
|
|
285
|
+
this.completed = true
|
|
286
|
+
if (statusCode !== null) {
|
|
287
|
+
this.#publishUpgradeTrailers()
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return result
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* @param {number} statusCode
|
|
296
|
+
* @param {import('node:http2').IncomingHttpHeaders} headers
|
|
297
|
+
* @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders
|
|
298
|
+
* @param {string} [statusText]
|
|
299
|
+
*/
|
|
300
|
+
onUpgradeResponse (statusCode, headers, parseHeaders, statusText = '') {
|
|
301
|
+
assert(!this.aborted)
|
|
302
|
+
assert(this.completed)
|
|
303
|
+
|
|
304
|
+
if (channels.headers.hasSubscribers) {
|
|
305
|
+
this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText)
|
|
306
|
+
}
|
|
307
|
+
this.#publishUpgradeTrailers()
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* @param {Error} error
|
|
312
|
+
*/
|
|
313
|
+
onUpgradeError (error) {
|
|
314
|
+
assert(!this.aborted)
|
|
315
|
+
assert(this.completed)
|
|
316
|
+
|
|
317
|
+
if (channels.error.hasSubscribers) {
|
|
318
|
+
channels.error.publish({ request: this, error })
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* @param {number} statusCode
|
|
324
|
+
* @param {Buffer[]} headers
|
|
325
|
+
* @param {string} statusText
|
|
326
|
+
*/
|
|
327
|
+
#publishUpgradeHeaders (statusCode, headers, statusText) {
|
|
328
|
+
if (channels.headers.hasSubscribers) {
|
|
329
|
+
channels.headers.publish({ request: this, response: { statusCode, headers, statusText } })
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
#publishUpgradeTrailers () {
|
|
334
|
+
if (channels.trailers.hasSubscribers) {
|
|
335
|
+
channels.trailers.publish({ request: this, trailers: [] })
|
|
336
|
+
}
|
|
271
337
|
}
|
|
272
338
|
|
|
273
339
|
onComplete (trailers) {
|
package/lib/dispatcher/agent.js
CHANGED
|
@@ -23,7 +23,7 @@ function defaultFactory (origin, opts) {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
class Agent extends DispatcherBase {
|
|
26
|
-
constructor ({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) {
|
|
26
|
+
constructor ({ factory = defaultFactory, maxRedirections = 0, connect, tls, ...options } = {}) {
|
|
27
27
|
if (typeof factory !== 'function') {
|
|
28
28
|
throw new InvalidArgumentError('factory must be a function.')
|
|
29
29
|
}
|
|
@@ -42,11 +42,15 @@ class Agent extends DispatcherBase {
|
|
|
42
42
|
connect = { ...connect }
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
if (tls && typeof tls !== 'function') {
|
|
46
|
+
tls = { ...tls }
|
|
47
|
+
}
|
|
48
|
+
|
|
45
49
|
this[kInterceptors] = options.interceptors?.Agent && Array.isArray(options.interceptors.Agent)
|
|
46
50
|
? options.interceptors.Agent
|
|
47
51
|
: [createRedirectInterceptor({ maxRedirections })]
|
|
48
52
|
|
|
49
|
-
this[kOptions] = { ...util.deepClone(options), connect }
|
|
53
|
+
this[kOptions] = { ...util.deepClone(options), connect, tls }
|
|
50
54
|
this[kOptions].interceptors = options.interceptors
|
|
51
55
|
? { ...options.interceptors }
|
|
52
56
|
: undefined
|
|
@@ -432,7 +432,7 @@ class Parser {
|
|
|
432
432
|
}
|
|
433
433
|
|
|
434
434
|
onUpgrade (head) {
|
|
435
|
-
const { upgrade, client, socket, headers, statusCode } = this
|
|
435
|
+
const { upgrade, client, socket, headers, statusCode, statusText } = this
|
|
436
436
|
|
|
437
437
|
assert(upgrade)
|
|
438
438
|
assert(client[kSocket] === socket)
|
|
@@ -467,9 +467,10 @@ class Parser {
|
|
|
467
467
|
client.emit('disconnect', client[kUrl], [client], new InformationalError('upgrade'))
|
|
468
468
|
|
|
469
469
|
try {
|
|
470
|
-
request.onUpgrade(statusCode, headers, socket)
|
|
471
|
-
} catch (
|
|
472
|
-
util.
|
|
470
|
+
request.onUpgrade(statusCode, headers, socket, statusText)
|
|
471
|
+
} catch (error) {
|
|
472
|
+
util.errorRequest(client, request, error)
|
|
473
|
+
util.destroy(socket, error)
|
|
473
474
|
}
|
|
474
475
|
|
|
475
476
|
client[kResume]()
|
|
@@ -726,8 +727,6 @@ async function connectH1 (client, socket) {
|
|
|
726
727
|
socket[kParser] = new Parser(client, socket, llhttpInstance)
|
|
727
728
|
|
|
728
729
|
addListener(socket, 'error', function (err) {
|
|
729
|
-
assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID')
|
|
730
|
-
|
|
731
730
|
const parser = this[kParser]
|
|
732
731
|
|
|
733
732
|
// On Mac OS, we get an ECONNRESET even if there is a full body to be forwarded
|
|
@@ -1050,12 +1049,22 @@ function writeH1 (client, request) {
|
|
|
1050
1049
|
const socket = client[kSocket]
|
|
1051
1050
|
clearIdleSocketValidation(socket)
|
|
1052
1051
|
|
|
1053
|
-
|
|
1054
|
-
|
|
1052
|
+
/**
|
|
1053
|
+
* @param {Error} [error]
|
|
1054
|
+
*/
|
|
1055
|
+
const abort = (error) => {
|
|
1056
|
+
if (request.aborted) {
|
|
1057
|
+
return
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
if (request.completed) {
|
|
1061
|
+
if (request.upgrade || request.method === 'CONNECT') {
|
|
1062
|
+
util.destroy(socket, new InformationalError('aborted'))
|
|
1063
|
+
}
|
|
1055
1064
|
return
|
|
1056
1065
|
}
|
|
1057
1066
|
|
|
1058
|
-
util.errorRequest(client, request,
|
|
1067
|
+
util.errorRequest(client, request, error || new RequestAbortedError())
|
|
1059
1068
|
|
|
1060
1069
|
util.destroy(body)
|
|
1061
1070
|
util.destroy(socket, new InformationalError('aborted'))
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const assert = require('node:assert')
|
|
4
|
+
const { errorMonitor } = require('node:events')
|
|
4
5
|
const { pipeline } = require('node:stream')
|
|
5
6
|
const util = require('../core/util.js')
|
|
6
7
|
const {
|
|
@@ -77,6 +78,15 @@ function parseH2Headers (headers) {
|
|
|
77
78
|
return result
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
/**
|
|
82
|
+
* @param {import('node:http2').IncomingHttpHeaders} headers
|
|
83
|
+
* @returns {Buffer[]}
|
|
84
|
+
*/
|
|
85
|
+
function parseH2ResponseHeaders (headers) {
|
|
86
|
+
const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers
|
|
87
|
+
return parseH2Headers(realHeaders)
|
|
88
|
+
}
|
|
89
|
+
|
|
80
90
|
async function connectH2 (client, socket) {
|
|
81
91
|
client[kSocket] = socket
|
|
82
92
|
|
|
@@ -126,8 +136,6 @@ async function connectH2 (client, socket) {
|
|
|
126
136
|
socket[kHTTP2Session] = session
|
|
127
137
|
|
|
128
138
|
util.addListener(socket, 'error', function (err) {
|
|
129
|
-
assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID')
|
|
130
|
-
|
|
131
139
|
this[kError] = err
|
|
132
140
|
|
|
133
141
|
this[kClient][kOnError](err)
|
|
@@ -201,8 +209,6 @@ function resumeH2 (client) {
|
|
|
201
209
|
}
|
|
202
210
|
|
|
203
211
|
function onHttp2SessionError (err) {
|
|
204
|
-
assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID')
|
|
205
|
-
|
|
206
212
|
this[kSocket][kError] = err
|
|
207
213
|
this[kClient][kOnError](err)
|
|
208
214
|
}
|
|
@@ -297,22 +303,32 @@ function writeH2 (client, request) {
|
|
|
297
303
|
headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ''}`
|
|
298
304
|
headers[HTTP2_HEADER_METHOD] = method
|
|
299
305
|
|
|
300
|
-
|
|
301
|
-
|
|
306
|
+
/**
|
|
307
|
+
* @param {Error} [error]
|
|
308
|
+
*/
|
|
309
|
+
const abort = (error) => {
|
|
310
|
+
if (request.aborted) {
|
|
302
311
|
return
|
|
303
312
|
}
|
|
304
313
|
|
|
305
|
-
|
|
314
|
+
if (request.completed) {
|
|
315
|
+
if (method === 'CONNECT' && stream != null) {
|
|
316
|
+
util.destroy(stream, error || new RequestAbortedError())
|
|
317
|
+
}
|
|
318
|
+
return
|
|
319
|
+
}
|
|
306
320
|
|
|
307
|
-
|
|
321
|
+
error = error || new RequestAbortedError()
|
|
322
|
+
|
|
323
|
+
util.errorRequest(client, request, error)
|
|
308
324
|
|
|
309
325
|
if (stream != null) {
|
|
310
|
-
util.destroy(stream,
|
|
326
|
+
util.destroy(stream, error)
|
|
311
327
|
}
|
|
312
328
|
|
|
313
329
|
// We do not destroy the socket as we can continue using the session
|
|
314
330
|
// the stream get's destroyed and the session remains to create new streams
|
|
315
|
-
util.destroy(body,
|
|
331
|
+
util.destroy(body, error)
|
|
316
332
|
client[kQueue][client[kRunningIdx]++] = null
|
|
317
333
|
client[kResume]()
|
|
318
334
|
}
|
|
@@ -331,25 +347,57 @@ function writeH2 (client, request) {
|
|
|
331
347
|
|
|
332
348
|
if (method === 'CONNECT') {
|
|
333
349
|
session.ref()
|
|
334
|
-
// We are already connected, streams are pending, first request
|
|
335
|
-
// will create a new stream. We trigger a request to create the stream and wait until
|
|
336
|
-
// `ready` event is triggered
|
|
337
350
|
// We disabled endStream to allow the user to write to the stream
|
|
338
351
|
stream = session.request(headers, { endStream: false, signal })
|
|
352
|
+
let upgradeResponseFinished = false
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* @param {import('node:http2').IncomingHttpHeaders} headers
|
|
356
|
+
*/
|
|
357
|
+
const onResponse = (headers) => {
|
|
358
|
+
upgradeResponseFinished = true
|
|
359
|
+
stream.off(errorMonitor, onUpgradeError)
|
|
360
|
+
request.onUpgradeResponse(Number(headers[HTTP2_HEADER_STATUS]), headers, parseH2ResponseHeaders)
|
|
361
|
+
}
|
|
339
362
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
stream.
|
|
363
|
+
/**
|
|
364
|
+
* @param {Error} error
|
|
365
|
+
*/
|
|
366
|
+
const onUpgradeError = (error) => {
|
|
367
|
+
upgradeResponseFinished = true
|
|
368
|
+
stream.off('response', onResponse)
|
|
369
|
+
request.onUpgradeError(error)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const onReady = () => {
|
|
373
|
+
try {
|
|
346
374
|
request.onUpgrade(null, null, stream)
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
375
|
+
} catch (error) {
|
|
376
|
+
stream.off('response', onResponse)
|
|
377
|
+
abort(error)
|
|
378
|
+
return
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (request.aborted) {
|
|
382
|
+
return
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
stream.off('error', abort)
|
|
386
|
+
stream.once(errorMonitor, onUpgradeError)
|
|
387
|
+
client[kQueue][client[kRunningIdx]++] = null
|
|
350
388
|
}
|
|
351
389
|
|
|
390
|
+
stream.once('response', onResponse)
|
|
391
|
+
stream.once('error', abort)
|
|
392
|
+
++session[kOpenStreams]
|
|
393
|
+
onReady()
|
|
394
|
+
|
|
352
395
|
stream.once('close', () => {
|
|
396
|
+
if (!upgradeResponseFinished && request.completed) {
|
|
397
|
+
stream.off('response', onResponse)
|
|
398
|
+
stream.off(errorMonitor, onUpgradeError)
|
|
399
|
+
request.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`))
|
|
400
|
+
}
|
|
353
401
|
session[kOpenStreams] -= 1
|
|
354
402
|
if (session[kOpenStreams] === 0) session.unref()
|
|
355
403
|
})
|
package/lib/dispatcher/client.js
CHANGED
|
@@ -107,9 +107,10 @@ class Client extends DispatcherBase {
|
|
|
107
107
|
// h2
|
|
108
108
|
maxConcurrentStreams,
|
|
109
109
|
allowH2,
|
|
110
|
-
webSocket
|
|
110
|
+
webSocket,
|
|
111
|
+
eventSource
|
|
111
112
|
} = {}) {
|
|
112
|
-
super({ webSocket })
|
|
113
|
+
super({ webSocket, eventSource })
|
|
113
114
|
|
|
114
115
|
if (keepAlive !== undefined) {
|
|
115
116
|
throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead')
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
+
const buffer = require('node:buffer')
|
|
3
4
|
const Dispatcher = require('./dispatcher')
|
|
4
5
|
const {
|
|
5
6
|
ClientDestroyedError,
|
|
@@ -12,6 +13,7 @@ const kOnDestroyed = Symbol('onDestroyed')
|
|
|
12
13
|
const kOnClosed = Symbol('onClosed')
|
|
13
14
|
const kInterceptedDispatch = Symbol('Intercepted Dispatch')
|
|
14
15
|
const kWebSocketOptions = Symbol('webSocketOptions')
|
|
16
|
+
const kEventSourceOptions = Symbol('eventSourceOptions')
|
|
15
17
|
|
|
16
18
|
class DispatcherBase extends Dispatcher {
|
|
17
19
|
constructor (opts) {
|
|
@@ -22,6 +24,7 @@ class DispatcherBase extends Dispatcher {
|
|
|
22
24
|
this[kClosed] = false
|
|
23
25
|
this[kOnClosed] = []
|
|
24
26
|
this[kWebSocketOptions] = opts?.webSocket ?? {}
|
|
27
|
+
this[kEventSourceOptions] = opts?.eventSource ?? {}
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
get webSocketOptions () {
|
|
@@ -31,6 +34,12 @@ class DispatcherBase extends Dispatcher {
|
|
|
31
34
|
}
|
|
32
35
|
}
|
|
33
36
|
|
|
37
|
+
get eventSourceOptions () {
|
|
38
|
+
return {
|
|
39
|
+
maxEventSize: this[kEventSourceOptions].maxEventSize ?? buffer.kStringMaxLength
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
34
43
|
get destroyed () {
|
|
35
44
|
return this[kDestroyed]
|
|
36
45
|
}
|
|
@@ -205,7 +205,10 @@ class RetryHandler {
|
|
|
205
205
|
this.retryCount += 1
|
|
206
206
|
|
|
207
207
|
if (statusCode >= 300) {
|
|
208
|
-
if
|
|
208
|
+
// Only expose a response if no earlier attempt has reached the caller.
|
|
209
|
+
// Otherwise abort this attempt so the error settles the existing body
|
|
210
|
+
// instead of replacing it with a new response.
|
|
211
|
+
if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) {
|
|
209
212
|
this.headersSent = true
|
|
210
213
|
this.checkpointResponseEnd(headers, resume)
|
|
211
214
|
return this.handler.onHeaders(
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
'use strict'
|
|
2
|
+
const buffer = require('node:buffer')
|
|
2
3
|
const { Transform } = require('node:stream')
|
|
3
4
|
const { isASCIINumber, isValidLastEventId } = require('./util')
|
|
4
5
|
|
|
@@ -23,11 +24,19 @@ const COLON = 0x3A
|
|
|
23
24
|
*/
|
|
24
25
|
const SPACE = 0x20
|
|
25
26
|
|
|
27
|
+
const defaultMaxEventSize = buffer.kStringMaxLength
|
|
28
|
+
|
|
26
29
|
const DATA = Buffer.from('data')
|
|
27
30
|
const EVENT = Buffer.from('event')
|
|
28
31
|
const ID = Buffer.from('id')
|
|
29
32
|
const RETRY = Buffer.from('retry')
|
|
30
33
|
|
|
34
|
+
// Each data field creates a string node, and each unconsumed input chunk keeps
|
|
35
|
+
// a Buffer wrapper. The absolute ceiling protects the large default byte limit.
|
|
36
|
+
const minParserItems = 1024
|
|
37
|
+
const maxParserItems = 131072
|
|
38
|
+
const bytesPerParserItem = 128
|
|
39
|
+
|
|
31
40
|
function isASCIINumberBytes (buffer, start) {
|
|
32
41
|
if (start >= buffer.length) {
|
|
33
42
|
return false
|
|
@@ -66,6 +75,12 @@ function isFieldName (line, length, field) {
|
|
|
66
75
|
return true
|
|
67
76
|
}
|
|
68
77
|
|
|
78
|
+
function createMaxEventSizeExceededError () {
|
|
79
|
+
const error = new Error('EventSource message size exceeded')
|
|
80
|
+
error.aborted = false
|
|
81
|
+
return error
|
|
82
|
+
}
|
|
83
|
+
|
|
69
84
|
/**
|
|
70
85
|
* @typedef {object} EventSourceStreamEvent
|
|
71
86
|
* @type {object}
|
|
@@ -114,6 +129,13 @@ class EventSourceStream extends Transform {
|
|
|
114
129
|
pos = 0
|
|
115
130
|
lineChunkIndex = 0
|
|
116
131
|
linePos = 0
|
|
132
|
+
lineLength = 0
|
|
133
|
+
eventSize = 0
|
|
134
|
+
eventFieldSizes = { data: 0, event: 0, id: 0, retry: 0 }
|
|
135
|
+
dataLineCount = 0
|
|
136
|
+
maxEventSize
|
|
137
|
+
maxDataLines
|
|
138
|
+
maxBufferedChunks
|
|
117
139
|
|
|
118
140
|
event = {
|
|
119
141
|
data: undefined,
|
|
@@ -124,6 +146,7 @@ class EventSourceStream extends Transform {
|
|
|
124
146
|
|
|
125
147
|
/**
|
|
126
148
|
* @param {object} options
|
|
149
|
+
* @param {number} [options.maxEventSize]
|
|
127
150
|
* @param {eventSourceSettings} options.eventSourceSettings
|
|
128
151
|
* @param {Function} [options.push]
|
|
129
152
|
*/
|
|
@@ -135,6 +158,12 @@ class EventSourceStream extends Transform {
|
|
|
135
158
|
super(options)
|
|
136
159
|
|
|
137
160
|
this.state = options.eventSourceSettings || {}
|
|
161
|
+
this.maxEventSize = options.maxEventSize ?? defaultMaxEventSize
|
|
162
|
+
const parserItemLimit = this.maxEventSize > 0
|
|
163
|
+
? Math.min(maxParserItems, Math.max(minParserItems, Math.floor(this.maxEventSize / bytesPerParserItem)))
|
|
164
|
+
: 0
|
|
165
|
+
this.maxDataLines = parserItemLimit
|
|
166
|
+
this.maxBufferedChunks = parserItemLimit
|
|
138
167
|
if (options.push) {
|
|
139
168
|
this.push = options.push
|
|
140
169
|
}
|
|
@@ -152,6 +181,14 @@ class EventSourceStream extends Transform {
|
|
|
152
181
|
return
|
|
153
182
|
}
|
|
154
183
|
|
|
184
|
+
// A leading line ending can immediately release the currently buffered
|
|
185
|
+
// line, even when it uses the final allowed chunk slot.
|
|
186
|
+
if (this.maxEventSize > 0 &&
|
|
187
|
+
this.chunks.length >= this.maxBufferedChunks &&
|
|
188
|
+
chunk[0] !== LF && chunk[0] !== CR) {
|
|
189
|
+
callback(createMaxEventSizeExceededError())
|
|
190
|
+
return
|
|
191
|
+
}
|
|
155
192
|
this.chunks.push(chunk)
|
|
156
193
|
|
|
157
194
|
// Strip leading byte-order-mark if we opened the stream and started
|
|
@@ -230,7 +267,12 @@ class EventSourceStream extends Transform {
|
|
|
230
267
|
|
|
231
268
|
// In any case, we can process the line as we reached an
|
|
232
269
|
// end-of-line character
|
|
233
|
-
|
|
270
|
+
try {
|
|
271
|
+
this.parseLine(this.readLine(), this.event)
|
|
272
|
+
} catch (error) {
|
|
273
|
+
callback(error)
|
|
274
|
+
return
|
|
275
|
+
}
|
|
234
276
|
this.consumeCurrentByte()
|
|
235
277
|
// A line was processed and this could be the end of the event. We need
|
|
236
278
|
// to check if the next line is empty to determine if the event is
|
|
@@ -239,15 +281,28 @@ class EventSourceStream extends Transform {
|
|
|
239
281
|
continue
|
|
240
282
|
}
|
|
241
283
|
|
|
284
|
+
// readLine() is only called after CR or LF. Reject an unfinished line
|
|
285
|
+
// before it can retain an unbounded number of decoded chunks. The seven
|
|
286
|
+
// extra bytes allow the longest field prefix ("event: " or "retry: ").
|
|
287
|
+
if (this.maxEventSize > 0 && ++this.lineLength > this.maxEventSize + 7) {
|
|
288
|
+
callback(createMaxEventSizeExceededError())
|
|
289
|
+
return
|
|
290
|
+
}
|
|
242
291
|
this.advanceCursor()
|
|
243
292
|
}
|
|
244
293
|
|
|
245
294
|
callback()
|
|
246
295
|
}
|
|
247
296
|
|
|
297
|
+
_destroy (error, callback) {
|
|
298
|
+
this.chunks.length = 0
|
|
299
|
+
this.clearEvent()
|
|
300
|
+
super._destroy(error, callback)
|
|
301
|
+
}
|
|
302
|
+
|
|
248
303
|
/**
|
|
249
304
|
* @param {Buffer} line
|
|
250
|
-
* @param {
|
|
305
|
+
* @param {EventSourceStreamEvent} event
|
|
251
306
|
*/
|
|
252
307
|
parseLine (line, event) {
|
|
253
308
|
// If the line is empty (a blank line)
|
|
@@ -281,6 +336,13 @@ class EventSourceStream extends Transform {
|
|
|
281
336
|
}
|
|
282
337
|
|
|
283
338
|
if (isFieldName(line, fieldLength, DATA)) {
|
|
339
|
+
if (this.maxEventSize > 0 && this.dataLineCount >= this.maxDataLines) {
|
|
340
|
+
throw createMaxEventSizeExceededError()
|
|
341
|
+
}
|
|
342
|
+
const valueBytes = line.length - valueStart
|
|
343
|
+
const dataSize = this.eventFieldSizes.data + (event.data === undefined ? 0 : 1) + valueBytes
|
|
344
|
+
this.setEventFieldSize('data', dataSize)
|
|
345
|
+
|
|
284
346
|
const value = line.toString('utf8', valueStart)
|
|
285
347
|
|
|
286
348
|
if (event.data === undefined) {
|
|
@@ -288,11 +350,13 @@ class EventSourceStream extends Transform {
|
|
|
288
350
|
} else {
|
|
289
351
|
event.data += `\n${value}`
|
|
290
352
|
}
|
|
353
|
+
this.dataLineCount++
|
|
291
354
|
return
|
|
292
355
|
}
|
|
293
356
|
|
|
294
357
|
if (isFieldName(line, fieldLength, RETRY)) {
|
|
295
358
|
if (isASCIINumberBytes(line, valueStart)) {
|
|
359
|
+
this.setEventFieldSize('retry', line.length - valueStart)
|
|
296
360
|
event.retry = line.toString('utf8', valueStart)
|
|
297
361
|
}
|
|
298
362
|
return
|
|
@@ -300,20 +364,29 @@ class EventSourceStream extends Transform {
|
|
|
300
364
|
|
|
301
365
|
if (isFieldName(line, fieldLength, ID)) {
|
|
302
366
|
if (isValidLastEventIdBytes(line, valueStart)) {
|
|
367
|
+
this.setEventFieldSize('id', line.length - valueStart)
|
|
303
368
|
event.id = line.toString('utf8', valueStart)
|
|
304
369
|
}
|
|
305
370
|
return
|
|
306
371
|
}
|
|
307
372
|
|
|
308
373
|
if (isFieldName(line, fieldLength, EVENT)) {
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
event.event = value
|
|
374
|
+
if (valueStart < line.length) {
|
|
375
|
+
this.setEventFieldSize('event', line.length - valueStart)
|
|
376
|
+
event.event = line.toString('utf8', valueStart)
|
|
313
377
|
}
|
|
314
378
|
}
|
|
315
379
|
}
|
|
316
380
|
|
|
381
|
+
setEventFieldSize (field, size) {
|
|
382
|
+
const eventSize = this.eventSize - this.eventFieldSizes[field] + size
|
|
383
|
+
if (this.maxEventSize > 0 && eventSize > this.maxEventSize) {
|
|
384
|
+
throw createMaxEventSizeExceededError()
|
|
385
|
+
}
|
|
386
|
+
this.eventSize = eventSize
|
|
387
|
+
this.eventFieldSizes[field] = size
|
|
388
|
+
}
|
|
389
|
+
|
|
317
390
|
/**
|
|
318
391
|
* @param {EventSourceStreamEvent} event
|
|
319
392
|
*/
|
|
@@ -344,6 +417,12 @@ class EventSourceStream extends Transform {
|
|
|
344
417
|
this.event.event = undefined
|
|
345
418
|
this.event.id = undefined
|
|
346
419
|
this.event.retry = undefined
|
|
420
|
+
this.eventSize = 0
|
|
421
|
+
this.dataLineCount = 0
|
|
422
|
+
this.eventFieldSizes.data = 0
|
|
423
|
+
this.eventFieldSizes.event = 0
|
|
424
|
+
this.eventFieldSizes.id = 0
|
|
425
|
+
this.eventFieldSizes.retry = 0
|
|
347
426
|
}
|
|
348
427
|
|
|
349
428
|
hasPendingEvent () {
|
|
@@ -379,14 +458,16 @@ class EventSourceStream extends Transform {
|
|
|
379
458
|
syncLineStartToCursor () {
|
|
380
459
|
this.lineChunkIndex = this.chunkIndex
|
|
381
460
|
this.linePos = this.pos
|
|
461
|
+
this.lineLength = 0
|
|
382
462
|
this.dropConsumedChunks()
|
|
383
463
|
}
|
|
384
464
|
|
|
385
465
|
dropConsumedChunks () {
|
|
386
|
-
|
|
387
|
-
this.
|
|
388
|
-
this.
|
|
389
|
-
this.
|
|
466
|
+
if (this.lineChunkIndex > 0) {
|
|
467
|
+
const consumed = this.lineChunkIndex
|
|
468
|
+
this.chunks.splice(0, consumed)
|
|
469
|
+
this.lineChunkIndex = 0
|
|
470
|
+
this.chunkIndex -= consumed
|
|
390
471
|
}
|
|
391
472
|
|
|
392
473
|
if (this.chunkIndex === this.chunks.length) {
|
|
@@ -11,6 +11,7 @@ const { isNetworkError } = require('../fetch/response')
|
|
|
11
11
|
const { delay } = require('./util')
|
|
12
12
|
const { kEnumerableProperty } = require('../../core/util')
|
|
13
13
|
const { environmentSettingsObject } = require('../fetch/util')
|
|
14
|
+
const { getGlobalDispatcher } = require('../../global')
|
|
14
15
|
|
|
15
16
|
let experimentalWarned = false
|
|
16
17
|
|
|
@@ -292,6 +293,7 @@ class EventSource extends EventTarget {
|
|
|
292
293
|
|
|
293
294
|
const eventSourceStream = new EventSourceStream({
|
|
294
295
|
eventSourceSettings: this.#state,
|
|
296
|
+
maxEventSize: this.#dispatcher.eventSourceOptions?.maxEventSize,
|
|
295
297
|
push: (event) => {
|
|
296
298
|
this.dispatchEvent(createFastMessageEvent(
|
|
297
299
|
event.type,
|
|
@@ -470,7 +472,8 @@ webidl.converters.EventSourceInitDict = webidl.dictionaryConverter([
|
|
|
470
472
|
},
|
|
471
473
|
{
|
|
472
474
|
key: 'dispatcher', // undici only
|
|
473
|
-
converter: webidl.converters.any
|
|
475
|
+
converter: webidl.converters.any,
|
|
476
|
+
defaultValue: () => getGlobalDispatcher()
|
|
474
477
|
}
|
|
475
478
|
])
|
|
476
479
|
|
package/lib/web/fetch/index.js
CHANGED
|
@@ -1950,8 +1950,10 @@ async function httpNetworkFetch (
|
|
|
1950
1950
|
// 19. Run these steps in parallel:
|
|
1951
1951
|
|
|
1952
1952
|
// 1. Run these steps, but abort when fetchParams is canceled:
|
|
1953
|
-
fetchParams.controller.
|
|
1954
|
-
|
|
1953
|
+
if (!fetchParams.controller.resume) {
|
|
1954
|
+
fetchParams.controller.on('terminated', onAborted)
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1955
1957
|
fetchParams.controller.resume = async () => {
|
|
1956
1958
|
// 1. While true
|
|
1957
1959
|
while (true) {
|
|
@@ -2120,8 +2122,6 @@ async function httpNetworkFetch (
|
|
|
2120
2122
|
}
|
|
2121
2123
|
location = headersList.get('location', true)
|
|
2122
2124
|
|
|
2123
|
-
this.body = new Readable({ read: resume })
|
|
2124
|
-
|
|
2125
2125
|
const decoders = []
|
|
2126
2126
|
|
|
2127
2127
|
const willFollow = location && request.redirect === 'follow' &&
|
|
@@ -2139,7 +2139,7 @@ async function httpNetworkFetch (
|
|
|
2139
2139
|
// CVE fix similar to urllib3 (GHSA-gm62-xv2j-4w53) and curl (CVE-2022-32206).
|
|
2140
2140
|
const maxContentEncodings = 5
|
|
2141
2141
|
if (codings.length > maxContentEncodings) {
|
|
2142
|
-
|
|
2142
|
+
this.abort(new Error(`too many content-encodings in response: ${codings.length}, maximum allowed is ${maxContentEncodings}`))
|
|
2143
2143
|
return true
|
|
2144
2144
|
}
|
|
2145
2145
|
|
|
@@ -2172,6 +2172,8 @@ async function httpNetworkFetch (
|
|
|
2172
2172
|
}
|
|
2173
2173
|
}
|
|
2174
2174
|
|
|
2175
|
+
this.body = new Readable({ read: resume })
|
|
2176
|
+
|
|
2175
2177
|
const onError = this.onError.bind(this)
|
|
2176
2178
|
|
|
2177
2179
|
resolve({
|
|
@@ -2218,10 +2220,6 @@ async function httpNetworkFetch (
|
|
|
2218
2220
|
fetchParams.controller.off('terminated', this.abort)
|
|
2219
2221
|
}
|
|
2220
2222
|
|
|
2221
|
-
if (fetchParams.controller.onAborted) {
|
|
2222
|
-
fetchParams.controller.off('terminated', fetchParams.controller.onAborted)
|
|
2223
|
-
}
|
|
2224
|
-
|
|
2225
2223
|
fetchParams.controller.ended = true
|
|
2226
2224
|
|
|
2227
2225
|
this.body.push(null)
|
|
@@ -249,6 +249,20 @@ class ByteParser extends Writable {
|
|
|
249
249
|
if (isControlFrame(this.#info.opcode)) {
|
|
250
250
|
this.#loop = this.parseControlFrame(body)
|
|
251
251
|
this.#state = parserStates.INFO
|
|
252
|
+
|
|
253
|
+
// If the automatic PONG/CLOSE reply filled the socket's write buffer,
|
|
254
|
+
// stop parsing and resume on drain so a PING flood cannot grow the
|
|
255
|
+
// outbound queue without bound.
|
|
256
|
+
const socket = this.ws[kResponse].socket
|
|
257
|
+
if (this.#loop && socket.writableNeedDrain) {
|
|
258
|
+
this.#loop = false
|
|
259
|
+
socket.once('drain', () => {
|
|
260
|
+
if (this.destroyed) return
|
|
261
|
+
this.#loop = true
|
|
262
|
+
this.run(callback)
|
|
263
|
+
})
|
|
264
|
+
return
|
|
265
|
+
}
|
|
252
266
|
} else {
|
|
253
267
|
if (!this.#info.compressed) {
|
|
254
268
|
if (!this.writeFragments(body)) {
|
package/package.json
CHANGED
package/types/client.d.ts
CHANGED
|
@@ -80,6 +80,8 @@ export declare namespace Client {
|
|
|
80
80
|
maxResponseSize?: number;
|
|
81
81
|
/** WebSocket-specific options */
|
|
82
82
|
webSocket?: Client.WebSocketOptions;
|
|
83
|
+
/** EventSource-specific options */
|
|
84
|
+
eventSource?: Client.EventSourceOptions;
|
|
83
85
|
/** Enables a family autodetection algorithm that loosely implements section 5 of RFC 8305. */
|
|
84
86
|
autoSelectFamily?: boolean;
|
|
85
87
|
/** The amount of time in milliseconds to wait for a connection attempt to finish before trying the next address when using the `autoSelectFamily` option. */
|
|
@@ -120,6 +122,18 @@ export declare namespace Client {
|
|
|
120
122
|
*/
|
|
121
123
|
maxPayloadSize?: number;
|
|
122
124
|
}
|
|
125
|
+
export interface EventSourceOptions {
|
|
126
|
+
/**
|
|
127
|
+
* Maximum total bytes of data and metadata field values retained for a
|
|
128
|
+
* pending EventSource event. Each unfinished line is separately bounded
|
|
129
|
+
* to this value plus seven syntax bytes. The number of data lines per
|
|
130
|
+
* event and buffered input chunks are each limited to
|
|
131
|
+
* min(131072, max(1024, floor(maxEventSize / 128))).
|
|
132
|
+
* Set to 0 to disable these limits.
|
|
133
|
+
* @default buffer.kStringMaxLength
|
|
134
|
+
*/
|
|
135
|
+
maxEventSize?: number;
|
|
136
|
+
}
|
|
123
137
|
}
|
|
124
138
|
|
|
125
139
|
export default Client;
|