undici 7.0.0-alpha.2 → 7.0.0-alpha.4

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 (56) hide show
  1. package/README.md +3 -2
  2. package/docs/docs/api/BalancedPool.md +1 -1
  3. package/docs/docs/api/CacheStore.md +100 -0
  4. package/docs/docs/api/Dispatcher.md +32 -2
  5. package/docs/docs/api/MockClient.md +1 -1
  6. package/docs/docs/api/Pool.md +1 -1
  7. package/docs/docs/api/api-lifecycle.md +2 -2
  8. package/docs/docs/best-practices/mocking-request.md +2 -2
  9. package/docs/docs/best-practices/proxy.md +1 -1
  10. package/index.d.ts +1 -1
  11. package/index.js +8 -2
  12. package/lib/api/api-request.js +2 -2
  13. package/lib/api/readable.js +6 -6
  14. package/lib/cache/memory-cache-store.js +325 -0
  15. package/lib/core/connect.js +5 -0
  16. package/lib/core/constants.js +24 -1
  17. package/lib/core/request.js +2 -2
  18. package/lib/core/util.js +13 -1
  19. package/lib/dispatcher/client-h1.js +100 -87
  20. package/lib/dispatcher/client-h2.js +168 -96
  21. package/lib/dispatcher/pool-base.js +3 -3
  22. package/lib/handler/cache-handler.js +389 -0
  23. package/lib/handler/cache-revalidation-handler.js +151 -0
  24. package/lib/handler/redirect-handler.js +5 -3
  25. package/lib/handler/retry-handler.js +3 -3
  26. package/lib/interceptor/cache.js +192 -0
  27. package/lib/interceptor/dns.js +71 -48
  28. package/lib/util/cache.js +249 -0
  29. package/lib/web/cache/cache.js +1 -0
  30. package/lib/web/cache/cachestorage.js +2 -0
  31. package/lib/web/cookies/index.js +12 -1
  32. package/lib/web/cookies/parse.js +6 -1
  33. package/lib/web/eventsource/eventsource.js +2 -0
  34. package/lib/web/fetch/body.js +1 -5
  35. package/lib/web/fetch/constants.js +12 -5
  36. package/lib/web/fetch/data-url.js +2 -2
  37. package/lib/web/fetch/formdata-parser.js +70 -43
  38. package/lib/web/fetch/formdata.js +3 -1
  39. package/lib/web/fetch/headers.js +3 -1
  40. package/lib/web/fetch/index.js +4 -6
  41. package/lib/web/fetch/request.js +3 -1
  42. package/lib/web/fetch/response.js +3 -1
  43. package/lib/web/fetch/util.js +171 -47
  44. package/lib/web/fetch/webidl.js +28 -16
  45. package/lib/web/websocket/constants.js +67 -6
  46. package/lib/web/websocket/events.js +4 -0
  47. package/lib/web/websocket/stream/websocketerror.js +1 -1
  48. package/lib/web/websocket/websocket.js +2 -0
  49. package/package.json +8 -5
  50. package/types/cache-interceptor.d.ts +101 -0
  51. package/types/cookies.d.ts +2 -0
  52. package/types/dispatcher.d.ts +1 -1
  53. package/types/fetch.d.ts +9 -8
  54. package/types/index.d.ts +3 -1
  55. package/types/interceptors.d.ts +4 -1
  56. package/types/webidl.d.ts +7 -1
