undici 7.15.0 → 7.17.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 (93) hide show
  1. package/README.md +48 -2
  2. package/docs/docs/api/Agent.md +1 -0
  3. package/docs/docs/api/Client.md +1 -0
  4. package/docs/docs/api/DiagnosticsChannel.md +57 -0
  5. package/docs/docs/api/Dispatcher.md +86 -0
  6. package/docs/docs/api/Errors.md +0 -1
  7. package/docs/docs/api/RoundRobinPool.md +145 -0
  8. package/docs/docs/api/WebSocket.md +21 -0
  9. package/docs/docs/best-practices/crawling.md +58 -0
  10. package/index-fetch.js +2 -2
  11. package/index.js +8 -9
  12. package/lib/api/api-request.js +22 -8
  13. package/lib/api/api-upgrade.js +2 -1
  14. package/lib/api/readable.js +7 -5
  15. package/lib/core/connect.js +4 -1
  16. package/lib/core/diagnostics.js +28 -1
  17. package/lib/core/errors.js +217 -13
  18. package/lib/core/request.js +5 -1
  19. package/lib/core/symbols.js +3 -0
  20. package/lib/core/util.js +61 -41
  21. package/lib/dispatcher/agent.js +19 -7
  22. package/lib/dispatcher/balanced-pool.js +10 -0
  23. package/lib/dispatcher/client-h1.js +18 -23
  24. package/lib/dispatcher/client-h2.js +166 -26
  25. package/lib/dispatcher/client.js +64 -59
  26. package/lib/dispatcher/dispatcher-base.js +20 -16
  27. package/lib/dispatcher/env-http-proxy-agent.js +12 -16
  28. package/lib/dispatcher/fixed-queue.js +15 -39
  29. package/lib/dispatcher/h2c-client.js +7 -78
  30. package/lib/dispatcher/pool-base.js +60 -43
  31. package/lib/dispatcher/pool.js +2 -2
  32. package/lib/dispatcher/proxy-agent.js +27 -11
  33. package/lib/dispatcher/round-robin-pool.js +137 -0
  34. package/lib/encoding/index.js +33 -0
  35. package/lib/global.js +19 -1
  36. package/lib/handler/cache-handler.js +84 -27
  37. package/lib/handler/deduplication-handler.js +216 -0
  38. package/lib/handler/retry-handler.js +0 -2
  39. package/lib/interceptor/cache.js +94 -15
  40. package/lib/interceptor/decompress.js +2 -1
  41. package/lib/interceptor/deduplicate.js +109 -0
  42. package/lib/interceptor/dns.js +55 -13
  43. package/lib/mock/mock-agent.js +4 -4
  44. package/lib/mock/mock-errors.js +10 -0
  45. package/lib/mock/mock-utils.js +13 -12
  46. package/lib/mock/snapshot-agent.js +11 -5
  47. package/lib/mock/snapshot-recorder.js +12 -4
  48. package/lib/mock/snapshot-utils.js +4 -4
  49. package/lib/util/cache.js +29 -1
  50. package/lib/util/date.js +534 -140
  51. package/lib/util/runtime-features.js +124 -0
  52. package/lib/web/cookies/index.js +1 -1
  53. package/lib/web/cookies/parse.js +1 -1
  54. package/lib/web/eventsource/eventsource-stream.js +2 -2
  55. package/lib/web/eventsource/eventsource.js +34 -29
  56. package/lib/web/eventsource/util.js +1 -9
  57. package/lib/web/fetch/body.js +45 -61
  58. package/lib/web/fetch/data-url.js +12 -160
  59. package/lib/web/fetch/formdata-parser.js +204 -127
  60. package/lib/web/fetch/index.js +21 -19
  61. package/lib/web/fetch/request.js +6 -0
  62. package/lib/web/fetch/response.js +4 -7
  63. package/lib/web/fetch/util.js +10 -79
  64. package/lib/web/infra/index.js +229 -0
  65. package/lib/web/subresource-integrity/subresource-integrity.js +6 -5
  66. package/lib/web/webidl/index.js +207 -44
  67. package/lib/web/websocket/connection.js +33 -22
  68. package/lib/web/websocket/events.js +1 -1
  69. package/lib/web/websocket/frame.js +9 -15
  70. package/lib/web/websocket/stream/websocketerror.js +22 -1
  71. package/lib/web/websocket/stream/websocketstream.js +17 -8
  72. package/lib/web/websocket/util.js +2 -1
  73. package/lib/web/websocket/websocket.js +32 -42
  74. package/package.json +9 -7
  75. package/types/agent.d.ts +2 -1
  76. package/types/api.d.ts +2 -2
  77. package/types/balanced-pool.d.ts +2 -1
  78. package/types/cache-interceptor.d.ts +1 -0
  79. package/types/client.d.ts +1 -1
  80. package/types/connector.d.ts +2 -2
  81. package/types/diagnostics-channel.d.ts +2 -2
  82. package/types/dispatcher.d.ts +12 -12
  83. package/types/errors.d.ts +5 -15
  84. package/types/fetch.d.ts +4 -4
  85. package/types/formdata.d.ts +1 -1
  86. package/types/h2c-client.d.ts +1 -1
  87. package/types/index.d.ts +9 -1
  88. package/types/interceptors.d.ts +36 -2
  89. package/types/pool.d.ts +1 -1
  90. package/types/readable.d.ts +2 -2
  91. package/types/round-robin-pool.d.ts +41 -0
  92. package/types/webidl.d.ts +82 -21
  93. package/types/websocket.d.ts +9 -9
