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.
- package/README.md +3 -2
- package/docs/docs/api/BalancedPool.md +1 -1
- package/docs/docs/api/CacheStore.md +100 -0
- package/docs/docs/api/Dispatcher.md +32 -2
- package/docs/docs/api/MockClient.md +1 -1
- package/docs/docs/api/Pool.md +1 -1
- package/docs/docs/api/api-lifecycle.md +2 -2
- package/docs/docs/best-practices/mocking-request.md +2 -2
- package/docs/docs/best-practices/proxy.md +1 -1
- package/index.d.ts +1 -1
- package/index.js +8 -2
- package/lib/api/api-request.js +2 -2
- package/lib/api/readable.js +6 -6
- package/lib/cache/memory-cache-store.js +325 -0
- package/lib/core/connect.js +5 -0
- package/lib/core/constants.js +24 -1
- package/lib/core/request.js +2 -2
- package/lib/core/util.js +13 -1
- package/lib/dispatcher/client-h1.js +100 -87
- package/lib/dispatcher/client-h2.js +168 -96
- package/lib/dispatcher/pool-base.js +3 -3
- package/lib/handler/cache-handler.js +389 -0
- package/lib/handler/cache-revalidation-handler.js +151 -0
- package/lib/handler/redirect-handler.js +5 -3
- package/lib/handler/retry-handler.js +3 -3
- package/lib/interceptor/cache.js +192 -0
- package/lib/interceptor/dns.js +71 -48
- package/lib/util/cache.js +249 -0
- package/lib/web/cache/cache.js +1 -0
- package/lib/web/cache/cachestorage.js +2 -0
- package/lib/web/cookies/index.js +12 -1
- package/lib/web/cookies/parse.js +6 -1
- package/lib/web/eventsource/eventsource.js +2 -0
- package/lib/web/fetch/body.js +1 -5
- package/lib/web/fetch/constants.js +12 -5
- package/lib/web/fetch/data-url.js +2 -2
- package/lib/web/fetch/formdata-parser.js +70 -43
- package/lib/web/fetch/formdata.js +3 -1
- package/lib/web/fetch/headers.js +3 -1
- package/lib/web/fetch/index.js +4 -6
- package/lib/web/fetch/request.js +3 -1
- package/lib/web/fetch/response.js +3 -1
- package/lib/web/fetch/util.js +171 -47
- package/lib/web/fetch/webidl.js +28 -16
- package/lib/web/websocket/constants.js +67 -6
- package/lib/web/websocket/events.js +4 -0
- package/lib/web/websocket/stream/websocketerror.js +1 -1
- package/lib/web/websocket/websocket.js +2 -0
- package/package.json +8 -5
- package/types/cache-interceptor.d.ts +101 -0
- package/types/cookies.d.ts +2 -0
- package/types/dispatcher.d.ts +1 -1
- package/types/fetch.d.ts +9 -8
- package/types/index.d.ts +3 -1
- package/types/interceptors.d.ts +4 -1
- 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
|
+
}
|
package/lib/interceptor/dns.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
}${
|
|
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
|
-
|
|
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
|
-
}${
|
|
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
|
-
{
|
|
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(`${
|
|
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
|
|
133
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
164
|
+
family = records[affinity]
|
|
139
165
|
}
|
|
140
166
|
|
|
141
|
-
//
|
|
142
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
310
|
-
affinity
|
|
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
|
+
}
|
package/lib/web/cache/cache.js
CHANGED
package/lib/web/cookies/index.js
CHANGED
|
@@ -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
|
}
|