@@ -0,0 +1,192 @@
1
+ 'use strict'
2
+
3
+ const assert = require('node:assert')
4
+ const { Readable } = require('node:stream')
5
+ const util = require('../core/util')
6
+ const CacheHandler = require('../handler/cache-handler')
7
+ const MemoryCacheStore = require('../cache/memory-cache-store')
8
+ const CacheRevalidationHandler = require('../handler/cache-revalidation-handler')
9
+ const { assertCacheStore, assertCacheMethods, makeCacheKey } = require('../util/cache.js')
10
+
11
+ const AGE_HEADER = Buffer.from('age')
12
+
13
+ /**
14
+ * @typedef {import('../../types/cache-interceptor.d.ts').default.CachedResponse} CachedResponse
15
+ */
16
+
17
+ /**
18
+ * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions} [opts]
19
+ * @returns {import('../../types/dispatcher.d.ts').default.DispatcherComposeInterceptor}
20
+ */
21
+ module.exports = (opts = {}) => {
22
+ const {
23
+ store = new MemoryCacheStore(),
24
+ methods = ['GET']
25
+ } = opts
26
+
27
+ if (typeof opts !== 'object' || opts === null) {
28
+ throw new TypeError(`expected type of opts to be an Object, got ${opts === null ? 'null' : typeof opts}`)
29
+ }
30
+
31
+ assertCacheStore(store, 'opts.store')
32
+ assertCacheMethods(methods, 'opts.methods')
33
+
34
+ const globalOpts = {
35
+ store,
36
+ methods
37
+ }
38
+
39
+ const safeMethodsToNotCache = util.safeHTTPMethods.filter(method => methods.includes(method) === false)
40
+
41
+ return dispatch => {
42
+ return (opts, handler) => {
43
+ // TODO (fix): What if e.g. opts.headers has if-modified-since header? Or other headers
44
+ // that make things ambigious?
45
+
46
+ if (!opts.origin || safeMethodsToNotCache.includes(opts.method)) {
47
+ // Not a method we want to cache or we don't have the origin, skip
48
+ return dispatch(opts, handler)
49
+ }
50
+
51
+ /**
52
+ * @type {import('../../types/cache-interceptor.d.ts').default.CacheKey}
53
+ */
54
+ const cacheKey = makeCacheKey(opts)
55
+
56
+ // TODO (perf): For small entries support returning a Buffer instead of a stream.
57
+ // Maybe store should return { staleAt, headers, body, etc... } instead of a stream + stream.value?
58
+ // Where body can be a Buffer, string, stream or blob?
59
+ const result = store.get(cacheKey)
60
+ if (!result) {
61
+ return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler))
62
+ }
63
+
64
+ /**
65
+ * @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result
66
+ */
67
+ const respondWithCachedValue = ({ cachedAt, rawHeaders, statusCode, statusMessage, body }) => {
68
+ const stream = util.isStream(body)
69
+ ? body
70
+ : Readable.from(body ?? [])
71
+
72
+ assert(!stream.destroyed, 'stream should not be destroyed')
73
+ assert(!stream.readableDidRead, 'stream should not be readableDidRead')
74
+
75
+ stream
76
+ .on('error', function (err) {
77
+ if (!this.readableEnded) {
78
+ if (typeof handler.onError === 'function') {
79
+ handler.onError(err)
80
+ } else {
81
+ throw err
82
+ }
83
+ }
84
+ })
85
+ .on('close', function () {
86
+ if (!this.errored && typeof handler.onComplete === 'function') {
87
+ handler.onComplete([])
88
+ }
89
+ })
90
+
91
+ if (typeof handler.onConnect === 'function') {
92
+ handler.onConnect((err) => {
93
+ stream.destroy(err)
94
+ })
95
+
96
+ if (stream.destroyed) {
97
+ return
98
+ }
99
+ }
100
+
101
+ if (typeof handler.onHeaders === 'function') {
102
+ // Add the age header
103
+ // https://www.rfc-editor.org/rfc/rfc9111.html#name-age
104
+ const age = Math.round((Date.now() - cachedAt) / 1000)
105
+
106
+ // TODO (fix): What if rawHeaders already contains age header?
107
+ rawHeaders = [...rawHeaders, AGE_HEADER, Buffer.from(`${age}`)]
108
+
109
+ if (handler.onHeaders(statusCode, rawHeaders, () => stream?.resume(), statusMessage) === false) {
110
+ stream.pause()
111
+ }
112
+ }
113
+
114
+ if (opts.method === 'HEAD') {
115
+ stream.destroy()
116
+ } else {
117
+ stream.on('data', function (chunk) {
118
+ if (typeof handler.onData === 'function' && !handler.onData(chunk)) {
119
+ stream.pause()
120
+ }
121
+ })
122
+ }
123
+ }
124
+
125
+ /**
126
+ * @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result
127
+ */
128
+ const handleResult = (result) => {
129
+ // TODO (perf): Readable.from path can be optimized...
130
+
131
+ if (!result.body && opts.method !== 'HEAD') {
132
+ throw new Error('stream is undefined but method isn\'t HEAD')
133
+ }
134
+
135
+ // Check if the response is stale
136
+ const now = Date.now()
137
+ if (now < result.staleAt) {
138
+ // Dump request body.
139
+ if (util.isStream(opts.body)) {
140
+ opts.body.on('error', () => {}).destroy()
141
+ }
142
+ respondWithCachedValue(result)
143
+ } else if (util.isStream(opts.body) && util.bodyLength(opts.body) !== 0) {
144
+ // If body is is stream we can't revalidate...
145
+ // TODO (fix): This could be less strict...
146
+ dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler))
147
+ } else {
148
+ // Need to revalidate the response
149
+ dispatch(
150
+ {
151
+ ...opts,
152
+ headers: {
153
+ ...opts.headers,
154
+ 'if-modified-since': new Date(result.cachedAt).toUTCString()
155
+ }
156
+ },
157
+ new CacheRevalidationHandler(
158
+ (success) => {
159
+ if (success) {
160
+ respondWithCachedValue(result)
161
+ } else if (util.isStream(result.body)) {
162
+ result.body.on('error', () => {}).destroy()
163
+ }
164
+ },
165
+ new CacheHandler(globalOpts, cacheKey, handler)
166
+ )
167
+ )
168
+ }
169
+ }
170
+
171
+ if (typeof result.then === 'function') {
172
+ result.then((result) => {
173
+ if (!result) {
174
+ dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler))
175
+ } else {
176
+ handleResult(result)
177
+ }
178
+ }, err => {
179
+ if (typeof handler.onError === 'function') {
180
+ handler.onError(err)
181
+ } else {
182
+ throw err
183
+ }
184
+ })
185
+ } else {
186
+ handleResult(result)
187
+ }
188
+
189
+ return true
190
+ }
191
+ }
192
+ }
@@ -13,7 +13,6 @@ class DNSInstance {
13
13
  affinity = null
14
14
  lookup = null
15
15
  pick = null
16
- lastIpFamily = null
17
16
 
18
17
  constructor (opts) {
19
18
  this.#maxTTL = opts.maxTTL
@@ -61,16 +60,23 @@ class DNSInstance {
61
60
  const ip = this.pick(
62
61
  origin,
63
62
  records,
64
- // Only set affinity if dual stack is disabled
65
- // otherwise let it go through normal flow
66
- !newOpts.dualStack && newOpts.affinity
63
+ newOpts.affinity
67
64
  )
68
65
 
66
+ let port
67
+ if (typeof ip.port === 'number') {
68
+ port = `:${ip.port}`
69
+ } else if (origin.port !== '') {
70
+ port = `:${origin.port}`
71
+ } else {
72
+ port = ''
73
+ }
74
+
69
75
  cb(
70
76
  null,
71
77
  `${origin.protocol}//${
72
78
  ip.family === 6 ? `[${ip.address}]` : ip.address
73
- }${origin.port === '' ? '' : `:${origin.port}`}`
79
+ }${port}`
74
80
  )
75
81
  })
76
82
  } else {
@@ -78,9 +84,7 @@ class DNSInstance {
78
84
  const ip = this.pick(
79
85
  origin,
80
86
  ips,
81
- // Only set affinity if dual stack is disabled
82
- // otherwise let it go through normal flow
83
- !newOpts.dualStack && newOpts.affinity
87
+ newOpts.affinity
84
88
  )
85
89
 
86
90
  // If no IPs we lookup - deleting old records
@@ -90,11 +94,20 @@ class DNSInstance {
90
94
  return
91
95
  }
92
96
 
97
+ let port
98
+ if (typeof ip.port === 'number') {
99
+ port = `:${ip.port}`
100
+ } else if (origin.port !== '') {
101
+ port = `:${origin.port}`
102
+ } else {
103
+ port = ''
104
+ }
105
+
93
106
  cb(
94
107
  null,
95
108
  `${origin.protocol}//${
96
109
  ip.family === 6 ? `[${ip.address}]` : ip.address
97
- }${origin.port === '' ? '' : `:${origin.port}`}`
110
+ }${port}`
98
111
  )
99
112
  }