@@ -18,54 +18,55 @@ const kAddClient = Symbol('add client')
18
18
  const kRemoveClient = Symbol('remove client')
19
19
 
20
20
  class PoolBase extends DispatcherBase {
21
- constructor () {
22
- super()
21
+ [kQueue] = new FixedQueue();
23
22
 
24
- this[kQueue] = new FixedQueue()
25
- this[kClients] = []
26
- this[kQueued] = 0
23
+ [kQueued] = 0;
27
24
 
28
- const pool = this
25
+ [kClients] = [];
29
26
 
30
- this[kOnDrain] = function onDrain (origin, targets) {
31
- const queue = pool[kQueue]
27
+ [kNeedDrain] = false;
32
28
 
33
- let needDrain = false
29
+ [kOnDrain] (client, origin, targets) {
30
+ const queue = this[kQueue]
34
31
 
35
- while (!needDrain) {
36
- const item = queue.shift()
37
- if (!item) {
38
- break
39
- }
40
- pool[kQueued]--
41
- needDrain = !this.dispatch(item.opts, item.handler)
32
+ let needDrain = false
33
+
34
+ while (!needDrain) {
35
+ const item = queue.shift()
36
+ if (!item) {
37
+ break
42
38
  }
39
+ this[kQueued]--
40
+ needDrain = !client.dispatch(item.opts, item.handler)
41
+ }
43
42
 
44
- this[kNeedDrain] = needDrain
43
+ client[kNeedDrain] = needDrain
45
44
 
46
- if (!this[kNeedDrain] && pool[kNeedDrain]) {
47
- pool[kNeedDrain] = false
48
- pool.emit('drain', origin, [pool, ...targets])
49
- }
45
+ if (!needDrain && this[kNeedDrain]) {
46
+ this[kNeedDrain] = false
47
+ this.emit('drain', origin, [this, ...targets])
48
+ }
50
49
 
51
- if (pool[kClosedResolve] && queue.isEmpty()) {
52
- Promise
53
- .all(pool[kClients].map(c => c.close()))
54
- .then(pool[kClosedResolve])
50
+ if (this[kClosedResolve] && queue.isEmpty()) {
51
+ const closeAll = new Array(this[kClients].length)
52
+ for (let i = 0; i < this[kClients].length; i++) {
53
+ closeAll[i] = this[kClients][i].close()
55
54
  }
55
+ return Promise.all(closeAll)
56
+ .then(this[kClosedResolve])
56
57
  }
58
+ }
57
59
 
58
- this[kOnConnect] = (origin, targets) => {
59
- pool.emit('connect', origin, [pool, ...targets])
60
- }
60
+ [kOnConnect] = (origin, targets) => {
61
+ this.emit('connect', origin, [this, ...targets])
62
+ };
61
63
 
62
- this[kOnDisconnect] = (origin, targets, err) => {
63
- pool.emit('disconnect', origin, [pool, ...targets], err)
64
- }
64
+ [kOnDisconnect] = (origin, targets, err) => {
65
+ this.emit('disconnect', origin, [this, ...targets], err)
66
+ };
65
67
 
66
- this[kOnConnectionError] = (origin, targets, err) => {
67
- pool.emit('connectionError', origin, [pool, ...targets], err)
68
- }
68
+ [kOnConnectionError] = (origin, targets, err) => {
69
+ this.emit('connectionError', origin, [this, ...targets], err)
69
70
  }
70
71
 
71
72
  get [kBusy] () {
@@ -73,11 +74,19 @@ class PoolBase extends DispatcherBase {
73
74
  }
74
75
 
75
76
  get [kConnected] () {
76
- return this[kClients].filter(client => client[kConnected]).length
77
+ let ret = 0
78
+ for (const { [kConnected]: connected } of this[kClients]) {
79
+ ret += connected
80
+ }
81
+ return ret
77
82
  }
78
83
 
79
84
  get [kFree] () {
80
- return this[kClients].filter(client => client[kConnected] && !client[kNeedDrain]).length
85
+ let ret = 0
86
+ for (const { [kConnected]: connected, [kNeedDrain]: needDrain } of this[kClients]) {
87
+ ret += connected && !needDrain
88
+ }
89
+ return ret
81
90
  }
82
91
 
83
92
  get [kPending] () {
@@ -108,17 +117,21 @@ class PoolBase extends DispatcherBase {
108
117
  return new PoolStats(this)
109
118
  }
110
119
 
111
- async [kClose] () {
120
+ [kClose] () {
112
121
  if (this[kQueue].isEmpty()) {
113
- await Promise.all(this[kClients].map(c => c.close()))
122
+ const closeAll = new Array(this[kClients].length)
123
+ for (let i = 0; i < this[kClients].length; i++) {
124
+ closeAll[i] = this[kClients][i].close()
125
+ }
126
+ return Promise.all(closeAll)
114
127
  } else {
115
- await new Promise((resolve) => {
128
+ return new Promise((resolve) => {
116
129
  this[kClosedResolve] = resolve
117
130
  })
118
131
  }
119
132
  }
120
133
 
121
- async [kDestroy] (err) {
134
+ [kDestroy] (err) {
122
135
  while (true) {
123
136
  const item = this[kQueue].shift()
124
137
  if (!item) {
@@ -127,7 +140,11 @@ class PoolBase extends DispatcherBase {
127
140
  item.handler.onError(err)
128
141
  }
129
142
 
130
- await Promise.all(this[kClients].map(c => c.destroy(err)))
143
+ const destroyAll = new Array(this[kClients].length)
144
+ for (let i = 0; i < this[kClients].length; i++) {
145
+ destroyAll[i] = this[kClients][i].destroy(err)
146
+ }
147
+ return Promise.all(destroyAll)
131
148
  }
132
149
 
133
150
  [kDispatch] (opts, handler) {
@@ -147,7 +164,7 @@ class PoolBase extends DispatcherBase {
147
164
 
148
165
  [kAddClient] (client) {
149
166
  client
150
- .on('drain', this[kOnDrain])
167
+ .on('drain', this[kOnDrain].bind(this, client))
151
168
  .on('connect', this[kOnConnect])
152
169
  .on('disconnect', this[kOnDisconnect])
153
170
  .on('connectionError', this[kOnConnectionError])
@@ -157,7 +174,7 @@ class PoolBase extends DispatcherBase {
157
174
  if (this[kNeedDrain]) {
158
175
  queueMicrotask(() => {
159
176
  if (this[kNeedDrain]) {
160
- this[kOnDrain](client[kUrl], [this, client])
177
+ this[kOnDrain](client, client[kUrl], [client, this])
161
178
  }
162
179
  })
163
180
  }
@@ -51,8 +51,6 @@ class Pool extends PoolBase {
51
51
  throw new InvalidArgumentError('connect must be a function or an object')
52
52
  }
53
53
 
54
- super()
55
-
56
54
  if (typeof connect !== 'function') {
57
55
  connect = buildConnector({
58
56
  ...tls,
@@ -65,6 +63,8 @@ class Pool extends PoolBase {
65
63
  })
66
64
  }
67
65
 
66
+ super()
67
+
68
68
  this[kConnections] = connections || null
69
69
  this[kUrl] = util.parseOrigin(origin)
70
70
  this[kOptions] = { ...util.deepClone(options), connect, allowH2, clientTtl }
@@ -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')
@@ -37,11 +38,12 @@ class Http1ProxyWrapper extends DispatcherBase {
37
38
  #client
38
39
 
39
40
  constructor (proxyUrl, { headers = {}, connect, factory }) {
40
- super()
41
41
  if (!proxyUrl) {
42
42
  throw new InvalidArgumentError('Proxy URL is mandatory')
43
43
  }
44
44
 
45
+ super()
46
+
45
47
  this[kProxyHeaders] = headers
46
48
  if (factory) {
47
49
  this.#client = factory(proxyUrl, { connect })
@@ -80,11 +82,11 @@ class Http1ProxyWrapper extends DispatcherBase {
80
82
  return this.#client[kDispatch](opts, handler)
81
83
  }
82
84
 
83
- async [kClose] () {
85
+ [kClose] () {
84
86
  return this.#client.close()
85
87
  }
86
88
 
87
- async [kDestroy] (err) {
89
+ [kDestroy] (err) {
88
90
  return this.#client.destroy(err)
89
91
  }
90
92
  }
@@ -149,7 +151,7 @@ class ProxyAgent extends DispatcherBase {
149
151
  requestedPath += `:${defaultProtocolPort(opts.protocol)}`
150
152
  }
151
153
  try {
152
- const { socket, statusCode } = await this[kClient].connect({
154
+ const connectParams = {
153
155
  origin,
154
156
  port,
155
157
  path: requestedPath,
@@ -160,11 +162,21 @@ class ProxyAgent extends DispatcherBase {
160
162
  ...(opts.connections == null || opts.connections > 0 ? { 'proxy-connection': 'keep-alive' } : {})
161
163
  },
162
164
  servername: this[kProxyTls]?.servername || proxyHostname
163
- })
165
+ }
166
+ const { socket, statusCode } = await this[kClient].connect(connectParams)
164
167
  if (statusCode !== 200) {
165
168
  socket.on('error', noop).destroy()
166
169
  callback(new RequestAbortedError(`Proxy response (${statusCode}) !== 200 when HTTP Tunneling`))
170
+ return
171
+ }
172
+
173
+ if (channels.proxyConnected.hasSubscribers) {
174
+ channels.proxyConnected.publish({
175
+ socket,
176
+ connectParams
177
+ })
167
178
  }
179
+
168
180
  if (opts.protocol !== 'https:') {
169
181
  callback(null, socket)
170
182
  return
@@ -220,14 +232,18 @@ class ProxyAgent extends DispatcherBase {
220
232
  }
221
233
  }
222
234
 
223
- async [kClose] () {
224
- await this[kAgent].close()
225
- await this[kClient].close()
235
+ [kClose] () {
236
+ return Promise.all([
237
+ this[kAgent].close(),
238
+ this[kClient].close()
239
+ ])
226
240
  }
227
241
 
228
- async [kDestroy] () {
229
- await this[kAgent].destroy()
230
- await this[kClient].destroy()
242
+ [kDestroy] () {
243
+ return Promise.all([
244
+ this[kAgent].destroy(),
245
+ this[kClient].destroy()
246
+ ])
231
247
  }
232
248
  }
233
249
 
@@ -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
+ }
package/lib/global.js CHANGED
@@ -26,7 +26,25 @@ function getGlobalDispatcher () {
26
26
  return globalThis[globalDispatcher]
27
27
  }
28
28
 
29
+ // These are the globals that can be installed by undici.install().
30
+ // Not exported by index.js to avoid use outside of this module.
31
+ const installedExports = /** @type {const} */ (
32
+ [
33
+ 'fetch',
34
+ 'Headers',
35
+ 'Response',
36
+ 'Request',
37
+ 'FormData',
38
+ 'WebSocket',
39
+ 'CloseEvent',
40
+ 'ErrorEvent',
41
+ 'MessageEvent',
42
+ 'EventSource'
43
+ ]
44
+ )
45
+
29
46
  module.exports = {
30
47
  setGlobalDispatcher,
31
- getGlobalDispatcher
48
+ getGlobalDispatcher,
49
+ installedExports
32
50
  }
@@ -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 and 304 unless the RFC is updated
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, 304
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
- if (typeof resHeaders.etag === 'string' && isEtagUsable(resHeaders.etag)) {
193
- value.etag = resHeaders.etag
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
- this.#writeStream = this.#store.createWriteStream(this.#cacheKey, value)
197
- if (!this.#writeStream) {
198
- return downstreamOnHeaders()
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
- const handler = this
202
- this.#writeStream
203
- .on('drain', () => controller.resume())
204
- .on('error', function () {
205
- // TODO (fix): Make error somehow observable?
206
- handler.#writeStream = undefined
207
-
208
- // Delete the value in case the cache store is holding onto state from
209
- // the call to createWriteStream
210
- handler.#store.delete(handler.#cacheKey)
211
- })
212
- .on('close', function () {
213
- if (handler.#writeStream === this) {
214
- handler.#writeStream = undefined
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
- // TODO (fix): Should we resume even if was paused downstream?
218
- controller.resume()
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
- return downstreamOnHeaders()
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) {