@nxtedition/nxt-undici 5.1.8 → 5.2.1

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.
@@ -0,0 +1,439 @@
1
+ import { assertCacheKey, assertCacheValue } from './util.js'
2
+
3
+ let DatabaseSync
4
+ try {
5
+ DatabaseSync = await import('node:sqlite').then((x) => x.DatabaseSync)
6
+ } catch {
7
+ // Do nothing...
8
+ }
9
+
10
+ const VERSION = 3
11
+
12
+ // 2gb
13
+ const MAX_ENTRY_SIZE = 2 * 1000 * 1000 * 1000
14
+
15
+ /**
16
+ * @typedef {import('../../types/cache-interceptor.d.ts').default.CacheStore} CacheStore
17
+ * @implements {CacheStore}
18
+ *
19
+ * @typedef {{
20
+ * id: Readonly<number>
21
+ * headers?: Record<string, string | string[]>
22
+ * vary?: string | object
23
+ * body: string
24
+ * } & import('../../types/cache-interceptor.d.ts').default.CacheValue} SqliteStoreValue
25
+ */
26
+ export class SqliteCacheStore {
27
+ #maxEntrySize = MAX_ENTRY_SIZE
28
+ #maxCount = Infinity
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
+ #updateValueQuery
44
+
45
+ /**
46
+ * @type {import('node:sqlite').StatementSync}
47
+ */
48
+ #insertValueQuery
49
+
50
+ /**
51
+ * @type {import('node:sqlite').StatementSync}
52
+ */
53
+ #deleteExpiredValuesQuery
54
+
55
+ /**
56
+ * @type {import('node:sqlite').StatementSync}
57
+ */
58
+ #deleteByUrlQuery
59
+
60
+ /**
61
+ * @type {import('node:sqlite').StatementSync}
62
+ */
63
+ #countEntriesQuery
64
+
65
+ /**
66
+ * @type {import('node:sqlite').StatementSync}
67
+ */
68
+ #deleteOldValuesQuery
69
+
70
+ /**
71
+ * @param {import('../../types/cache-interceptor.d.ts').default.SqliteCacheStoreOpts | undefined} opts
72
+ */
73
+ constructor(opts) {
74
+ if (!DatabaseSync) {
75
+ throw new Error('SqliteCacheStore requires node:sqlite')
76
+ }
77
+
78
+ if (opts) {
79
+ if (typeof opts !== 'object') {
80
+ throw new TypeError('SqliteCacheStore options must be an object')
81
+ }
82
+
83
+ if (opts.maxEntrySize !== undefined) {
84
+ if (
85
+ typeof opts.maxEntrySize !== 'number' ||
86
+ !Number.isInteger(opts.maxEntrySize) ||
87
+ opts.maxEntrySize < 0
88
+ ) {
89
+ throw new TypeError(
90
+ 'SqliteCacheStore options.maxEntrySize must be a non-negative integer',
91
+ )
92
+ }
93
+
94
+ if (opts.maxEntrySize > MAX_ENTRY_SIZE) {
95
+ throw new TypeError('SqliteCacheStore options.maxEntrySize must be less than 2gb')
96
+ }
97
+
98
+ this.#maxEntrySize = opts.maxEntrySize
99
+ }
100
+
101
+ if (opts.maxCount !== undefined) {
102
+ if (
103
+ typeof opts.maxCount !== 'number' ||
104
+ !Number.isInteger(opts.maxCount) ||
105
+ opts.maxCount < 0
106
+ ) {
107
+ throw new TypeError('SqliteCacheStore options.maxCount must be a non-negative integer')
108
+ }
109
+ this.#maxCount = opts.maxCount
110
+ }
111
+ }
112
+
113
+ this.#db = new DatabaseSync(opts?.location ?? ':memory:')
114
+
115
+ this.#db.exec(`
116
+ CREATE TABLE IF NOT EXISTS cacheInterceptorV${VERSION} (
117
+ -- Data specific to us
118
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
119
+ url TEXT NOT NULL,
120
+ method TEXT NOT NULL,
121
+
122
+ -- Data returned to the interceptor
123
+ body BUF NULL,
124
+ deleteAt INTEGER NOT NULL,
125
+ statusCode INTEGER NOT NULL,
126
+ statusMessage TEXT NOT NULL,
127
+ headers TEXT NULL,
128
+ cacheControlDirectives TEXT NULL,
129
+ etag TEXT NULL,
130
+ vary TEXT NULL,
131
+ cachedAt INTEGER NOT NULL,
132
+ staleAt INTEGER NOT NULL
133
+ );
134
+
135
+ CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_url ON cacheInterceptorV${VERSION}(url);
136
+ CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_method ON cacheInterceptorV${VERSION}(method);
137
+ CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_deleteAt ON cacheInterceptorV${VERSION}(deleteAt);
138
+ `)
139
+
140
+ this.#getValuesQuery = this.#db.prepare(`
141
+ SELECT
142
+ id,
143
+ body,
144
+ deleteAt,
145
+ statusCode,
146
+ statusMessage,
147
+ headers,
148
+ etag,
149
+ cacheControlDirectives,
150
+ vary,
151
+ cachedAt,
152
+ staleAt
153
+ FROM cacheInterceptorV${VERSION}
154
+ WHERE
155
+ url = ?
156
+ AND method = ?
157
+ ORDER BY
158
+ deleteAt ASC
159
+ `)
160
+
161
+ this.#updateValueQuery = this.#db.prepare(`
162
+ UPDATE cacheInterceptorV${VERSION} SET
163
+ body = ?,
164
+ deleteAt = ?,
165
+ statusCode = ?,
166
+ statusMessage = ?,
167
+ headers = ?,
168
+ etag = ?,
169
+ cacheControlDirectives = ?,
170
+ cachedAt = ?,
171
+ staleAt = ?,
172
+ deleteAt = ?
173
+ WHERE
174
+ id = ?
175
+ `)
176
+
177
+ this.#insertValueQuery = this.#db.prepare(`
178
+ INSERT INTO cacheInterceptorV${VERSION} (
179
+ url,
180
+ method,
181
+ body,
182
+ deleteAt,
183
+ statusCode,
184
+ statusMessage,
185
+ headers,
186
+ etag,
187
+ cacheControlDirectives,
188
+ vary,
189
+ cachedAt,
190
+ staleAt,
191
+ deleteAt
192
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
193
+ `)
194
+
195
+ this.#deleteByUrlQuery = this.#db.prepare(
196
+ `DELETE FROM cacheInterceptorV${VERSION} WHERE url = ?`,
197
+ )
198
+
199
+ this.#countEntriesQuery = this.#db.prepare(
200
+ `SELECT COUNT(*) AS total FROM cacheInterceptorV${VERSION}`,
201
+ )
202
+
203
+ this.#deleteExpiredValuesQuery = this.#db.prepare(
204
+ `DELETE FROM cacheInterceptorV${VERSION} WHERE deleteAt <= ?`,
205
+ )
206
+
207
+ this.#deleteOldValuesQuery =
208
+ this.#maxCount === Infinity
209
+ ? null
210
+ : this.#db.prepare(`
211
+ DELETE FROM cacheInterceptorV${VERSION}
212
+ WHERE id IN (
213
+ SELECT
214
+ id
215
+ FROM cacheInterceptorV${VERSION}
216
+ ORDER BY cachedAt DESC
217
+ LIMIT ?
218
+ )
219
+ `)
220
+ }
221
+
222
+ close() {
223
+ this.#db.close()
224
+ }
225
+
226
+ /**
227
+ * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
228
+ * @returns {import('../../types/cache-interceptor.d.ts').default.GetResult | undefined}
229
+ */
230
+ get(key) {
231
+ assertCacheKey(key)
232
+
233
+ const value = this.#findValue(key)
234
+
235
+ if (!value) {
236
+ return undefined
237
+ }
238
+
239
+ /**
240
+ * @type {import('../../types/cache-interceptor.d.ts').default.GetResult}
241
+ */
242
+ const result = {
243
+ body: Buffer.from(value.body),
244
+ statusCode: value.statusCode,
245
+ statusMessage: value.statusMessage,
246
+ headers: value.headers ? JSON.parse(value.headers) : undefined,
247
+ etag: value.etag ? value.etag : undefined,
248
+ vary: value.vary ?? undefined,
249
+ cacheControlDirectives: value.cacheControlDirectives
250
+ ? JSON.parse(value.cacheControlDirectives)
251
+ : undefined,
252
+ cachedAt: value.cachedAt,
253
+ staleAt: value.staleAt,
254
+ deleteAt: value.deleteAt,
255
+ }
256
+
257
+ return result
258
+ }
259
+
260
+ /**
261
+ * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
262
+ * @param {import('../../types/cache-interceptor.d.ts').default.CacheValue & { body: Buffer | null }} value
263
+ */
264
+ set(key, value) {
265
+ assertCacheKey(value)
266
+ assertCacheValue(value)
267
+
268
+ const body = value.body
269
+ const size = body ? body.byteLength : 0
270
+
271
+ if (size > this.#maxEntrySize) {
272
+ return false
273
+ }
274
+
275
+ const url = this.#makeValueUrl(key)
276
+
277
+ const existingValue = this.#findValue(key, true)
278
+ if (existingValue) {
279
+ // Updating an existing response, let's overwrite it
280
+ this.#updateValueQuery.run(
281
+ body,
282
+ value.deleteAt,
283
+ value.statusCode,
284
+ value.statusMessage,
285
+ value.headers ? JSON.stringify(value.headers) : null,
286
+ value.etag ? value.etag : null,
287
+ value.cacheControlDirectives ? JSON.stringify(value.cacheControlDirectives) : null,
288
+ value.cachedAt,
289
+ value.staleAt,
290
+ value.deleteAt,
291
+ existingValue.id,
292
+ )
293
+ } else {
294
+ this.#prune()
295
+ // New response, let's insert it
296
+ this.#insertValueQuery.run(
297
+ url,
298
+ key.method,
299
+ body,
300
+ value.deleteAt,
301
+ value.statusCode,
302
+ value.statusMessage,
303
+ value.headers ? JSON.stringify(value.headers) : null,
304
+ value.etag ? value.etag : null,
305
+ value.cacheControlDirectives ? JSON.stringify(value.cacheControlDirectives) : null,
306
+ value.vary ? JSON.stringify(value.vary) : null,
307
+ value.cachedAt,
308
+ value.staleAt,
309
+ value.deleteAt,
310
+ )
311
+ }
312
+
313
+ return true
314
+ }
315
+
316
+ /**
317
+ * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
318
+ */
319
+ delete(key) {
320
+ if (typeof key !== 'object') {
321
+ throw new TypeError(`expected key to be object, got ${typeof key}`)
322
+ }
323
+
324
+ this.#deleteByUrlQuery.run(this.#makeValueUrl(key))
325
+ }
326
+
327
+ #prune() {
328
+ if (this.size <= this.#maxCount) {
329
+ return 0
330
+ }
331
+
332
+ {
333
+ const removed = this.#deleteExpiredValuesQuery.run(Date.now()).changes
334
+ if (removed > 0) {
335
+ return removed
336
+ }
337
+ }
338
+
339
+ {
340
+ const removed = this.#deleteOldValuesQuery.run(
341
+ Math.max(Math.floor(this.#maxCount * 0.1), 1),
342
+ ).changes
343
+ if (removed > 0) {
344
+ return removed
345
+ }
346
+ }
347
+
348
+ return 0
349
+ }
350
+
351
+ /**
352
+ * Counts the number of rows in the cache
353
+ * @returns {Number}
354
+ */
355
+ get size() {
356
+ const { total } = this.#countEntriesQuery.get()
357
+ return total
358
+ }
359
+
360
+ /**
361
+ * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
362
+ * @returns {string}
363
+ */
364
+ #makeValueUrl(key) {
365
+ return `${key.origin}/${key.path}`
366
+ }
367
+
368
+ /**
369
+ * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
370
+ * @param {boolean} [canBeExpired=false]
371
+ * @returns {(SqliteStoreValue & { vary?: Record<string, string[]> }) | undefined}
372
+ */
373
+ #findValue(key, canBeExpired = false) {
374
+ const url = this.#makeValueUrl(key)
375
+ const { headers, method } = key
376
+
377
+ /**
378
+ * @type {SqliteStoreValue[]}
379
+ */
380
+ const values = this.#getValuesQuery.all(url, method)
381
+
382
+ if (values.length === 0) {
383
+ return undefined
384
+ }
385
+
386
+ const now = Date.now()
387
+ for (const value of values) {
388
+ if (now >= value.deleteAt && !canBeExpired) {
389
+ return undefined
390
+ }
391
+
392
+ let matches = true
393
+
394
+ if (value.vary) {
395
+ if (!headers) {
396
+ return undefined
397
+ }
398
+
399
+ value.vary = JSON.parse(value.vary)
400
+
401
+ for (const header in value.vary) {
402
+ if (!headerValueEquals(headers[header], value.vary[header])) {
403
+ matches = false
404
+ break
405
+ }
406
+ }
407
+ }
408
+
409
+ if (matches) {
410
+ return value
411
+ }
412
+ }
413
+
414
+ return undefined
415
+ }
416
+ }
417
+
418
+ /**
419
+ * @param {string|string[]|null|undefined} lhs
420
+ * @param {string|string[]|null|undefined} rhs
421
+ * @returns {boolean}
422
+ */
423
+ function headerValueEquals(lhs, rhs) {
424
+ if (Array.isArray(lhs) && Array.isArray(rhs)) {
425
+ if (lhs.length !== rhs.length) {
426
+ return false
427
+ }
428
+
429
+ for (let i = 0; i < lhs.length; i++) {
430
+ if (rhs.includes(lhs[i])) {
431
+ return false
432
+ }
433
+ }
434
+
435
+ return true
436
+ }
437
+
438
+ return lhs === rhs
439
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @param {any} key
3
+ */
4
+ export function assertCacheKey(key) {
5
+ if (typeof key !== 'object') {
6
+ throw new TypeError(`expected key to be object, got ${typeof key}`)
7
+ }
8
+
9
+ for (const property of ['origin', 'method', 'path']) {
10
+ if (typeof key[property] !== 'string') {
11
+ throw new TypeError(`expected key.${property} to be string, got ${typeof key[property]}`)
12
+ }
13
+ }
14
+
15
+ if (key.headers !== undefined && typeof key.headers !== 'object') {
16
+ throw new TypeError(`expected headers to be object, got ${typeof key}`)
17
+ }
18
+ }
19
+
20
+ /**
21
+ * @param {any} value
22
+ */
23
+ export function assertCacheValue(value) {
24
+ if (typeof value !== 'object') {
25
+ throw new TypeError(`expected value to be object, got ${typeof value}`)
26
+ }
27
+
28
+ for (const property of ['statusCode', 'cachedAt', 'staleAt', 'deleteAt']) {
29
+ if (typeof value[property] !== 'number') {
30
+ throw new TypeError(`expected value.${property} to be number, got ${typeof value[property]}`)
31
+ }
32
+ }
33
+
34
+ if (typeof value.statusMessage !== 'string') {
35
+ throw new TypeError(
36
+ `expected value.statusMessage to be string, got ${typeof value.statusMessage}`,
37
+ )
38
+ }
39
+
40
+ if (value.headers != null && typeof value.headers !== 'object') {
41
+ throw new TypeError(`expected value.rawHeaders to be object, got ${typeof value.headers}`)
42
+ }
43
+
44
+ if (value.vary !== undefined && typeof value.vary !== 'object') {
45
+ throw new TypeError(`expected value.vary to be object, got ${typeof value.vary}`)
46
+ }
47
+
48
+ if (value.etag !== undefined && typeof value.etag !== 'string') {
49
+ throw new TypeError(`expected value.etag to be string, got ${typeof value.etag}`)
50
+ }
51
+ }
package/lib/index.js CHANGED
@@ -1,6 +1,7 @@
1
- import undici from 'undici'
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 './cache/sqlite-cache-store.js'
4
5
 
5
6
  const dispatcherCache = new WeakMap()
6
7
 
@@ -18,8 +19,12 @@ export const interceptors = {
18
19
  lookup: (await import('./interceptor/lookup.js')).default,
19
20
  }
20
21
 
22
+ export const cache = {
23
+ SqliteCacheStore,
24
+ }
25
+
21
26
  export { parseHeaders } from './utils.js'
22
- export { Client, Pool, Agent, getGlobalDispatcher, setGlobalDispatcher } from 'undici'
27
+ export { Client, Pool, Agent, getGlobalDispatcher, setGlobalDispatcher } from '@nxtedition/undici'
23
28
 
24
29
  function defaultLookup(origin, opts, callback) {
25
30
  callback(null, Array.isArray(origin) ? origin[Math.floor(Math.random() * origin.length)] : origin)
@@ -69,14 +74,12 @@ function wrapDispatch(dispatcher) {
69
74
  (dispatch) => (opts, handler) => {
70
75
  const headers = parseHeaders(opts.headers)
71
76
 
72
- const userAgent = opts.userAgent ?? globalThis.userAgent
73
- if (userAgent && headers?.['user-agent'] !== userAgent) {
74
- headers['user-agent'] = userAgent
75
- }
77
+ // TODO (fix): Move to interceptor?
78
+ headers['user-agent'] ??= opts.userAgent ?? globalThis.userAgent
76
79
 
77
80
  return dispatch(
78
81
  {
79
- id: opts.id,
82
+ id: opts.id ?? headers['request-id'],
80
83
  origin: opts.origin,
81
84
  path: opts.path,
82
85
  method: opts.method ?? (opts.body ? 'POST' : 'GET'),