100
113
  }
@@ -102,7 +115,11 @@ class DNSInstance {
102
115
  #defaultLookup (origin, opts, cb) {
103
116
  lookup(
104
117
  origin.hostname,
105
- { all: true, family: this.dualStack === false ? this.affinity : 0 },
118
+ {
119
+ all: true,
120
+ family: this.dualStack === false ? this.affinity : 0,
121
+ order: 'ipv4first'
122
+ },
106
123
  (err, addresses) => {
107
124
  if (err) {
108
125
  return cb(err)
@@ -111,15 +128,9 @@ class DNSInstance {
111
128
  const results = new Map()
112
129
 
113
130
  for (const addr of addresses) {
114
- const record = {
115
- address: addr.address,
116
- ttl: opts.maxTTL,
117
- family: addr.family
118
- }
119
-
120
131
  // On linux we found duplicates, we attempt to remove them with
121
132
  // the latest record
122
- results.set(`${record.address}:${record.family}`, record)
133
+ results.set(`${addr.address}:${addr.family}`, addr)
123
134
  }
124
135
 
125
136
  cb(null, results.values())
@@ -129,36 +140,36 @@ class DNSInstance {
129
140
 
130
141
  #defaultPick (origin, hostnameRecords, affinity) {
131
142
  let ip = null
132
- const { records, offset = 0 } = hostnameRecords
133
- let newOffset = 0
143
+ const { records, offset } = hostnameRecords
144
+
145
+ let family
146
+ if (this.dualStack) {
147
+ if (affinity == null) {
148
+ // Balance between ip families
149
+ if (offset == null || offset === maxInt) {
150
+ hostnameRecords.offset = 0
151
+ affinity = 4
152
+ } else {
153
+ hostnameRecords.offset++
154
+ affinity = (hostnameRecords.offset & 1) === 1 ? 6 : 4
155
+ }
156
+ }
134
157
 
135
- if (offset === maxInt) {
136
- newOffset = 0
158
+ if (records[affinity] != null && records[affinity].ips.length > 0) {
159
+ family = records[affinity]
160
+ } else {
161
+ family = records[affinity === 4 ? 6 : 4]
162
+ }
137
163
  } else {
138
- newOffset = offset + 1
164
+ family = records[affinity]
139
165
  }
140
166
 
141
- // We balance between the two IP families
142
- // If dual-stack disabled, we automatically pick the affinity
143
- const newIpFamily = (newOffset & 1) === 1 ? 4 : 6
144
- const family =
145
- this.dualStack === false
146
- ? records[this.affinity] // If dual-stack is disabled, we pick the default affiniy
147
- : records[affinity] ?? records[newIpFamily]
148
-
149
- // If no IPs and we have tried both families or dual stack is disabled, we return null
150
- if (
151
- (family == null || family.ips.length === 0) &&
152
- // eslint-disable-next-line eqeqeq
153
- (this.dualStack === false || this.lastIpFamily != newIpFamily)
154
- ) {
167
+ // If no IPs we return null
168
+ if (family == null || family.ips.length === 0) {
155
169
  return ip
156
170
  }
157
171
 
158
- family.offset = family.offset ?? 0
159
- hostnameRecords.offset = newOffset
160
-
161
- if (family.offset === maxInt) {
172
+ if (family.offset == null || family.offset === maxInt) {
162
173
  family.offset = 0
163
174
  } else {
164
175
  family.offset++
@@ -171,24 +182,28 @@ class DNSInstance {
171
182
  return ip
172
183
  }
173
184
 
174
- const timestamp = Date.now()
175
- // Record TTL is already in ms
176
- if (ip.timestamp != null && timestamp - ip.timestamp > ip.ttl) {
185
+ if (Date.now() - ip.timestamp > ip.ttl) { // record TTL is already in ms
177
186
  // We delete expired records
178
187
  // It is possible that they have different TTL, so we manage them individually
179
188
  family.ips.splice(position, 1)
180
189
  return this.pick(origin, hostnameRecords, affinity)
181
190
  }
182
191
 
183
- ip.timestamp = timestamp
184
-
185
- this.lastIpFamily = newIpFamily
186
192
  return ip
187
193
  }
188
194
 
189
195
  setRecords (origin, addresses) {
196
+ const timestamp = Date.now()
190
197
  const records = { records: { 4: null, 6: null } }
191
198
  for (const record of addresses) {
199
+ record.timestamp = timestamp
200
+ if (typeof record.ttl === 'number') {
201
+ // The record TTL is expected to be in ms
202
+ record.ttl = Math.min(record.ttl, this.#maxTTL)
203
+ } else {
204
+ record.ttl = this.#maxTTL
205
+ }
206
+
192
207
  const familyRecords = records.records[record.family] ?? { ips: [] }
193
208
 
194
209
  familyRecords.ips.push(record)
@@ -302,12 +317,20 @@ module.exports = interceptorOpts => {
302
317
  throw new InvalidArgumentError('Invalid pick. Must be a function')
303
318
  }
304
319
 
320
+ const dualStack = interceptorOpts?.dualStack ?? true
321
+ let affinity
322
+ if (dualStack) {
323
+ affinity = interceptorOpts?.affinity ?? null
324
+ } else {
325
+ affinity = interceptorOpts?.affinity ?? 4
326
+ }
327
+
305
328
  const opts = {
306
329
  maxTTL: interceptorOpts?.maxTTL ?? 10e3, // Expressed in ms
307
330
  lookup: interceptorOpts?.lookup ?? null,
308
331
  pick: interceptorOpts?.pick ?? null,
309
- dualStack: interceptorOpts?.dualStack ?? true,
310
- affinity: interceptorOpts?.affinity ?? 4,
332
+ dualStack,
333
+ affinity,
311
334
  maxItems: interceptorOpts?.maxItems ?? Infinity
312
335
  }
313
336
 
@@ -0,0 +1,249 @@
1
+ 'use strict'
2
+
3
+ const {
4
+ safeHTTPMethods
5
+ } = require('../core/util')
6
+
7
+ /**
8
+ *
9
+ * @param {import('../../types/dispatcher.d.ts').default.DispatchOptions} opts
10
+ */
11
+ function makeCacheKey (opts) {
12
+ if (!opts.origin) {
13
+ throw new Error('opts.origin is undefined')
14
+ }
15
+
16
+ /**
17
+ * @type {import('../../types/cache-interceptor.d.ts').default.CacheKey}
18
+ */
19
+ const cacheKey = {
20
+ origin: opts.origin.toString(),
21
+ method: opts.method,
22
+ path: opts.path,
23
+ headers: opts.headers
24
+ }
25
+
26
+ return cacheKey
27
+ }
28
+
29
+ /**
30
+ * @see https://www.rfc-editor.org/rfc/rfc9111.html#name-cache-control
31
+ * @see https://www.iana.org/assignments/http-cache-directives/http-cache-directives.xhtml
32
+ *
33
+ * @typedef {{
34
+ * 'max-stale'?: number;
35
+ * 'min-fresh'?: number;
36
+ * 'max-age'?: number;
37
+ * 's-maxage'?: number;
38
+ * 'stale-while-revalidate'?: number;
39
+ * 'stale-if-error'?: number;
40
+ * public?: true;
41
+ * private?: true | string[];
42
+ * 'no-store'?: true;
43
+ * 'no-cache'?: true | string[];
44
+ * 'must-revalidate'?: true;
45
+ * 'proxy-revalidate'?: true;
46
+ * immutable?: true;
47
+ * 'no-transform'?: true;
48
+ * 'must-understand'?: true;
49
+ * 'only-if-cached'?: true;
50
+ * }} CacheControlDirectives
51
+ *
52
+ * @param {string | string[]} header
53
+ * @returns {CacheControlDirectives}
54
+ */
55
+ function parseCacheControlHeader (header) {
56
+ /**
57
+ * @type {import('../util/cache.js').CacheControlDirectives}
58
+ */
59
+ const output = {}
60
+
61
+ const directives = Array.isArray(header) ? header : header.split(',')
62
+ for (let i = 0; i < directives.length; i++) {
63
+ const directive = directives[i].toLowerCase()
64
+ const keyValueDelimiter = directive.indexOf('=')
65
+
66
+ let key
67
+ let value
68
+ if (keyValueDelimiter !== -1) {
69
+ key = directive.substring(0, keyValueDelimiter).trim()
70
+ value = directive
71
+ .substring(keyValueDelimiter + 1)
72
+ .trim()
73
+ } else {
74
+ key = directive.trim()
75
+ }
76
+
77
+ switch (key) {
78
+ case 'min-fresh':
79
+ case 'max-stale':
80
+ case 'max-age':
81
+ case 's-maxage':
82
+ case 'stale-while-revalidate':
83
+ case 'stale-if-error': {
84
+ if (value === undefined) {
85
+ continue
86
+ }
87
+
88
+ const parsedValue = parseInt(value, 10)
89
+ // eslint-disable-next-line no-self-compare
90
+ if (parsedValue !== parsedValue) {
91
+ continue
92
+ }
93
+
94
+ output[key] = parsedValue
95
+
96
+ break
97
+ }
98
+ case 'private':
99
+ case 'no-cache': {
100
+ if (value) {
101
+ // The private and no-cache directives can be unqualified (aka just
102
+ // `private` or `no-cache`) or qualified (w/ a value). When they're
103
+ // qualified, it's a list of headers like `no-cache=header1`,
104
+ // `no-cache="header1"`, or `no-cache="header1, header2"`
105
+ // If we're given multiple headers, the comma messes us up since
106
+ // we split the full header by commas. So, let's loop through the
107
+ // remaining parts in front of us until we find one that ends in a
108
+ // quote. We can then just splice all of the parts in between the
109
+ // starting quote and the ending quote out of the directives array
110
+ // and continue parsing like normal.
111
+ // https://www.rfc-editor.org/rfc/rfc9111.html#name-no-cache-2
112
+ if (value[0] === '"') {
113
+ // Something like `no-cache="some-header"` OR `no-cache="some-header, another-header"`.
114
+
115
+ // Add the first header on and cut off the leading quote
116
+ const headers = [value.substring(1)]
117
+
118
+ let foundEndingQuote = value[value.length - 1] === '"'
119
+ if (!foundEndingQuote) {
120
+ // Something like `no-cache="some-header, another-header"`
121
+ // This can still be something invalid, e.g. `no-cache="some-header, ...`
122
+ for (let j = i + 1; j < directives.length; j++) {
123
+ const nextPart = directives[j]
124
+ const nextPartLength = nextPart.length
125
+
126
+ headers.push(nextPart.trim())
127
+
128
+ if (nextPartLength !== 0 && nextPart[nextPartLength - 1] === '"') {
129
+ foundEndingQuote = true
130
+ break
131
+ }
132
+ }
133
+ }
134
+
135
+ if (foundEndingQuote) {
136
+ let lastHeader = headers[headers.length - 1]
137
+ if (lastHeader[lastHeader.length - 1] === '"') {
138
+ lastHeader = lastHeader.substring(0, lastHeader.length - 1)
139
+ headers[headers.length - 1] = lastHeader
140
+ }
141
+
142
+ output[key] = headers
143
+ }
144
+ } else {
145
+ // Something like `no-cache=some-header`
146
+ output[key] = [value]
147
+ }
148
+
149
+ break
150
+ }
151
+ }
152
+ // eslint-disable-next-line no-fallthrough
153
+ case 'public':
154
+ case 'no-store':
155
+ case 'must-revalidate':
156
+ case 'proxy-revalidate':
157
+ case 'immutable':
158
+ case 'no-transform':
159
+ case 'must-understand':
160
+ case 'only-if-cached':
161
+ if (value) {
162
+ // These are qualified (something like `public=...`) when they aren't
163
+ // allowed to be, skip
164
+ continue
165
+ }
166
+
167
+ output[key] = true
168
+ break
169
+ default:
170
+ // Ignore unknown directives as per https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.3-1
171
+ continue
172
+ }
173
+ }
174
+
175
+ return output
176
+ }
177
+
178
+ /**
179
+ * @param {string | string[]} varyHeader Vary header from the server
180
+ * @param {Record<string, string | string[]>} headers Request headers
181
+ * @returns {Record<string, string | string[]>}
182
+ */
183
+ function parseVaryHeader (varyHeader, headers) {
184
+ if (typeof varyHeader === 'string' && varyHeader === '*') {
185
+ return headers
186
+ }
187
+
188
+ const output = /** @type {Record<string, string | string[]>} */ ({})
189
+
190
+ const varyingHeaders = typeof varyHeader === 'string'
191
+ ? varyHeader.split(',')
192
+ : varyHeader
193
+ for (const header of varyingHeaders) {
194
+ const trimmedHeader = header.trim().toLowerCase()
195
+
196
+ if (headers[trimmedHeader]) {
197
+ output[trimmedHeader] = headers[trimmedHeader]
198
+ }
199
+ }
200
+
201
+ return output
202
+ }
203
+
204
+ /**
205
+ * @param {unknown} store
206
+ * @returns {asserts store is import('../../types/cache-interceptor.d.ts').default.CacheStore}
207
+ */
208
+ function assertCacheStore (store, name = 'CacheStore') {
209
+ if (typeof store !== 'object' || store === null) {
210
+ throw new TypeError(`expected type of ${name} to be a CacheStore, got ${store === null ? 'null' : typeof store}`)
211
+ }
212
+
213
+ for (const fn of ['get', 'createWriteStream', 'delete']) {
214
+ if (typeof store[fn] !== 'function') {
215
+ throw new TypeError(`${name} needs to have a \`${fn}()\` function`)
216
+ }
217
+ }
218
+
219
+ if (typeof store.isFull !== 'undefined' && typeof store.isFull !== 'boolean') {
220
+ throw new TypeError(`${name} needs a isFull getter with type boolean or undefined, current type: ${typeof store.isFull}`)
221
+ }
222
+ }
223
+ /**
224
+ * @param {unknown} methods
225
+ * @returns {asserts methods is import('../../types/cache-interceptor.d.ts').default.CacheMethods[]}
226
+ */
227
+ function assertCacheMethods (methods, name = 'CacheMethods') {
228
+ if (!Array.isArray(methods)) {
229
+ throw new TypeError(`expected type of ${name} needs to be an array, got ${methods === null ? 'null' : typeof methods}`)
230
+ }
231
+
232
+ if (methods.length === 0) {
233
+ throw new TypeError(`${name} needs to have at least one method`)
234
+ }
235
+
236
+ for (const method of methods) {
237
+ if (!safeHTTPMethods.includes(method)) {
238
+ throw new TypeError(`element of ${name}-array needs to be one of following values: ${safeHTTPMethods.join(', ')}, got ${method}`)
239
+ }
240
+ }
241
+ }
242
+
243
+ module.exports = {
244
+ makeCacheKey,
245
+ parseCacheControlHeader,
246
+ parseVaryHeader,
247
+ assertCacheMethods,
248
+ assertCacheStore
249
+ }
@@ -36,6 +36,7 @@ class Cache {
36
36
  webidl.illegalConstructor()
37
37
  }
38
38
 
39
+ webidl.util.markAsUncloneable(this)
39
40
  this.#relevantRequestResponseList = arguments[1]
40
41
  }
41
42
 
@@ -16,6 +16,8 @@ class CacheStorage {
16
16
  if (arguments[0] !== kConstruct) {
17
17
  webidl.illegalConstructor()
18
18
  }
19
+
20
+ webidl.util.markAsUncloneable(this)
19
21
  }
20
22
 
21
23
  async match (request, options = {}) {
@@ -91,6 +91,16 @@ function getSetCookies (headers) {
91
91
  return cookies.map((pair) => parseSetCookie(pair))
92
92
  }
93
93
 
94
+ /**
95
+ * Parses a cookie string
96
+ * @param {string} cookie
97
+ */
98
+ function parseCookie (cookie) {
99
+ cookie = webidl.converters.DOMString(cookie)
100
+
101
+ return parseSetCookie(cookie)
102
+ }
103
+
94
104
  /**
95
105
  * @param {Headers} headers
96
106
  * @param {Cookie} cookie
@@ -184,5 +194,6 @@ module.exports = {
184
194
  getCookies,
185
195
  deleteCookie,
186
196
  getSetCookies,
187
- setCookie
197
+ setCookie,
198
+ parseCookie
188
199
  }