@nxtedition/nxt-undici 6.2.9 → 6.2.10

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/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import undici from '@nxtedition/undici'
2
2
  import { parseHeaders } from './utils.js'
3
3
  import { request as _request } from './request.js'
4
+ import { SqliteCacheStore } from './sqlite-cache-store.js'
4
5
 
5
6
  const dispatcherCache = new WeakMap()
6
7
 
@@ -19,7 +20,7 @@ export const interceptors = {
19
20
  }
20
21
 
21
22
  export const cache = {
22
- SqliteCacheStore: undici.cacheStores.SqliteCacheStore,
23
+ SqliteCacheStore,
23
24
  }
24
25
 
25
26
  export { parseHeaders } from './utils.js'
@@ -1,7 +1,8 @@
1
1
  import undici from '@nxtedition/undici'
2
2
  import { DecoratorHandler, parseCacheControl, parseContentRange } from '../utils.js'
3
+ import { SqliteCacheStore, assertCacheKey } from '../sqlite-cache-store.js'
3
4
 
4
- const DEFAULT_STORE = new undici.cacheStores.SqliteCacheStore({ location: ':memory:' })
5
+ const DEFAULT_STORE = new SqliteCacheStore({ location: ':memory:' })
5
6
  const DEFAULT_MAX_ENTRY_SIZE = 128 * 1024
6
7
  const NOOP = () => {}
7
8
 
