@nxtedition/nxt-undici 6.2.9 → 6.2.11

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 = 6
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,7 @@ 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);
129
136
  `)
130
137
 
131
138
  this.#getValuesQuery = this.#db.prepare(`
@@ -145,6 +152,7 @@ export class SqliteCacheStore {
145
152
  WHERE
146
153
  url = ?
147
154
  AND method = ?
155
+ AND start <= ?
148
156
  ORDER BY
149
157
  deleteAt ASC
150
158
  `)
@@ -154,6 +162,8 @@ export class SqliteCacheStore {
154
162
  url,
155
163
  method,
156
164
  body,
165
+ start,
166
+ end,
157
167
  deleteAt,
158
168
  statusCode,
159
169
  statusMessage,
@@ -163,7 +173,7 @@ export class SqliteCacheStore {
163
173
  vary,
164
174
  cachedAt,
165
175
  staleAt
166
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
176
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
167
177
  `)
168
178
 
169
179
  this.#deleteByUrlQuery = this.#db.prepare(
@@ -210,17 +220,7 @@ export class SqliteCacheStore {
210
220
 
211
221
  /**
212
222
  * @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
223
+ * @param {import('undici-types/cache-interceptor.d.ts').default.CacheValue & { body: null | Buffer | Array<Buffer>, start: number, end: number }} value
224
224
  */
225
225
  set(key, value) {
226
226
  assertCacheKey(key)
@@ -231,12 +231,18 @@ export class SqliteCacheStore {
231
231
  return
232
232
  }
233
233
 
234
+ assert(Number.isFinite(value.start))
235
+ assert(Number.isFinite(value.end))
236
+ assert(!body || body?.byteLength === value.end - value.start)
237
+
234
238
  this.#prune()
235
239
 
236
240
  this.#insertValueQuery.run(
237
241
  makeValueUrl(key),
238
242
  key.method,
239
243
  body,
244
+ value.start,
245
+ value.end,
240
246
  value.deleteAt,
241
247
  value.statusCode,
242
248
  value.statusMessage,
@@ -301,10 +307,16 @@ export class SqliteCacheStore {
301
307
  #findValue(key, canBeExpired = false) {
302
308
  const { headers, method } = key
303
309
 
310
+ if (Array.isArray(headers?.range)) {
311
+ return undefined
312
+ }
313
+
314
+ const range = parseRangeHeader(headers?.range)
315
+
304
316
  /**
305
317
  * @type {SqliteStoreValue[]}
306
318
  */
307
- const values = this.#getValuesQuery.all(makeValueUrl(key), method)
319
+ const values = this.#getValuesQuery.all(makeValueUrl(key), method, range?.start ?? 0)
308
320
 
309
321
  if (values.length === 0) {
310
322
  return undefined
@@ -318,6 +330,11 @@ export class SqliteCacheStore {
318
330
 
319
331
  let matches = true
320
332
 
333
+ // TODO (fix): Allow full and partial match?
334
+ if (range && (range.start !== value.start || range.end !== value.end)) {
335
+ continue
336
+ }
337
+
321
338
  if (value.vary) {
322
339
  const vary = JSON.parse(value.vary)
323
340
 
@@ -336,42 +353,6 @@ export class SqliteCacheStore {
336
353
 
337
354
  return undefined
338
355
  }
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
356
  }
376
357
 
377
358
  /**
@@ -429,7 +410,7 @@ function makeResult(value) {
429
410
  /**
430
411
  * @param {any} key
431
412
  */
432
- function assertCacheKey(key) {
413
+ export function assertCacheKey(key) {
433
414
  if (typeof key !== 'object') {
434
415
  throw new TypeError(`expected key to be object, got ${typeof key}`)
435
416
  }
@@ -448,7 +429,7 @@ function assertCacheKey(key) {
448
429
  /**
449
430
  * @param {any} value
450
431
  */
451
- function assertCacheValue(value) {
432
+ export function assertCacheValue(value) {
452
433
  if (typeof value !== 'object') {
453
434
  throw new TypeError(`expected value to be object, got ${typeof value}`)
454
435
  }
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.11",
4
4
  "license": "MIT",
5
5
  "author": "Robert Nagy <robert.nagy@boffins.se>",
6
6
  "main": "lib/index.js",