undici 7.16.0 → 7.18.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.
- package/README.md +53 -1
- package/docs/docs/api/Client.md +1 -0
- package/docs/docs/api/DiagnosticsChannel.md +57 -0
- package/docs/docs/api/Dispatcher.md +86 -0
- package/docs/docs/api/RoundRobinPool.md +145 -0
- package/docs/docs/api/WebSocket.md +21 -0
- package/docs/docs/best-practices/crawling.md +58 -0
- package/index.js +4 -1
- package/lib/api/api-upgrade.js +2 -1
- package/lib/core/connect.js +4 -1
- package/lib/core/diagnostics.js +28 -1
- package/lib/core/symbols.js +3 -0
- package/lib/core/util.js +29 -31
- package/lib/dispatcher/balanced-pool.js +10 -0
- package/lib/dispatcher/client-h1.js +0 -16
- package/lib/dispatcher/client-h2.js +153 -23
- package/lib/dispatcher/client.js +7 -2
- package/lib/dispatcher/dispatcher-base.js +11 -12
- package/lib/dispatcher/h2c-client.js +7 -78
- package/lib/dispatcher/pool-base.js +1 -1
- package/lib/dispatcher/proxy-agent.js +13 -2
- package/lib/dispatcher/round-robin-pool.js +137 -0
- package/lib/encoding/index.js +33 -0
- package/lib/handler/cache-handler.js +84 -27
- package/lib/handler/deduplication-handler.js +216 -0
- package/lib/handler/retry-handler.js +0 -2
- package/lib/interceptor/cache.js +35 -17
- package/lib/interceptor/decompress.js +2 -1
- package/lib/interceptor/deduplicate.js +109 -0
- package/lib/interceptor/dns.js +55 -13
- package/lib/mock/mock-utils.js +1 -2
- package/lib/mock/snapshot-agent.js +11 -5
- package/lib/mock/snapshot-recorder.js +12 -4
- package/lib/mock/snapshot-utils.js +4 -4
- package/lib/util/cache.js +29 -1
- package/lib/util/runtime-features.js +124 -0
- package/lib/web/cookies/parse.js +1 -1
- package/lib/web/fetch/body.js +29 -39
- package/lib/web/fetch/data-url.js +12 -160
- package/lib/web/fetch/formdata-parser.js +204 -127
- package/lib/web/fetch/index.js +18 -6
- package/lib/web/fetch/request.js +6 -0
- package/lib/web/fetch/response.js +2 -3
- package/lib/web/fetch/util.js +2 -65
- package/lib/web/infra/index.js +229 -0
- package/lib/web/subresource-integrity/subresource-integrity.js +6 -5
- package/lib/web/webidl/index.js +4 -2
- package/lib/web/websocket/connection.js +31 -21
- package/lib/web/websocket/frame.js +9 -15
- package/lib/web/websocket/stream/websocketstream.js +1 -1
- package/lib/web/websocket/util.js +2 -1
- package/package.json +5 -4
- package/types/agent.d.ts +1 -1
- package/types/api.d.ts +2 -2
- package/types/balanced-pool.d.ts +2 -1
- package/types/cache-interceptor.d.ts +1 -0
- package/types/client.d.ts +1 -1
- package/types/connector.d.ts +2 -2
- package/types/diagnostics-channel.d.ts +2 -2
- package/types/dispatcher.d.ts +12 -12
- package/types/fetch.d.ts +4 -4
- package/types/formdata.d.ts +1 -1
- package/types/h2c-client.d.ts +1 -1
- package/types/index.d.ts +9 -1
- package/types/interceptors.d.ts +36 -2
- package/types/pool.d.ts +1 -1
- package/types/readable.d.ts +2 -2
- package/types/round-robin-pool.d.ts +41 -0
- package/types/websocket.d.ts +9 -9
|
@@ -1,16 +1,9 @@
|
|
|
1
1
|
'use strict'
|
|
2
|
-
const { connect } = require('node:net')
|
|
3
2
|
|
|
4
|
-
const { kClose, kDestroy } = require('../core/symbols')
|
|
5
3
|
const { InvalidArgumentError } = require('../core/errors')
|
|
6
|
-
const util = require('../core/util')
|
|
7
|
-
|
|
8
4
|
const Client = require('./client')
|
|
9
|
-
const DispatcherBase = require('./dispatcher-base')
|
|
10
|
-
|
|
11
|
-
class H2CClient extends DispatcherBase {
|
|
12
|
-
#client = null
|
|
13
5
|
|
|
6
|
+
class H2CClient extends Client {
|
|
14
7
|
constructor (origin, clientOpts) {
|
|
15
8
|
if (typeof origin === 'string') {
|
|
16
9
|
origin = new URL(origin)
|
|
@@ -23,14 +16,14 @@ class H2CClient extends DispatcherBase {
|
|
|
23
16
|
}
|
|
24
17
|
|
|
25
18
|
const { connect, maxConcurrentStreams, pipelining, ...opts } =
|
|
26
|
-
|
|
19
|
+
clientOpts ?? {}
|
|
27
20
|
let defaultMaxConcurrentStreams = 100
|
|
28
21
|
let defaultPipelining = 100
|
|
29
22
|
|
|
30
23
|
if (
|
|
31
24
|
maxConcurrentStreams != null &&
|
|
32
|
-
|
|
33
|
-
|
|
25
|
+
Number.isInteger(maxConcurrentStreams) &&
|
|
26
|
+
maxConcurrentStreams > 0
|
|
34
27
|
) {
|
|
35
28
|
defaultMaxConcurrentStreams = maxConcurrentStreams
|
|
36
29
|
}
|
|
@@ -45,78 +38,14 @@ class H2CClient extends DispatcherBase {
|
|
|
45
38
|
)
|
|
46
39
|
}
|
|
47
40
|
|
|
48
|
-
super(
|
|
49
|
-
|
|
50
|
-
this.#client = new Client(origin, {
|
|
41
|
+
super(origin, {
|
|
51
42
|
...opts,
|
|
52
|
-
connect: this.#buildConnector(connect),
|
|
53
43
|
maxConcurrentStreams: defaultMaxConcurrentStreams,
|
|
54
44
|
pipelining: defaultPipelining,
|
|
55
|
-
allowH2: true
|
|
45
|
+
allowH2: true,
|
|
46
|
+
useH2c: true
|
|
56
47
|
})
|
|
57
48
|
}
|
|
58
|
-
|
|
59
|
-
#buildConnector (connectOpts) {
|
|
60
|
-
return (opts, callback) => {
|
|
61
|
-
const timeout = connectOpts?.connectOpts ?? 10e3
|
|
62
|
-
const { hostname, port, pathname } = opts
|
|
63
|
-
const socket = connect({
|
|
64
|
-
...opts,
|
|
65
|
-
host: hostname,
|
|
66
|
-
port,
|
|
67
|
-
pathname
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
// Set TCP keep alive options on the socket here instead of in connect() for the case of assigning the socket
|
|
71
|
-
if (opts.keepAlive == null || opts.keepAlive) {
|
|
72
|
-
const keepAliveInitialDelay =
|
|
73
|
-
opts.keepAliveInitialDelay == null ? 60e3 : opts.keepAliveInitialDelay
|
|
74
|
-
socket.setKeepAlive(true, keepAliveInitialDelay)
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
socket.alpnProtocol = 'h2'
|
|
78
|
-
|
|
79
|
-
const clearConnectTimeout = util.setupConnectTimeout(
|
|
80
|
-
new WeakRef(socket),
|
|
81
|
-
{ timeout, hostname, port }
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
socket
|
|
85
|
-
.setNoDelay(true)
|
|
86
|
-
.once('connect', function () {
|
|
87
|
-
queueMicrotask(clearConnectTimeout)
|
|
88
|
-
|
|
89
|
-
if (callback) {
|
|
90
|
-
const cb = callback
|
|
91
|
-
callback = null
|
|
92
|
-
cb(null, this)
|
|
93
|
-
}
|
|
94
|
-
})
|
|
95
|
-
.on('error', function (err) {
|
|
96
|
-
queueMicrotask(clearConnectTimeout)
|
|
97
|
-
|
|
98
|
-
if (callback) {
|
|
99
|
-
const cb = callback
|
|
100
|
-
callback = null
|
|
101
|
-
cb(err)
|
|
102
|
-
}
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
return socket
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
dispatch (opts, handler) {
|
|
110
|
-
return this.#client.dispatch(opts, handler)
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
[kClose] () {
|
|
114
|
-
return this.#client.close()
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
[kDestroy] () {
|
|
118
|
-
return this.#client.destroy()
|
|
119
|
-
}
|
|
120
49
|
}
|
|
121
50
|
|
|
122
51
|
module.exports = H2CClient
|
|
@@ -7,6 +7,7 @@ const DispatcherBase = require('./dispatcher-base')
|
|
|
7
7
|
const { InvalidArgumentError, RequestAbortedError, SecureProxyConnectionError } = require('../core/errors')
|
|
8
8
|
const buildConnector = require('../core/connect')
|
|
9
9
|
const Client = require('./client')
|
|
10
|
+
const { channels } = require('../core/diagnostics')
|
|
10
11
|
|
|
11
12
|
const kAgent = Symbol('proxy agent')
|
|
12
13
|
const kClient = Symbol('proxy client')
|
|
@@ -150,7 +151,7 @@ class ProxyAgent extends DispatcherBase {
|
|
|
150
151
|
requestedPath += `:${defaultProtocolPort(opts.protocol)}`
|
|
151
152
|
}
|
|
152
153
|
try {
|
|
153
|
-
const
|
|
154
|
+
const connectParams = {
|
|
154
155
|
origin,
|
|
155
156
|
port,
|
|
156
157
|
path: requestedPath,
|
|
@@ -161,11 +162,21 @@ class ProxyAgent extends DispatcherBase {
|
|
|
161
162
|
...(opts.connections == null || opts.connections > 0 ? { 'proxy-connection': 'keep-alive' } : {})
|
|
162
163
|
},
|
|
163
164
|
servername: this[kProxyTls]?.servername || proxyHostname
|
|
164
|
-
}
|
|
165
|
+
}
|
|
166
|
+
const { socket, statusCode } = await this[kClient].connect(connectParams)
|
|
165
167
|
if (statusCode !== 200) {
|
|
166
168
|
socket.on('error', noop).destroy()
|
|
167
169
|
callback(new RequestAbortedError(`Proxy response (${statusCode}) !== 200 when HTTP Tunneling`))
|
|
170
|
+
return
|
|
168
171
|
}
|
|
172
|
+
|
|
173
|
+
if (channels.proxyConnected.hasSubscribers) {
|
|
174
|
+
channels.proxyConnected.publish({
|
|
175
|
+
socket,
|
|
176
|
+
connectParams
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
|
|
169
180
|
if (opts.protocol !== 'https:') {
|
|
170
181
|
callback(null, socket)
|
|
171
182
|
return
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
PoolBase,
|
|
5
|
+
kClients,
|
|
6
|
+
kNeedDrain,
|
|
7
|
+
kAddClient,
|
|
8
|
+
kGetDispatcher,
|
|
9
|
+
kRemoveClient
|
|
10
|
+
} = require('./pool-base')
|
|
11
|
+
const Client = require('./client')
|
|
12
|
+
const {
|
|
13
|
+
InvalidArgumentError
|
|
14
|
+
} = require('../core/errors')
|
|
15
|
+
const util = require('../core/util')
|
|
16
|
+
const { kUrl } = require('../core/symbols')
|
|
17
|
+
const buildConnector = require('../core/connect')
|
|
18
|
+
|
|
19
|
+
const kOptions = Symbol('options')
|
|
20
|
+
const kConnections = Symbol('connections')
|
|
21
|
+
const kFactory = Symbol('factory')
|
|
22
|
+
const kIndex = Symbol('index')
|
|
23
|
+
|
|
24
|
+
function defaultFactory (origin, opts) {
|
|
25
|
+
return new Client(origin, opts)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class RoundRobinPool extends PoolBase {
|
|
29
|
+
constructor (origin, {
|
|
30
|
+
connections,
|
|
31
|
+
factory = defaultFactory,
|
|
32
|
+
connect,
|
|
33
|
+
connectTimeout,
|
|
34
|
+
tls,
|
|
35
|
+
maxCachedSessions,
|
|
36
|
+
socketPath,
|
|
37
|
+
autoSelectFamily,
|
|
38
|
+
autoSelectFamilyAttemptTimeout,
|
|
39
|
+
allowH2,
|
|
40
|
+
clientTtl,
|
|
41
|
+
...options
|
|
42
|
+
} = {}) {
|
|
43
|
+
if (connections != null && (!Number.isFinite(connections) || connections < 0)) {
|
|
44
|
+
throw new InvalidArgumentError('invalid connections')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (typeof factory !== 'function') {
|
|
48
|
+
throw new InvalidArgumentError('factory must be a function.')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (connect != null && typeof connect !== 'function' && typeof connect !== 'object') {
|
|
52
|
+
throw new InvalidArgumentError('connect must be a function or an object')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (typeof connect !== 'function') {
|
|
56
|
+
connect = buildConnector({
|
|
57
|
+
...tls,
|
|
58
|
+
maxCachedSessions,
|
|
59
|
+
allowH2,
|
|
60
|
+
socketPath,
|
|
61
|
+
timeout: connectTimeout,
|
|
62
|
+
...(typeof autoSelectFamily === 'boolean' ? { autoSelectFamily, autoSelectFamilyAttemptTimeout } : undefined),
|
|
63
|
+
...connect
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
super()
|
|
68
|
+
|
|
69
|
+
this[kConnections] = connections || null
|
|
70
|
+
this[kUrl] = util.parseOrigin(origin)
|
|
71
|
+
this[kOptions] = { ...util.deepClone(options), connect, allowH2, clientTtl }
|
|
72
|
+
this[kOptions].interceptors = options.interceptors
|
|
73
|
+
? { ...options.interceptors }
|
|
74
|
+
: undefined
|
|
75
|
+
this[kFactory] = factory
|
|
76
|
+
this[kIndex] = -1
|
|
77
|
+
|
|
78
|
+
this.on('connect', (origin, targets) => {
|
|
79
|
+
if (clientTtl != null && clientTtl > 0) {
|
|
80
|
+
for (const target of targets) {
|
|
81
|
+
Object.assign(target, { ttl: Date.now() })
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
this.on('connectionError', (origin, targets, error) => {
|
|
87
|
+
for (const target of targets) {
|
|
88
|
+
const idx = this[kClients].indexOf(target)
|
|
89
|
+
if (idx !== -1) {
|
|
90
|
+
this[kClients].splice(idx, 1)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
[kGetDispatcher] () {
|
|
97
|
+
const clientTtlOption = this[kOptions].clientTtl
|
|
98
|
+
const clientsLength = this[kClients].length
|
|
99
|
+
|
|
100
|
+
// If we have no clients yet, create one
|
|
101
|
+
if (clientsLength === 0) {
|
|
102
|
+
const dispatcher = this[kFactory](this[kUrl], this[kOptions])
|
|
103
|
+
this[kAddClient](dispatcher)
|
|
104
|
+
return dispatcher
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Round-robin through existing clients
|
|
108
|
+
let checked = 0
|
|
109
|
+
while (checked < clientsLength) {
|
|
110
|
+
this[kIndex] = (this[kIndex] + 1) % clientsLength
|
|
111
|
+
const client = this[kClients][this[kIndex]]
|
|
112
|
+
|
|
113
|
+
// Check if client is stale (TTL expired)
|
|
114
|
+
if (clientTtlOption != null && clientTtlOption > 0 && client.ttl && ((Date.now() - client.ttl) > clientTtlOption)) {
|
|
115
|
+
this[kRemoveClient](client)
|
|
116
|
+
checked++
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Return client if it's not draining
|
|
121
|
+
if (!client[kNeedDrain]) {
|
|
122
|
+
return client
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
checked++
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// All clients are busy, create a new one if we haven't reached the limit
|
|
129
|
+
if (!this[kConnections] || clientsLength < this[kConnections]) {
|
|
130
|
+
const dispatcher = this[kFactory](this[kUrl], this[kOptions])
|
|
131
|
+
this[kAddClient](dispatcher)
|
|
132
|
+
return dispatcher
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = RoundRobinPool
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const textDecoder = new TextDecoder()
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @see https://encoding.spec.whatwg.org/#utf-8-decode
|
|
7
|
+
* @param {Uint8Array} buffer
|
|
8
|
+
*/
|
|
9
|
+
function utf8DecodeBytes (buffer) {
|
|
10
|
+
if (buffer.length === 0) {
|
|
11
|
+
return ''
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// 1. Let buffer be the result of peeking three bytes from
|
|
15
|
+
// ioQueue, converted to a byte sequence.
|
|
16
|
+
|
|
17
|
+
// 2. If buffer is 0xEF 0xBB 0xBF, then read three
|
|
18
|
+
// bytes from ioQueue. (Do nothing with those bytes.)
|
|
19
|
+
if (buffer[0] === 0xEF && buffer[1] === 0xBB && buffer[2] === 0xBF) {
|
|
20
|
+
buffer = buffer.subarray(3)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 3. Process a queue with an instance of UTF-8’s
|
|
24
|
+
// decoder, ioQueue, output, and "replacement".
|
|
25
|
+
const output = textDecoder.decode(buffer)
|
|
26
|
+
|
|
27
|
+
// 4. Return output.
|
|
28
|
+
return output
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = {
|
|
32
|
+
utf8DecodeBytes
|
|
33
|
+
}
|
|
@@ -17,11 +17,11 @@ const HEURISTICALLY_CACHEABLE_STATUS_CODES = [
|
|
|
17
17
|
|
|
18
18
|
// Status codes which semantic is not handled by the cache
|
|
19
19
|
// https://datatracker.ietf.org/doc/html/rfc9111#section-3
|
|
20
|
-
// This list should not grow beyond 206
|
|
20
|
+
// This list should not grow beyond 206 unless the RFC is updated
|
|
21
21
|
// by a newer one including more. Please introduce another list if
|
|
22
22
|
// implementing caching of responses with the 'must-understand' directive.
|
|
23
23
|
const NOT_UNDERSTOOD_STATUS_CODES = [
|
|
24
|
-
206
|
|
24
|
+
206
|
|
25
25
|
]
|
|
26
26
|
|
|
27
27
|
const MAX_RESPONSE_AGE = 2147483647000
|
|
@@ -104,6 +104,7 @@ class CacheHandler {
|
|
|
104
104
|
resHeaders,
|
|
105
105
|
statusMessage
|
|
106
106
|
)
|
|
107
|
+
const handler = this
|
|
107
108
|
|
|
108
109
|
if (
|
|
109
110
|
!util.safeHTTPMethods.includes(this.#cacheKey.method) &&
|
|
@@ -189,36 +190,92 @@ class CacheHandler {
|
|
|
189
190
|
deleteAt
|
|
190
191
|
}
|
|
191
192
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
193
|
+
// Not modified, re-use the cached value
|
|
194
|
+
// https://www.rfc-editor.org/rfc/rfc9111.html#name-handling-304-not-modified
|
|
195
|
+
if (statusCode === 304) {
|
|
196
|
+
/**
|
|
197
|
+
* @type {import('../../types/cache-interceptor.d.ts').default.CacheValue}
|
|
198
|
+
*/
|
|
199
|
+
const cachedValue = this.#store.get(this.#cacheKey)
|
|
200
|
+
if (!cachedValue) {
|
|
201
|
+
// Do not create a new cache entry, as a 304 won't have a body - so cannot be cached.
|
|
202
|
+
return downstreamOnHeaders()
|
|
203
|
+
}
|
|
195
204
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
205
|
+
// Re-use the cached value: statuscode, statusmessage, headers and body
|
|
206
|
+
value.statusCode = cachedValue.statusCode
|
|
207
|
+
value.statusMessage = cachedValue.statusMessage
|
|
208
|
+
value.etag = cachedValue.etag
|
|
209
|
+
value.headers = { ...cachedValue.headers, ...strippedHeaders }
|
|
200
210
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
handler
|
|
211
|
+
downstreamOnHeaders()
|
|
212
|
+
|
|
213
|
+
this.#writeStream = this.#store.createWriteStream(this.#cacheKey, value)
|
|
214
|
+
|
|
215
|
+
if (!this.#writeStream || !cachedValue?.body) {
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const bodyIterator = cachedValue.body.values()
|
|
220
|
+
|
|
221
|
+
const streamCachedBody = () => {
|
|
222
|
+
for (const chunk of bodyIterator) {
|
|
223
|
+
const full = this.#writeStream.write(chunk) === false
|
|
224
|
+
this.#handler.onResponseData?.(controller, chunk)
|
|
225
|
+
// when stream is full stop writing until we get a 'drain' event
|
|
226
|
+
if (full) {
|
|
227
|
+
break
|
|
228
|
+
}
|
|
215
229
|
}
|
|
230
|
+
}
|
|
216
231
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
232
|
+
this.#writeStream
|
|
233
|
+
.on('error', function () {
|
|
234
|
+
handler.#writeStream = undefined
|
|
235
|
+
handler.#store.delete(handler.#cacheKey)
|
|
236
|
+
})
|
|
237
|
+
.on('drain', () => {
|
|
238
|
+
streamCachedBody()
|
|
239
|
+
})
|
|
240
|
+
.on('close', function () {
|
|
241
|
+
if (handler.#writeStream === this) {
|
|
242
|
+
handler.#writeStream = undefined
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
streamCachedBody()
|
|
247
|
+
} else {
|
|
248
|
+
if (typeof resHeaders.etag === 'string' && isEtagUsable(resHeaders.etag)) {
|
|
249
|
+
value.etag = resHeaders.etag
|
|
250
|
+
}
|
|
220
251
|
|
|
221
|
-
|
|
252
|
+
this.#writeStream = this.#store.createWriteStream(this.#cacheKey, value)
|
|
253
|
+
|
|
254
|
+
if (!this.#writeStream) {
|
|
255
|
+
return downstreamOnHeaders()
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
this.#writeStream
|
|
259
|
+
.on('drain', () => controller.resume())
|
|
260
|
+
.on('error', function () {
|
|
261
|
+
// TODO (fix): Make error somehow observable?
|
|
262
|
+
handler.#writeStream = undefined
|
|
263
|
+
|
|
264
|
+
// Delete the value in case the cache store is holding onto state from
|
|
265
|
+
// the call to createWriteStream
|
|
266
|
+
handler.#store.delete(handler.#cacheKey)
|
|
267
|
+
})
|
|
268
|
+
.on('close', function () {
|
|
269
|
+
if (handler.#writeStream === this) {
|
|
270
|
+
handler.#writeStream = undefined
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// TODO (fix): Should we resume even if was paused downstream?
|
|
274
|
+
controller.resume()
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
downstreamOnHeaders()
|
|
278
|
+
}
|
|
222
279
|
}
|
|
223
280
|
|
|
224
281
|
onResponseData (controller, chunk) {
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {import('../../types/dispatcher.d.ts').default.DispatchHandler} DispatchHandler
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Handler that buffers response data and notifies multiple waiting handlers.
|
|
9
|
+
* Used for request deduplication.
|
|
10
|
+
*
|
|
11
|
+
* @implements {DispatchHandler}
|
|
12
|
+
*/
|
|
13
|
+
class DeduplicationHandler {
|
|
14
|
+
/**
|
|
15
|
+
* @type {DispatchHandler}
|
|
16
|
+
*/
|
|
17
|
+
#primaryHandler
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @type {DispatchHandler[]}
|
|
21
|
+
*/
|
|
22
|
+
#waitingHandlers = []
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @type {Buffer[]}
|
|
26
|
+
*/
|
|
27
|
+
#chunks = []
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @type {number}
|
|
31
|
+
*/
|
|
32
|
+
#statusCode = 0
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @type {Record<string, string | string[]>}
|
|
36
|
+
*/
|
|
37
|
+
#headers = {}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @type {string}
|
|
41
|
+
*/
|
|
42
|
+
#statusMessage = ''
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @type {boolean}
|
|
46
|
+
*/
|
|
47
|
+
#aborted = false
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @type {import('../../types/dispatcher.d.ts').default.DispatchController | null}
|
|
51
|
+
*/
|
|
52
|
+
#controller = null
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @type {(() => void) | null}
|
|
56
|
+
*/
|
|
57
|
+
#onComplete = null
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {DispatchHandler} primaryHandler The primary handler
|
|
61
|
+
* @param {() => void} onComplete Callback when request completes
|
|
62
|
+
*/
|
|
63
|
+
constructor (primaryHandler, onComplete) {
|
|
64
|
+
this.#primaryHandler = primaryHandler
|
|
65
|
+
this.#onComplete = onComplete
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Add a waiting handler that will receive the buffered response
|
|
70
|
+
* @param {DispatchHandler} handler
|
|
71
|
+
*/
|
|
72
|
+
addWaitingHandler (handler) {
|
|
73
|
+
this.#waitingHandlers.push(handler)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {() => void} abort
|
|
78
|
+
* @param {any} context
|
|
79
|
+
*/
|
|
80
|
+
onRequestStart (controller, context) {
|
|
81
|
+
this.#controller = controller
|
|
82
|
+
this.#primaryHandler.onRequestStart?.(controller, context)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {import('../../types/dispatcher.d.ts').default.DispatchController} controller
|
|
87
|
+
* @param {number} statusCode
|
|
88
|
+
* @param {import('../../types/header.d.ts').IncomingHttpHeaders} headers
|
|
89
|
+
* @param {Socket} socket
|
|
90
|
+
*/
|
|
91
|
+
onRequestUpgrade (controller, statusCode, headers, socket) {
|
|
92
|
+
this.#primaryHandler.onRequestUpgrade?.(controller, statusCode, headers, socket)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @param {import('../../types/dispatcher.d.ts').default.DispatchController} controller
|
|
97
|
+
* @param {number} statusCode
|
|
98
|
+
* @param {Record<string, string | string[]>} headers
|
|
99
|
+
* @param {string} statusMessage
|
|
100
|
+
*/
|
|
101
|
+
onResponseStart (controller, statusCode, headers, statusMessage) {
|
|
102
|
+
this.#statusCode = statusCode
|
|
103
|
+
this.#headers = headers
|
|
104
|
+
this.#statusMessage = statusMessage
|
|
105
|
+
this.#primaryHandler.onResponseStart?.(controller, statusCode, headers, statusMessage)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param {import('../../types/dispatcher.d.ts').default.DispatchController} controller
|
|
110
|
+
* @param {Buffer} chunk
|
|
111
|
+
*/
|
|
112
|
+
onResponseData (controller, chunk) {
|
|
113
|
+
// Buffer the chunk for waiting handlers
|
|
114
|
+
this.#chunks.push(Buffer.from(chunk))
|
|
115
|
+
this.#primaryHandler.onResponseData?.(controller, chunk)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {import('../../types/dispatcher.d.ts').default.DispatchController} controller
|
|
120
|
+
* @param {object} trailers
|
|
121
|
+
*/
|
|
122
|
+
onResponseEnd (controller, trailers) {
|
|
123
|
+
this.#primaryHandler.onResponseEnd?.(controller, trailers)
|
|
124
|
+
this.#notifyWaitingHandlers()
|
|
125
|
+
this.#onComplete?.()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {import('../../types/dispatcher.d.ts').default.DispatchController} controller
|
|
130
|
+
* @param {Error} err
|
|
131
|
+
*/
|
|
132
|
+
onResponseError (controller, err) {
|
|
133
|
+
this.#aborted = true
|
|
134
|
+
this.#primaryHandler.onResponseError?.(controller, err)
|
|
135
|
+
this.#notifyWaitingHandlersError(err)
|
|
136
|
+
this.#onComplete?.()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Notify all waiting handlers with the buffered response
|
|
141
|
+
*/
|
|
142
|
+
#notifyWaitingHandlers () {
|
|
143
|
+
const body = Buffer.concat(this.#chunks)
|
|
144
|
+
|
|
145
|
+
for (const handler of this.#waitingHandlers) {
|
|
146
|
+
// Create a simple controller for each waiting handler
|
|
147
|
+
const waitingController = {
|
|
148
|
+
resume () {},
|
|
149
|
+
pause () {},
|
|
150
|
+
get paused () { return false },
|
|
151
|
+
get aborted () { return false },
|
|
152
|
+
get reason () { return null },
|
|
153
|
+
abort () {}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
handler.onRequestStart?.(waitingController, null)
|
|
158
|
+
|
|
159
|
+
if (waitingController.aborted) {
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
handler.onResponseStart?.(
|
|
164
|
+
waitingController,
|
|
165
|
+
this.#statusCode,
|
|
166
|
+
this.#headers,
|
|
167
|
+
this.#statusMessage
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if (waitingController.aborted) {
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (body.length > 0) {
|
|
175
|
+
handler.onResponseData?.(waitingController, body)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
handler.onResponseEnd?.(waitingController, {})
|
|
179
|
+
} catch {
|
|
180
|
+
// Ignore errors from waiting handlers
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
this.#waitingHandlers = []
|
|
185
|
+
this.#chunks = []
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Notify all waiting handlers of an error
|
|
190
|
+
* @param {Error} err
|
|
191
|
+
*/
|
|
192
|
+
#notifyWaitingHandlersError (err) {
|
|
193
|
+
for (const handler of this.#waitingHandlers) {
|
|
194
|
+
const waitingController = {
|
|
195
|
+
resume () {},
|
|
196
|
+
pause () {},
|
|
197
|
+
get paused () { return false },
|
|
198
|
+
get aborted () { return true },
|
|
199
|
+
get reason () { return err },
|
|
200
|
+
abort () {}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
handler.onRequestStart?.(waitingController, null)
|
|
205
|
+
handler.onResponseError?.(waitingController, err)
|
|
206
|
+
} catch {
|
|
207
|
+
// Ignore errors from waiting handlers
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
this.#waitingHandlers = []
|
|
212
|
+
this.#chunks = []
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = DeduplicationHandler
|