@@ -12,7 +13,7 @@ class CacheHandler extends DecoratorHandler {
12
13
  #maxEntrySize
13
14
 
14
15
  constructor(key, { store, handler, maxEntrySize }) {
15
- undici.util.cache.assertCacheKey(key)
16
+ assertCacheKey(key)
16
17
 
17
18
  super(handler)
18
19
 
@@ -26,7 +27,7 @@ class CacheHandler extends DecoratorHandler {
26
27
  this.#value = null
27
28
 
28
29
  super.onConnect((reason) => {
29
- // TODO (fix): Can we cache partial results?
30
+ // TODO (fix): Cache partial results?
30
31
  abort(reason)
31
32
  })
32
33
  }
@@ -41,19 +42,26 @@ class CacheHandler extends DecoratorHandler {
41
42
  return super.onHeaders(statusCode, headers, resume)
42
43
  }
43
44
 
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)
45
+ let contentRange
46
+ if (headers['content-range']) {
47
+ contentRange = parseContentRange(headers['content-range'])
48
+ if (contentRange === null) {
49
+ // We don't support caching responses with invalid content-range...
50
+ return super.onHeaders(statusCode, headers, resume)
51
+ }
47
52
  }
48
53
 
49
- if (statusCode === 206) {
50
- // TODO (fix): enable range requests
51
- return super.onHeaders(statusCode, headers, resume)
54
+ let contentLength
55
+ if (headers['content-length']) {
56
+ contentLength = Number(headers['content-length'])
57
+ if (!Number.isFinite(contentLength) || contentLength <= 0) {
58
+ // We don't support caching responses with invalid content-length...
59
+ return super.onHeaders(statusCode, headers, resume)
60
+ }
52
61
  }
53
62
 
54
- const contentLength = headers['content-length'] ? Number(headers['content-length']) : Infinity
55
- if (Number.isFinite(contentLength) && contentLength > DEFAULT_MAX_ENTRY_SIZE) {
56
- // We don't support caching responses with body...
63
+ if (statusCode === 206 && !contentRange) {
64
+ // We don't support caching range responses without content-range...
57
65
  return super.onHeaders(statusCode, headers, resume)
58
66
  }
59
67
 
@@ -107,19 +115,27 @@ class CacheHandler extends DecoratorHandler {
107
115
  return super.onHeaders(statusCode, headers, resume)
108
116
  }
109
117
 
110
- const cachedAt = Date.now()
111
- this.#value = {
112
- body: [],
113
- size: 0,
114
- deleteAt: cachedAt + ttl * 1e3,
115
- statusCode,
116
- statusMessage: '',
117
- headers,
118
- cacheControlDirectives,
119
- etag: isEtagUsable(headers.etag) ? headers.etag : '',
120
- vary,
121
- cachedAt,
122
- staleAt: 0,
118
+ const start = contentRange ? contentRange.start : 0
119
+ const end = contentRange ? contentRange.end : contentLength
120
+
121
+ if (end == null || end - start < this.#maxEntrySize) {
122
+ const cachedAt = Date.now()
123
+ this.#value = {
124
+ body: [],
125
+ start,
126
+ end,
127
+ deleteAt: cachedAt + ttl * 1e3,
128
+ statusCode,
129
+ statusMessage: '',
130
+ headers,
131
+ cacheControlDirectives,
132
+ etag: isEtagUsable(headers.etag) ? headers.etag : '',
133
+ vary,
134
+ cachedAt,
135
+ staleAt: 0,
136
+ // Handler state.
137
+ size: 0,
138
+ }
123
139
  }
124
140
 
125
141
  return super.onHeaders(statusCode, headers, resume)
@@ -141,6 +157,7 @@ class CacheHandler extends DecoratorHandler {
141
157
 
142
158
  onComplete(trailers) {
143
159
  if (this.#value && (!trailers || Object.keys(trailers).length === 0)) {
160
+ this.#value.end ??= this.#value.start + this.#value.size
144
161
  this.#store.set(this.#key, this.#value)
145
162
  this.#value = null
146
163
  }
@@ -178,76 +195,8 @@ export default () => (dispatch) => (opts, handler) => {
178
195
  const store = opts.cache.store ?? DEFAULT_STORE
179
196
 
180
197
  // TODO (fix): enable range requests
181
- const entry = store.get(opts)
182
198
 
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
- // }
199
+ const entry = store.get(opts)
251
200
 
252
201
  if (!entry && !cacheControlDirectives['only-if-cached']) {
253
202
  return dispatch(
@@ -1,6 +1,8 @@
1
1
  import { DatabaseSync } from 'node:sqlite'
2
+ import assert from 'node:assert'
3
+ import { parseRangeHeader } from './utils.js'
2
4
 
3
- const VERSION = 4
5
+ const VERSION = 5
4
6
 
5
7
  // 2gb
6
8
  const MAX_ENTRY_SIZE = 2 * 1000 * 1000 * 1000
@@ -12,6 +14,8 @@ const MAX_ENTRY_SIZE = 2 * 1000 * 1000 * 1000
12
14
  * @typedef {{
13
15
  * id: Readonly<number>,
14
16
  * body?: Uint8Array
17
+ * start: number
18
+ * end: number
15
19
  * statusCode: number
16
20
  * statusMessage: string
17
21
  * headers?: string
@@ -112,6 +116,8 @@ export class SqliteCacheStore {
112
116
  url TEXT NOT NULL,
113
117
  method TEXT NOT NULL,
114
118
  body BUF NULL,
119
+ start INTEGER NOT NULL,
120
+ end INTEGER NOT NULL,
115
121
  deleteAt INTEGER NOT NULL,
116
122
  statusCode INTEGER NOT NULL,
117
123
  statusMessage TEXT NOT NULL,
@@ -126,6 +132,8 @@ export class SqliteCacheStore {
126
132
  CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_url ON cacheInterceptorV${VERSION}(url);
127
133
  CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_method ON cacheInterceptorV${VERSION}(method);
128
134
  CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_deleteAt ON cacheInterceptorV${VERSION}(deleteAt);
135
+ CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_start ON cacheInterceptorV${VERSION}(start);
136
+ CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_end ON cacheInterceptorV${VERSION}(end);
129
137
  `)
130
138
 
131
139
  this.#getValuesQuery = this.#db.prepare(`
@@ -145,6 +153,8 @@ export class SqliteCacheStore {
145
153
  WHERE
146
154
  url = ?
147
155
  AND method = ?
156
+ AND start <= ?
157
+ AND end >= ?
148
158
  ORDER BY
149
159
  deleteAt ASC
150
160
  `)
@@ -154,6 +164,8 @@ export class SqliteCacheStore {
154
164
  url,
155
165
  method,
156
166
  body,
167
+ start,
168
+ end,
157
169
  deleteAt,
158
170
  statusCode,
159
171
  statusMessage,
@@ -163,7 +175,7 @@ export class SqliteCacheStore {
163
175
  vary,
164
176
  cachedAt,
165
177
  staleAt
166
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
178
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
167
179
  `)
168
180
 
169
181
  this.#deleteByUrlQuery = this.#db.prepare(
@@ -210,17 +222,7 @@ export class SqliteCacheStore {
210
222
 
211
223
  /**
212
224
  * @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
225
+ * @param {import('undici-types/cache-interceptor.d.ts').default.CacheValue & { body: null | Buffer | Array<Buffer>, start: number, end: number }} value
224
226
  */
225
227
  set(key, value) {
226
228
  assertCacheKey(key)
@@ -231,12 +233,18 @@ export class SqliteCacheStore {
231
233
  return
232
234
  }
233
235
 
236
+ assert(Number.isFinite(value.start))
237
+ assert(Number.isFinite(value.end))
238
+ assert(!body || body?.byteLength === value.end - value.start)
239
+
234
240
  this.#prune()
235
241
 
236
242
  this.#insertValueQuery.run(
237
243
  makeValueUrl(key),
238
244
  key.method,
239
245
  body,
246
+ value.start,
247
+ value.end,
240
248
  value.deleteAt,
241
249
  value.statusCode,
242
250
  value.statusMessage,
@@ -301,10 +309,21 @@ export class SqliteCacheStore {
301
309
  #findValue(key, canBeExpired = false) {
302
310
  const { headers, method } = key
303
311
 
312
+ if (Array.isArray(headers?.range)) {
313
+ return undefined
314
+ }
315
+
316
+ const range = parseRangeHeader(headers?.range)
317
+
304
318
  /**
305
319
  * @type {SqliteStoreValue[]}
306
320
  */
307
- const values = this.#getValuesQuery.all(makeValueUrl(key), method)
321
+ const values = this.#getValuesQuery.all(
322
+ makeValueUrl(key),
323
+ method,
324
+ range?.start ?? 0,
325
+ range?.end ?? Number.MAX_SAFE_INTEGER,
326
+ )
308
327
 
309
328
  if (values.length === 0) {
310
329
  return undefined
@@ -318,6 +337,11 @@ export class SqliteCacheStore {
318
337
 
319
338
  let matches = true
320
339
 
340
+ // TODO (fix): Allow full and partial match?
341
+ if (range && (range.start !== value.start || range.end !== value.end)) {
342
+ continue
343
+ }
344
+
321
345
  if (value.vary) {
322
346
  const vary = JSON.parse(value.vary)
323
347
 
@@ -336,42 +360,6 @@ export class SqliteCacheStore {
336
360
 
337
361
  return undefined
338
362
  }
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
363
  }
376
364
 
377
365
  /**
@@ -429,7 +417,7 @@ function makeResult(value) {
429
417
  /**
430
418
  * @param {any} key
431
419
  */
432
- function assertCacheKey(key) {
420
+ export function assertCacheKey(key) {
433
421
  if (typeof key !== 'object') {
434
422
  throw new TypeError(`expected key to be object, got ${typeof key}`)
435
423
  }
@@ -448,7 +436,7 @@ function assertCacheKey(key) {
448
436
  /**
449
437
  * @param {any} value
450
438
  */
451
- function assertCacheValue(value) {
439
+ export function assertCacheValue(value) {
452
440
  if (typeof value !== 'object') {
453
441
  throw new TypeError(`expected value to be object, got ${typeof value}`)
454
442
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxtedition/nxt-undici",
3
- "version": "6.2.9",
3
+ "version": "6.2.10",
4
4
  "license": "MIT",
5
5
  "author": "Robert Nagy <robert.nagy@boffins.se>",
6
6
  "main": "lib/index.js",