@nxtedition/nxt-undici 6.2.7 → 6.2.9
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/lib/interceptor/cache.js +98 -8
- package/lib/sqlite-cache-store.js +479 -0
- package/lib/utils.js +41 -31
- package/package.json +9 -9
package/lib/interceptor/cache.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import undici from '@nxtedition/undici'
|
|
2
|
-
import { DecoratorHandler, parseCacheControl } from '../utils.js'
|
|
2
|
+
import { DecoratorHandler, parseCacheControl, parseContentRange } from '../utils.js'
|
|
3
3
|
|
|
4
4
|
const DEFAULT_STORE = new undici.cacheStores.SqliteCacheStore({ location: ':memory:' })
|
|
5
5
|
const DEFAULT_MAX_ENTRY_SIZE = 128 * 1024
|
|
@@ -25,11 +25,14 @@ class CacheHandler extends DecoratorHandler {
|
|
|
25
25
|
onConnect(abort) {
|
|
26
26
|
this.#value = null
|
|
27
27
|
|
|
28
|
-
super.onConnect(
|
|
28
|
+
super.onConnect((reason) => {
|
|
29
|
+
// TODO (fix): Can we cache partial results?
|
|
30
|
+
abort(reason)
|
|
31
|
+
})
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
onHeaders(statusCode, headers, resume) {
|
|
32
|
-
if (statusCode !== 307 && statusCode !== 200) {
|
|
35
|
+
if (statusCode !== 307 && statusCode !== 200 && statusCode !== 206) {
|
|
33
36
|
return super.onHeaders(statusCode, headers, resume)
|
|
34
37
|
}
|
|
35
38
|
|
|
@@ -38,6 +41,16 @@ class CacheHandler extends DecoratorHandler {
|
|
|
38
41
|
return super.onHeaders(statusCode, headers, resume)
|
|
39
42
|
}
|
|
40
43
|
|
|
44
|
+
if (statusCode === 206 && !parseContentRange(headers['content-range'])) {
|
|
45
|
+
// We don't support caching range responses without content-range...
|
|
46
|
+
return super.onHeaders(statusCode, headers, resume)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (statusCode === 206) {
|
|
50
|
+
// TODO (fix): enable range requests
|
|
51
|
+
return super.onHeaders(statusCode, headers, resume)
|
|
52
|
+
}
|
|
53
|
+
|
|
41
54
|
const contentLength = headers['content-length'] ? Number(headers['content-length']) : Infinity
|
|
42
55
|
if (Number.isFinite(contentLength) && contentLength > DEFAULT_MAX_ENTRY_SIZE) {
|
|
43
56
|
// We don't support caching responses with body...
|
|
@@ -103,7 +116,7 @@ class CacheHandler extends DecoratorHandler {
|
|
|
103
116
|
statusMessage: '',
|
|
104
117
|
headers,
|
|
105
118
|
cacheControlDirectives,
|
|
106
|
-
etag:
|
|
119
|
+
etag: isEtagUsable(headers.etag) ? headers.etag : '',
|
|
107
120
|
vary,
|
|
108
121
|
cachedAt,
|
|
109
122
|
staleAt: 0,
|
|
@@ -129,6 +142,7 @@ class CacheHandler extends DecoratorHandler {
|
|
|
129
142
|
onComplete(trailers) {
|
|
130
143
|
if (this.#value && (!trailers || Object.keys(trailers).length === 0)) {
|
|
131
144
|
this.#store.set(this.#key, this.#value)
|
|
145
|
+
this.#value = null
|
|
132
146
|
}
|
|
133
147
|
|
|
134
148
|
super.onComplete(trailers)
|
|
@@ -161,11 +175,80 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
161
175
|
return dispatch(opts, handler)
|
|
162
176
|
}
|
|
163
177
|
|
|
164
|
-
// Dump body...
|
|
165
|
-
opts.body?.on('error', () => {}).resume()
|
|
166
|
-
|
|
167
178
|
const store = opts.cache.store ?? DEFAULT_STORE
|
|
179
|
+
|
|
180
|
+
// TODO (fix): enable range requests
|
|
168
181
|
const entry = store.get(opts)
|
|
182
|
+
|
|
183
|
+
// let entry
|
|
184
|
+
// if (opts.headers.range) {
|
|
185
|
+
// const range = parseRangeHeader(opts.headers.range)
|
|
186
|
+
// if (!range) {
|
|
187
|
+
// // Invalid range header...
|
|
188
|
+
// return dispatch(opts, handler)
|
|
189
|
+
// }
|
|
190
|
+
|
|
191
|
+
// // TODO (perf): This is not optimal as all range bodies will be loaded...
|
|
192
|
+
// // Make sure it only returns valid ranges by passing/parsing content range...
|
|
193
|
+
// const entries = store.getAll(opts)
|
|
194
|
+
|
|
195
|
+
// for (const x of entries) {
|
|
196
|
+
// const { statusCode, headers, body } = x
|
|
197
|
+
|
|
198
|
+
// if (!body) {
|
|
199
|
+
// continue
|
|
200
|
+
// }
|
|
201
|
+
|
|
202
|
+
// let contentRange
|
|
203
|
+
// if (statusCode === 200) {
|
|
204
|
+
// // TODO (fix): Implement this...
|
|
205
|
+
// // contentRange = { start: 0, end: body.byteLength }
|
|
206
|
+
// // x = {
|
|
207
|
+
// // ...x,
|
|
208
|
+
// // headers: {
|
|
209
|
+
// // ...x,
|
|
210
|
+
// // 'content-md5': undefined
|
|
211
|
+
// // // TODO (fix): What other headers need to be modified? accept-ranges? etag?
|
|
212
|
+
// // }
|
|
213
|
+
// // }
|
|
214
|
+
// } else if (statusCode === 206) {
|
|
215
|
+
// contentRange = parseContentRange(headers?.['content-range'])
|
|
216
|
+
// }
|
|
217
|
+
|
|
218
|
+
// if (!contentRange) {
|
|
219
|
+
// continue
|
|
220
|
+
// }
|
|
221
|
+
|
|
222
|
+
// if (contentRange.start === range.start && contentRange.end === range.end) {
|
|
223
|
+
// entry = x
|
|
224
|
+
// break
|
|
225
|
+
// }
|
|
226
|
+
|
|
227
|
+
// // TODO (fix): Implement this...
|
|
228
|
+
// // const start = 0
|
|
229
|
+
// // const end = contentRange.end - contentRange.start
|
|
230
|
+
// // x = {
|
|
231
|
+
// // ...x,
|
|
232
|
+
// // body: body.subarray(start, end),
|
|
233
|
+
// // headers: {
|
|
234
|
+
// // ...headers,
|
|
235
|
+
// // 'content-range': `bytes ${start}-${end - 1}/${contentRange.size ?? '*'}`
|
|
236
|
+
// // 'content-md5': undefined
|
|
237
|
+
// // // TODO (fix): What other headers need to be modified? etag?
|
|
238
|
+
// // }
|
|
239
|
+
// // }
|
|
240
|
+
// // TODO (fix): Pick best entry... i.e. what ever fullfills most of the range
|
|
241
|
+
// }
|
|
242
|
+
// } else {
|
|
243
|
+
// entry = store.get(opts)
|
|
244
|
+
|
|
245
|
+
// // TODO (fix): store.get is not optimal as it can return partial (206) responses.
|
|
246
|
+
// // Make sure it only returns valid statusCodes.
|
|
247
|
+
// if (entry?.statusCode === 206) {
|
|
248
|
+
// return dispatch(opts, handler)
|
|
249
|
+
// }
|
|
250
|
+
// }
|
|
251
|
+
|
|
169
252
|
if (!entry && !cacheControlDirectives['only-if-cached']) {
|
|
170
253
|
return dispatch(
|
|
171
254
|
opts,
|
|
@@ -189,6 +272,9 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
189
272
|
}
|
|
190
273
|
}
|
|
191
274
|
|
|
275
|
+
// Dump body...
|
|
276
|
+
opts.body?.on('error', () => {}).resume()
|
|
277
|
+
|
|
192
278
|
try {
|
|
193
279
|
handler.onConnect(abort)
|
|
194
280
|
if (aborted) {
|
|
@@ -219,10 +305,14 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
219
305
|
*
|
|
220
306
|
* @see https://www.rfc-editor.org/rfc/rfc9110.html#name-etag
|
|
221
307
|
*
|
|
222
|
-
* @param {string} etag
|
|
308
|
+
* @param {string|any} etag
|
|
223
309
|
* @returns {boolean}
|
|
224
310
|
*/
|
|
225
311
|
function isEtagUsable(etag) {
|
|
312
|
+
if (typeof etag !== 'string') {
|
|
313
|
+
return false
|
|
314
|
+
}
|
|
315
|
+
|
|
226
316
|
if (etag.length <= 2) {
|
|
227
317
|
// Shortest an etag can be is two chars (just ""). This is where we deviate
|
|
228
318
|
// from the spec requiring a min of 3 chars however
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
2
|
+
|
|
3
|
+
const VERSION = 4
|
|
4
|
+
|
|
5
|
+
// 2gb
|
|
6
|
+
const MAX_ENTRY_SIZE = 2 * 1000 * 1000 * 1000
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {import('undici-types/cache-interceptor.d.ts').default.CacheStore} CacheStore
|
|
10
|
+
* @implements {CacheStore}
|
|
11
|
+
*
|
|
12
|
+
* @typedef {{
|
|
13
|
+
* id: Readonly<number>,
|
|
14
|
+
* body?: Uint8Array
|
|
15
|
+
* statusCode: number
|
|
16
|
+
* statusMessage: string
|
|
17
|
+
* headers?: string
|
|
18
|
+
* vary?: string
|
|
19
|
+
* etag?: string
|
|
20
|
+
* cacheControlDirectives?: string
|
|
21
|
+
* cachedAt: number
|
|
22
|
+
* staleAt: number
|
|
23
|
+
* deleteAt: number
|
|
24
|
+
* }} SqliteStoreValue
|
|
25
|
+
*/
|
|
26
|
+
export class SqliteCacheStore {
|
|
27
|
+
#maxEntrySize = MAX_ENTRY_SIZE
|
|
28
|
+
#maxEntryCount = 16 * 1024
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @type {import('node:sqlite').DatabaseSync}
|
|
32
|
+
*/
|
|
33
|
+
#db
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @type {import('node:sqlite').StatementSync}
|
|
37
|
+
*/
|
|
38
|
+
#getValuesQuery
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @type {import('node:sqlite').StatementSync}
|
|
42
|
+
*/
|
|
43
|
+
#insertValueQuery
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @type {import('node:sqlite').StatementSync}
|
|
47
|
+
*/
|
|
48
|
+
#deleteExpiredValuesQuery
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @type {import('node:sqlite').StatementSync}
|
|
52
|
+
*/
|
|
53
|
+
#deleteByUrlQuery
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @type {import('node:sqlite').StatementSync}
|
|
57
|
+
*/
|
|
58
|
+
#countEntriesQuery
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @type {import('node:sqlite').StatementSync | null}
|
|
62
|
+
*/
|
|
63
|
+
#deleteOldValuesQuery
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {import('undici-types/cache-interceptor.d.ts').default.SqliteCacheStoreOpts & { maxEntryCount?: number} | undefined} opts
|
|
67
|
+
*/
|
|
68
|
+
constructor(opts) {
|
|
69
|
+
if (opts) {
|
|
70
|
+
if (typeof opts !== 'object') {
|
|
71
|
+
throw new TypeError('SqliteCacheStore options must be an object')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (opts.maxEntrySize !== undefined) {
|
|
75
|
+
if (
|
|
76
|
+
typeof opts.maxEntrySize !== 'number' ||
|
|
77
|
+
!Number.isInteger(opts.maxEntrySize) ||
|
|
78
|
+
opts.maxEntrySize < 0
|
|
79
|
+
) {
|
|
80
|
+
throw new TypeError(
|
|
81
|
+
'SqliteCacheStore options.maxEntrySize must be a non-negative integer',
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (opts.maxEntrySize > MAX_ENTRY_SIZE) {
|
|
86
|
+
throw new TypeError('SqliteCacheStore options.maxEntrySize must be less than 2gb')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
this.#maxEntrySize = opts.maxEntrySize
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const maxEntryCount = opts.maxEntryCount ?? opts.maxCount
|
|
93
|
+
if (maxEntryCount !== undefined) {
|
|
94
|
+
if (
|
|
95
|
+
typeof maxEntryCount !== 'number' ||
|
|
96
|
+
!Number.isInteger(maxEntryCount) ||
|
|
97
|
+
maxEntryCount < 0
|
|
98
|
+
) {
|
|
99
|
+
throw new TypeError(
|
|
100
|
+
'SqliteCacheStore options.maxEntryCount must be a non-negative integer',
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
this.#maxEntryCount = maxEntryCount
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
this.#db = new DatabaseSync(opts?.location ?? ':memory:')
|
|
108
|
+
|
|
109
|
+
this.#db.exec(`
|
|
110
|
+
CREATE TABLE IF NOT EXISTS cacheInterceptorV${VERSION} (
|
|
111
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
112
|
+
url TEXT NOT NULL,
|
|
113
|
+
method TEXT NOT NULL,
|
|
114
|
+
body BUF NULL,
|
|
115
|
+
deleteAt INTEGER NOT NULL,
|
|
116
|
+
statusCode INTEGER NOT NULL,
|
|
117
|
+
statusMessage TEXT NOT NULL,
|
|
118
|
+
headers TEXT NULL,
|
|
119
|
+
cacheControlDirectives TEXT NULL,
|
|
120
|
+
etag TEXT NULL,
|
|
121
|
+
vary TEXT NULL,
|
|
122
|
+
cachedAt INTEGER NOT NULL,
|
|
123
|
+
staleAt INTEGER NOT NULL
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_url ON cacheInterceptorV${VERSION}(url);
|
|
127
|
+
CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_method ON cacheInterceptorV${VERSION}(method);
|
|
128
|
+
CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_deleteAt ON cacheInterceptorV${VERSION}(deleteAt);
|
|
129
|
+
`)
|
|
130
|
+
|
|
131
|
+
this.#getValuesQuery = this.#db.prepare(`
|
|
132
|
+
SELECT
|
|
133
|
+
id,
|
|
134
|
+
body,
|
|
135
|
+
deleteAt,
|
|
136
|
+
statusCode,
|
|
137
|
+
statusMessage,
|
|
138
|
+
headers,
|
|
139
|
+
etag,
|
|
140
|
+
cacheControlDirectives,
|
|
141
|
+
vary,
|
|
142
|
+
cachedAt,
|
|
143
|
+
staleAt
|
|
144
|
+
FROM cacheInterceptorV${VERSION}
|
|
145
|
+
WHERE
|
|
146
|
+
url = ?
|
|
147
|
+
AND method = ?
|
|
148
|
+
ORDER BY
|
|
149
|
+
deleteAt ASC
|
|
150
|
+
`)
|
|
151
|
+
|
|
152
|
+
this.#insertValueQuery = this.#db.prepare(`
|
|
153
|
+
INSERT INTO cacheInterceptorV${VERSION} (
|
|
154
|
+
url,
|
|
155
|
+
method,
|
|
156
|
+
body,
|
|
157
|
+
deleteAt,
|
|
158
|
+
statusCode,
|
|
159
|
+
statusMessage,
|
|
160
|
+
headers,
|
|
161
|
+
etag,
|
|
162
|
+
cacheControlDirectives,
|
|
163
|
+
vary,
|
|
164
|
+
cachedAt,
|
|
165
|
+
staleAt
|
|
166
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
167
|
+
`)
|
|
168
|
+
|
|
169
|
+
this.#deleteByUrlQuery = this.#db.prepare(
|
|
170
|
+
`DELETE FROM cacheInterceptorV${VERSION} WHERE url = ?`,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
this.#countEntriesQuery = this.#db.prepare(
|
|
174
|
+
`SELECT COUNT(*) AS total FROM cacheInterceptorV${VERSION}`,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
this.#deleteExpiredValuesQuery = this.#db.prepare(
|
|
178
|
+
`DELETE FROM cacheInterceptorV${VERSION} WHERE deleteAt <= ?`,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
this.#deleteOldValuesQuery =
|
|
182
|
+
this.#maxEntryCount === Infinity
|
|
183
|
+
? null
|
|
184
|
+
: this.#db.prepare(`
|
|
185
|
+
DELETE FROM cacheInterceptorV${VERSION}
|
|
186
|
+
WHERE id IN (
|
|
187
|
+
SELECT
|
|
188
|
+
id
|
|
189
|
+
FROM cacheInterceptorV${VERSION}
|
|
190
|
+
ORDER BY cachedAt DESC
|
|
191
|
+
LIMIT ?
|
|
192
|
+
)
|
|
193
|
+
`)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
close() {
|
|
197
|
+
this.#db.close()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* @param {import('undici-types/cache-interceptor.d.ts').default.CacheKey} key
|
|
202
|
+
* @returns {(import('undici-types/cache-interceptor.d.ts').default.GetResult & { body?: Buffer }) | undefined}
|
|
203
|
+
*/
|
|
204
|
+
get(key) {
|
|
205
|
+
assertCacheKey(key)
|
|
206
|
+
|
|
207
|
+
const value = this.#findValue(key)
|
|
208
|
+
return value ? makeResult(value) : undefined
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* @param {import('undici-types/cache-interceptor.d.ts').default.CacheKey} key
|
|
213
|
+
* @returns {(import('undici-types/cache-interceptor.d.ts').default.GetResult & { body?: Buffer })[]}
|
|
214
|
+
*/
|
|
215
|
+
getAll(key) {
|
|
216
|
+
assertCacheKey(key)
|
|
217
|
+
|
|
218
|
+
return this.#findValues(key).map((value) => makeResult(value))
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* @param {import('undici-types/cache-interceptor.d.ts').default.CacheKey} key
|
|
223
|
+
* @param {import('undici-types/cache-interceptor.d.ts').default.CacheValue & { body: null | Buffer | Array<Buffer>}} value
|
|
224
|
+
*/
|
|
225
|
+
set(key, value) {
|
|
226
|
+
assertCacheKey(key)
|
|
227
|
+
assertCacheValue(value)
|
|
228
|
+
|
|
229
|
+
const body = Array.isArray(value.body) ? Buffer.concat(value.body) : value.body
|
|
230
|
+
if ((body?.byteLength ?? 0) > this.#maxEntrySize) {
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
this.#prune()
|
|
235
|
+
|
|
236
|
+
this.#insertValueQuery.run(
|
|
237
|
+
makeValueUrl(key),
|
|
238
|
+
key.method,
|
|
239
|
+
body,
|
|
240
|
+
value.deleteAt,
|
|
241
|
+
value.statusCode,
|
|
242
|
+
value.statusMessage,
|
|
243
|
+
value.headers ? JSON.stringify(value.headers) : null,
|
|
244
|
+
value.etag ? value.etag : null,
|
|
245
|
+
value.cacheControlDirectives ? JSON.stringify(value.cacheControlDirectives) : null,
|
|
246
|
+
value.vary ? JSON.stringify(value.vary) : null,
|
|
247
|
+
value.cachedAt,
|
|
248
|
+
value.staleAt,
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* @param {import('undici-types/cache-interceptor.d.ts').default.CacheKey} key
|
|
254
|
+
*/
|
|
255
|
+
delete(key) {
|
|
256
|
+
if (typeof key !== 'object') {
|
|
257
|
+
throw new TypeError(`expected key to be object, got ${typeof key}`)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
this.#deleteByUrlQuery.run(makeValueUrl(key))
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
#prune() {
|
|
264
|
+
if (this.size <= this.#maxEntryCount) {
|
|
265
|
+
return 0
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
{
|
|
269
|
+
const removed = this.#deleteExpiredValuesQuery.run(Date.now()).changes
|
|
270
|
+
if (removed) {
|
|
271
|
+
return removed
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
{
|
|
276
|
+
const removed = this.#deleteOldValuesQuery?.run(
|
|
277
|
+
Math.max(Math.floor(this.#maxEntryCount * 0.1), 1),
|
|
278
|
+
).changes
|
|
279
|
+
if (removed) {
|
|
280
|
+
return removed
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return 0
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Counts the number of rows in the cache
|
|
289
|
+
* @returns {Number}
|
|
290
|
+
*/
|
|
291
|
+
get size() {
|
|
292
|
+
const { total } = this.#countEntriesQuery.get()
|
|
293
|
+
return total
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* @param {import('undici-types/cache-interceptor.d.ts').default.CacheKey} key
|
|
298
|
+
* @param {boolean} [canBeExpired=false]
|
|
299
|
+
* @returns {SqliteStoreValue | undefined}
|
|
300
|
+
*/
|
|
301
|
+
#findValue(key, canBeExpired = false) {
|
|
302
|
+
const { headers, method } = key
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* @type {SqliteStoreValue[]}
|
|
306
|
+
*/
|
|
307
|
+
const values = this.#getValuesQuery.all(makeValueUrl(key), method)
|
|
308
|
+
|
|
309
|
+
if (values.length === 0) {
|
|
310
|
+
return undefined
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const now = Date.now()
|
|
314
|
+
for (const value of values) {
|
|
315
|
+
if (now >= value.deleteAt && !canBeExpired) {
|
|
316
|
+
return undefined
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
let matches = true
|
|
320
|
+
|
|
321
|
+
if (value.vary) {
|
|
322
|
+
const vary = JSON.parse(value.vary)
|
|
323
|
+
|
|
324
|
+
for (const header in vary) {
|
|
325
|
+
if (!headerValueEquals(headers?.[header], vary[header])) {
|
|
326
|
+
matches = false
|
|
327
|
+
break
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (matches) {
|
|
333
|
+
return value
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return undefined
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* @param {import('undici-types/cache-interceptor.d.ts').default.CacheKey} key
|
|
342
|
+
* @param {boolean} [canBeExpired=false]
|
|
343
|
+
* @returns {SqliteStoreValue[]}
|
|
344
|
+
*/
|
|
345
|
+
#findValues(key, canBeExpired = false) {
|
|
346
|
+
const { headers, method } = key
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* @type {SqliteStoreValue[]}
|
|
350
|
+
*/
|
|
351
|
+
const values = this.#getValuesQuery.all(makeValueUrl(key), method)
|
|
352
|
+
const matches = []
|
|
353
|
+
const now = Date.now()
|
|
354
|
+
for (const value of values) {
|
|
355
|
+
if (now >= value.deleteAt && !canBeExpired) {
|
|
356
|
+
continue
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (value.vary) {
|
|
360
|
+
const vary = JSON.parse(value.vary)
|
|
361
|
+
|
|
362
|
+
for (const header in vary) {
|
|
363
|
+
if (!headerValueEquals(headers?.[header], vary[header])) {
|
|
364
|
+
break
|
|
365
|
+
}
|
|
366
|
+
matches.push(value)
|
|
367
|
+
}
|
|
368
|
+
} else {
|
|
369
|
+
matches.push(value)
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return matches
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* @param {string|string[]|null|undefined} lhs
|
|
379
|
+
* @param {string|string[]|null|undefined} rhs
|
|
380
|
+
* @returns {boolean}
|
|
381
|
+
*/
|
|
382
|
+
function headerValueEquals(lhs, rhs) {
|
|
383
|
+
if (lhs == null && rhs == null) {
|
|
384
|
+
return true
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if ((lhs == null && rhs != null) || (lhs != null && rhs == null)) {
|
|
388
|
+
return false
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (Array.isArray(lhs) && Array.isArray(rhs)) {
|
|
392
|
+
if (lhs.length !== rhs.length) {
|
|
393
|
+
return false
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return lhs.every((x, i) => x === rhs[i])
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return lhs === rhs
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* @param {import('undici-types/cache-interceptor.d.ts').default.CacheKey} key
|
|
404
|
+
* @returns {string}
|
|
405
|
+
*/
|
|
406
|
+
function makeValueUrl(key) {
|
|
407
|
+
return `${key.origin}/${key.path}`
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function makeResult(value) {
|
|
411
|
+
return {
|
|
412
|
+
body: value.body
|
|
413
|
+
? Buffer.from(value.body.buffer, value.body.byteOffset, value.body.byteLength)
|
|
414
|
+
: undefined,
|
|
415
|
+
statusCode: value.statusCode,
|
|
416
|
+
statusMessage: value.statusMessage,
|
|
417
|
+
headers: value.headers ? JSON.parse(value.headers) : undefined,
|
|
418
|
+
etag: value.etag ? value.etag : undefined,
|
|
419
|
+
vary: value.vary ? JSON.parse(value.vary) : undefined,
|
|
420
|
+
cacheControlDirectives: value.cacheControlDirectives
|
|
421
|
+
? JSON.parse(value.cacheControlDirectives)
|
|
422
|
+
: undefined,
|
|
423
|
+
cachedAt: value.cachedAt,
|
|
424
|
+
staleAt: value.staleAt,
|
|
425
|
+
deleteAt: value.deleteAt,
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* @param {any} key
|
|
431
|
+
*/
|
|
432
|
+
function assertCacheKey(key) {
|
|
433
|
+
if (typeof key !== 'object') {
|
|
434
|
+
throw new TypeError(`expected key to be object, got ${typeof key}`)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
for (const property of ['origin', 'method', 'path']) {
|
|
438
|
+
if (typeof key[property] !== 'string') {
|
|
439
|
+
throw new TypeError(`expected key.${property} to be string, got ${typeof key[property]}`)
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (key.headers !== undefined && typeof key.headers !== 'object') {
|
|
444
|
+
throw new TypeError(`expected headers to be object, got ${typeof key}`)
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* @param {any} value
|
|
450
|
+
*/
|
|
451
|
+
function assertCacheValue(value) {
|
|
452
|
+
if (typeof value !== 'object') {
|
|
453
|
+
throw new TypeError(`expected value to be object, got ${typeof value}`)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
for (const property of ['statusCode', 'cachedAt', 'staleAt', 'deleteAt']) {
|
|
457
|
+
if (typeof value[property] !== 'number') {
|
|
458
|
+
throw new TypeError(`expected value.${property} to be number, got ${typeof value[property]}`)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (typeof value.statusMessage !== 'string') {
|
|
463
|
+
throw new TypeError(
|
|
464
|
+
`expected value.statusMessage to be string, got ${typeof value.statusMessage}`,
|
|
465
|
+
)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (value.headers != null && typeof value.headers !== 'object') {
|
|
469
|
+
throw new TypeError(`expected value.rawHeaders to be object, got ${typeof value.headers}`)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (value.vary !== undefined && typeof value.vary !== 'object') {
|
|
473
|
+
throw new TypeError(`expected value.vary to be object, got ${typeof value.vary}`)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (value.etag !== undefined && typeof value.etag !== 'string') {
|
|
477
|
+
throw new TypeError(`expected value.etag to be string, got ${typeof value.etag}`)
|
|
478
|
+
}
|
|
479
|
+
}
|
package/lib/utils.js
CHANGED
|
@@ -17,23 +17,6 @@ export function parseCacheControl(str) {
|
|
|
17
17
|
return str ? cacheControlParser.parse(str) : null
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
// Parsed accordingly to RFC 9110
|
|
21
|
-
// https://www.rfc-editor.org/rfc/rfc9110#field.content-range
|
|
22
|
-
export function parseRangeHeader(range) {
|
|
23
|
-
if (range == null || range === '') {
|
|
24
|
-
return { start: 0, end: null, size: null }
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const m = range ? range.match(/^bytes (\d+)-(\d+)\/(\d+)?$/) : null
|
|
28
|
-
return m
|
|
29
|
-
? {
|
|
30
|
-
start: parseInt(m[1]),
|
|
31
|
-
end: m[2] ? parseInt(m[2]) + 1 : null,
|
|
32
|
-
size: m[3] ? parseInt(m[3]) : null,
|
|
33
|
-
}
|
|
34
|
-
: null
|
|
35
|
-
}
|
|
36
|
-
|
|
37
20
|
export function isDisturbed(body) {
|
|
38
21
|
if (
|
|
39
22
|
body == null ||
|
|
@@ -51,32 +34,59 @@ export function isDisturbed(body) {
|
|
|
51
34
|
return stream.isDisturbed(body)
|
|
52
35
|
}
|
|
53
36
|
|
|
37
|
+
/**
|
|
38
|
+
* @typedef {object} RangeHeader
|
|
39
|
+
* @property {number} start
|
|
40
|
+
* @property {number | null} end
|
|
41
|
+
* @property {number | null} size
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} [range]
|
|
46
|
+
* @returns {RangeHeader|null|undefined}
|
|
47
|
+
*/
|
|
54
48
|
export function parseContentRange(range) {
|
|
55
|
-
if (
|
|
56
|
-
return
|
|
49
|
+
if (range == null || range === '') {
|
|
50
|
+
return undefined
|
|
57
51
|
}
|
|
58
52
|
|
|
59
|
-
|
|
60
|
-
if (!m) {
|
|
53
|
+
if (typeof range !== 'string') {
|
|
61
54
|
return null
|
|
62
55
|
}
|
|
63
56
|
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
57
|
+
const m = range.match(/^bytes (\d+)-(\d+)?\/(\d+|\*)$/)
|
|
58
|
+
return m
|
|
59
|
+
? {
|
|
60
|
+
start: parseInt(m[1], 10),
|
|
61
|
+
end: m[2] ? parseInt(m[2]) + 1 : null,
|
|
62
|
+
size: m[3] === '*' ? null : parseInt(m[3]),
|
|
63
|
+
}
|
|
64
|
+
: null
|
|
65
|
+
}
|
|
68
66
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
67
|
+
// Parsed accordingly to RFC 9110
|
|
68
|
+
// https://www.rfc-editor.org/rfc/rfc9110#field.content-range
|
|
69
|
+
/**
|
|
70
|
+
* @param {string} [range]
|
|
71
|
+
* @returns {RangeHeader|null|undefined}
|
|
72
|
+
*/
|
|
73
|
+
export function parseRangeHeader(range) {
|
|
74
|
+
if (range == null || range === '') {
|
|
75
|
+
return undefined
|
|
72
76
|
}
|
|
73
77
|
|
|
74
|
-
|
|
75
|
-
if (size !== null && !Number.isFinite(size)) {
|
|
78
|
+
if (typeof range !== 'string') {
|
|
76
79
|
return null
|
|
77
80
|
}
|
|
78
81
|
|
|
79
|
-
|
|
82
|
+
const m = range.match(/^bytes (\d+)-(\d+)\/(\d+)?$/)
|
|
83
|
+
return m
|
|
84
|
+
? {
|
|
85
|
+
start: parseInt(m[1]),
|
|
86
|
+
end: m[2] ? parseInt(m[2]) + 1 : null,
|
|
87
|
+
size: m[3] ? parseInt(m[3]) : null,
|
|
88
|
+
}
|
|
89
|
+
: null
|
|
80
90
|
}
|
|
81
91
|
|
|
82
92
|
export function parseURL(url) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nxtedition/nxt-undici",
|
|
3
|
-
"version": "6.2.
|
|
3
|
+
"version": "6.2.9",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Robert Nagy <robert.nagy@boffins.se>",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -9,21 +9,21 @@
|
|
|
9
9
|
"lib/*"
|
|
10
10
|
],
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@nxtedition/undici": "^
|
|
12
|
+
"@nxtedition/undici": "^11.0.0",
|
|
13
13
|
"cache-control-parser": "^2.0.6",
|
|
14
14
|
"http-errors": "^2.0.0"
|
|
15
15
|
},
|
|
16
16
|
"devDependencies": {
|
|
17
|
-
"@types/node": "^22.10
|
|
18
|
-
"eslint": "^9.
|
|
19
|
-
"eslint-plugin-n": "^17.
|
|
17
|
+
"@types/node": "^22.13.10",
|
|
18
|
+
"eslint": "^9.22.0",
|
|
19
|
+
"eslint-plugin-n": "^17.16.2",
|
|
20
20
|
"husky": "^9.1.7",
|
|
21
|
-
"lint-staged": "^15.
|
|
21
|
+
"lint-staged": "^15.5.0",
|
|
22
22
|
"pinst": "^3.0.0",
|
|
23
|
-
"prettier": "^3.
|
|
23
|
+
"prettier": "^3.5.3",
|
|
24
24
|
"send": "^1.1.0",
|
|
25
|
-
"tap": "^21.0
|
|
26
|
-
"undici-types": "^7.
|
|
25
|
+
"tap": "^21.1.0",
|
|
26
|
+
"undici-types": "^7.5.0"
|
|
27
27
|
},
|
|
28
28
|
"scripts": {
|
|
29
29
|
"prepare": "husky",
|