@nxtedition/nxt-undici 6.0.1 → 6.0.3

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.
@@ -1,3 +1,5 @@
1
+ 'use strict'
2
+
1
3
  import { assertCacheKey, assertCacheValue } from './util.js'
2
4
 
3
5
  let DatabaseSync
@@ -13,16 +15,24 @@ const VERSION = 3
13
15
  const MAX_ENTRY_SIZE = 2 * 1000 * 1000 * 1000
14
16
 
15
17
  /**
16
- * @typedef {import('../../types/cache-interceptor.d.ts').default.CacheStore} CacheStore
18
+ * @typedef {import('undici-types/cache-interceptor.d.ts').default.CacheStore} CacheStore
17
19
  * @implements {CacheStore}
18
20
  *
19
21
  * @typedef {{
20
22
  * 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
23
+ * body?: Buffer
24
+ * statusCode: number
25
+ * statusMessage: string
26
+ * headers?: string
27
+ * vary?: string
28
+ * etag?: string
29
+ * cacheControlDirectives?: string
30
+ * cachedAt: number
31
+ * staleAt: number
32
+ * deleteAt: number
33
+ * }} SqliteStoreValue
25
34
  */
35
+
26
36
  export class SqliteCacheStore {
27
37
  #maxEntrySize = MAX_ENTRY_SIZE
28
38
  #maxCount = Infinity
@@ -68,13 +78,9 @@ export class SqliteCacheStore {
68
78
  #deleteOldValuesQuery
69
79
 
70
80
  /**
71
- * @param {import('../../types/cache-interceptor.d.ts').default.SqliteCacheStoreOpts | undefined} opts
81
+ * @param {import('undici-types/cache-interceptor.d.ts').default.SqliteCacheStoreOpts | undefined} opts
72
82
  */
73
83
  constructor(opts) {
74
- if (!DatabaseSync) {
75
- throw new Error('SqliteCacheStore requires node:sqlite')
76
- }
77
-
78
84
  if (opts) {
79
85
  if (typeof opts !== 'object') {
80
86
  throw new TypeError('SqliteCacheStore options must be an object')
@@ -224,8 +230,8 @@ export class SqliteCacheStore {
224
230
  }
225
231
 
226
232
  /**
227
- * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
228
- * @returns {import('../../types/cache-interceptor.d.ts').default.GetResult | undefined}
233
+ * @param {import('undici-types/cache-interceptor.d.ts').default.CacheKey} key
234
+ * @returns {import('undici-types/cache-interceptor.d.ts').default.GetResult | undefined}
229
235
  */
230
236
  get(key) {
231
237
  assertCacheKey(key)
@@ -237,15 +243,15 @@ export class SqliteCacheStore {
237
243
  }
238
244
 
239
245
  /**
240
- * @type {import('../../types/cache-interceptor.d.ts').default.GetResult}
246
+ * @type {import('undici-types/cache-interceptor.d.ts').default.GetResult}
241
247
  */
242
248
  const result = {
243
- body: Buffer.from(value.body),
249
+ body: value.body ? Buffer.from(value.body) : null,
244
250
  statusCode: value.statusCode,
245
251
  statusMessage: value.statusMessage,
246
252
  headers: value.headers ? JSON.parse(value.headers) : undefined,
247
253
  etag: value.etag ? value.etag : undefined,
248
- vary: value.vary ?? undefined,
254
+ vary: value.vary ? JSON.parse(value.vary) : undefined,
249
255
  cacheControlDirectives: value.cacheControlDirectives
250
256
  ? JSON.parse(value.cacheControlDirectives)
251
257
  : undefined,
@@ -258,14 +264,14 @@ export class SqliteCacheStore {
258
264
  }
259
265
 
260
266
  /**
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
267
+ * @param {import('undici-types/cache-interceptor.d.ts').default.CacheKey} key
268
+ * @param {import('undici-types/cache-interceptor.d.ts').default.CacheValue & { body: Buffer | Array<Buffer> | null }} value
263
269
  */
264
270
  set(key, value) {
265
- assertCacheKey(value)
271
+ assertCacheKey(key)
266
272
  assertCacheValue(value)
267
273
 
268
- const body = value.body
274
+ const body = Array.isArray(value.body) ? Buffer.concat(value.body) : value.body
269
275
  const size = body ? body.byteLength : 0
270
276
 
271
277
  if (size > this.#maxEntrySize) {
@@ -314,7 +320,7 @@ export class SqliteCacheStore {
314
320
  }
315
321
 
316
322
  /**
317
- * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
323
+ * @param {import('undici-types/cache-interceptor.d.ts').default.CacheKey} key
318
324
  */
319
325
  delete(key) {
320
326
  if (typeof key !== 'object') {
@@ -358,7 +364,7 @@ export class SqliteCacheStore {
358
364
  }
359
365
 
360
366
  /**
361
- * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
367
+ * @param {import('undici-types/cache-interceptor.d.ts').default.CacheKey} key
362
368
  * @returns {string}
363
369
  */
364
370
  #makeValueUrl(key) {
@@ -366,9 +372,9 @@ export class SqliteCacheStore {
366
372
  }
367
373
 
368
374
  /**
369
- * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
375
+ * @param {import('undici-types/cache-interceptor.d.ts').default.CacheKey} key
370
376
  * @param {boolean} [canBeExpired=false]
371
- * @returns {(SqliteStoreValue & { vary?: Record<string, string[]> }) | undefined}
377
+ * @returns {SqliteStoreValue | undefined}
372
378
  */
373
379
  #findValue(key, canBeExpired = false) {
374
380
  const url = this.#makeValueUrl(key)
@@ -396,10 +402,10 @@ export class SqliteCacheStore {
396
402
  return undefined
397
403
  }
398
404
 
399
- value.vary = JSON.parse(value.vary)
405
+ const vary = JSON.parse(value.vary)
400
406
 
401
- for (const header in value.vary) {
402
- if (!headerValueEquals(headers[header], value.vary[header])) {
407
+ for (const header in vary) {
408
+ if (!headerValueEquals(headers[header], vary[header])) {
403
409
  matches = false
404
410
  break
405
411
  }
package/lib/index.js CHANGED
@@ -103,7 +103,6 @@ function wrapDispatch(dispatcher) {
103
103
  dns: opts.dns ?? true,
104
104
  connect: opts.connect,
105
105
  lookup: opts.lookup ?? defaultLookup,
106
- maxRedirections: 0, // TODO (fix): Ugly hack to disable undici redirections.
107
106
  },
108
107
  handler,
109
108
  )
@@ -2,6 +2,7 @@ import { SqliteCacheStore } from '../cache/sqlite-cache-store.js'
2
2
  import { DecoratorHandler, parseCacheControl } from '../utils.js'
3
3
 
4
4
  const DEFAULT_STORE = new SqliteCacheStore({ location: ':memory:' })
5
+ const MAX_ENTRY_SIZE = 128 * 1024
5
6
 
6
7
  class CacheHandler extends DecoratorHandler {
7
8
  #value
@@ -27,19 +28,18 @@ class CacheHandler extends DecoratorHandler {
27
28
  return super.onHeaders(statusCode, headers, resume)
28
29
  }
29
30
 
30
- if (headers.vary === '*') {
31
+ if (headers.vary === '*' || headers.trailers) {
31
32
  // Not cacheble...
32
33
  return super.onHeaders(statusCode, headers, resume)
33
34
  }
34
35
 
35
- const cacheControl = parseCacheControl(headers['cache-control'])
36
36
  const contentLength = headers['content-length'] ? Number(headers['content-length']) : Infinity
37
-
38
- if (contentLength) {
37
+ if (Number.isFinite(contentLength) && contentLength > MAX_ENTRY_SIZE) {
39
38
  // We don't support caching responses with body...
40
39
  return super.onHeaders(statusCode, headers, resume)
41
40
  }
42
41
 
42
+ const cacheControl = parseCacheControl(headers['cache-control'])
43
43
  if (
44
44
  !cacheControl ||
45
45
  !cacheControl.public ||
@@ -83,7 +83,8 @@ class CacheHandler extends DecoratorHandler {
83
83
  const cachedAt = Date.now()
84
84
 
85
85
  this.#value = {
86
- body: null,
86
+ body: [],
87
+ size: 0,
87
88
  deleteAt: cachedAt + ttl * 1e3,
88
89
  statusCode,
89
90
  statusMessage: '',
@@ -99,15 +100,25 @@ class CacheHandler extends DecoratorHandler {
99
100
  }
100
101
 
101
102
  onData(chunk) {
102
- this.#value = null
103
+ if (this.#value) {
104
+ this.#value.size += chunk.length
105
+ this.#value.body.push(chunk)
106
+
107
+ if (this.#value.size > MAX_ENTRY_SIZE) {
108
+ this.#value = null
109
+ this.#value.size = 0
110
+ }
111
+ }
112
+
103
113
  return super.onData(chunk)
104
114
  }
105
115
 
106
- onComplete() {
107
- if (this.#value) {
116
+ onComplete(trailers) {
117
+ if (this.#value && (!trailers || Object.keys(trailers).length === 0)) {
108
118
  this.#store.set(this.#opts, this.#value)
109
119
  }
110
- super.onComplete()
120
+
121
+ super.onComplete(trailers)
111
122
  }
112
123
  }
113
124
 
@@ -1,9 +1,56 @@
1
1
  import net from 'node:net'
2
2
  import { resolve4 } from 'node:dns/promises'
3
- import { getFastNow } from '../utils.js'
3
+ import { DecoratorHandler, getFastNow } from '../utils.js'
4
+
5
+ function noop() {}
6
+
7
+ const MAX_TTL = 10e3
8
+
9
+ class Handler extends DecoratorHandler {
10
+ #callback
11
+
12
+ constructor(handler, callback) {
13
+ super(handler)
14
+ this.#callback = callback
15
+ }
16
+
17
+ onComplete(trailers) {
18
+ this.#callback(null)
19
+ super.onComplete(trailers)
20
+ }
21
+
22
+ onError(err) {
23
+ this.#callback(err)
24
+ super.onError(err)
25
+ }
26
+ }
4
27
 
5
28
  export default () => (dispatch) => {
6
29
  const cache = new Map()
30
+ const promises = new Map()
31
+
32
+ function resolve(hostname) {
33
+ let promise = promises.get(hostname)
34
+ if (!promise) {
35
+ promise = resolve4(hostname, { ttl: true })
36
+ .then((records) => {
37
+ const now = getFastNow()
38
+ const prev = cache.get(hostname)
39
+ const next = records.map(({ address, ttl }) => ({
40
+ address,
41
+ expires: now + Math.min(MAX_TTL, 1e3 * ttl),
42
+ stats: prev?.find((x) => x.address === address)?.stats || { pending: 0, errored: 0 },
43
+ }))
44
+ cache.set(hostname, next)
45
+ return next
46
+ })
47
+ .finally(() => {
48
+ promises.delete(hostname)
49
+ })
50
+ promises.set(hostname, promise)
51
+ }
52
+ return promise
53
+ }
7
54
 
8
55
  return async (opts, handler) => {
9
56
  if (!opts || !opts.dns || !opts.origin) {
@@ -16,33 +63,60 @@ export default () => (dispatch) => {
16
63
  return dispatch(opts, handler)
17
64
  }
18
65
 
19
- const now = getFastNow()
20
66
  const { host, hostname } = origin
21
67
 
22
- const promiseOrRecords = cache.get(hostname)
68
+ const now = getFastNow()
23
69
 
24
- let records = promiseOrRecords?.then ? await promiseOrRecords : promiseOrRecords
70
+ let records = cache.get(hostname)
25
71
 
26
- records = records.filter(({ expires }) => expires > now)
27
- if (records == null || records.length === 0) {
28
- const promise = resolve4(hostname, { ttl: true }).then((records) =>
29
- records.map(({ address, ttl }) => ({ address, expires: now + 1e3 * ttl })),
30
- )
31
- cache.set(hostname, promise)
32
- records = await promise
33
- cache.set(hostname, records)
72
+ if (records == null || records.every((x) => x.stats.errored || x.expires < now)) {
73
+ records = await resolve(hostname)
74
+ } else if (records.some((x) => x.errored || x.expires < now + 1e3)) {
75
+ resolve(hostname).catch(noop)
34
76
  }
35
77
 
36
- if (records == null || records.length === 0) {
37
- throw Object.assign(new Error('No DNS records found for the specified hostname.'), {
78
+ records.sort((a, b) => {
79
+ if (a.stats.errored !== b.stats.errored) {
80
+ return a.stats.errored - b.stats.errored
81
+ }
82
+
83
+ if (a.stats.pending !== b.stats.pending) {
84
+ return a.stats.pending - b.stats.pending
85
+ }
86
+
87
+ return 0
88
+ })
89
+
90
+ const record = records.find((x) => x.expires >= now)
91
+
92
+ if (!record) {
93
+ throw Object.assign(new Error(`No available DNS records found for ${hostname}`), {
38
94
  code: 'ENOTFOUND',
39
- hostname: origin.hostname,
40
95
  })
41
96
  }
42
97
 
43
- const addresses = records.map(({ address }) => address)
44
- origin.hostname = addresses[Math.floor(Math.random() * addresses.length)]
98
+ origin.hostname = record.address
45
99
 
46
- return dispatch({ ...opts, origin, headers: { ...opts.headers, host } }, handler)
100
+ record.stats.pending++
101
+ try {
102
+ return dispatch(
103
+ { ...opts, origin, headers: { ...opts.headers, host } },
104
+ new Handler(handler, (err) => {
105
+ record.pending--
106
+ if (err == null) {
107
+ record.stats.errored = 0
108
+ } else if (err.name === 'AbortError') {
109
+ // Do nothing...
110
+ } else if (err.statusCode == null || err.statusCode >= 500) {
111
+ record.stats.errored++
112
+ } else {
113
+ record.stats.errored = 0
114
+ }
115
+ }),
116
+ )
117
+ } catch (err) {
118
+ record.stats.pending--
119
+ throw err
120
+ }
47
121
  }
48
122
  }
@@ -84,7 +84,7 @@ class Handler extends DecoratorHandler {
84
84
 
85
85
  #decorateError(err) {
86
86
  try {
87
- err.url ??= new URL(this.#opts.path, this.#opts.origin).href
87
+ err.url ??= this.#opts.origin ? new URL(this.#opts.path, this.#opts.origin).href : null
88
88
 
89
89
  err.req = {
90
90
  method: this.#opts?.method,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxtedition/nxt-undici",
3
- "version": "6.0.1",
3
+ "version": "6.0.3",
4
4
  "license": "MIT",
5
5
  "author": "Robert Nagy <robert.nagy@boffins.se>",
6
6
  "main": "lib/index.js",
@@ -22,7 +22,8 @@
22
22
  "pinst": "^3.0.0",
23
23
  "prettier": "^3.4.1",
24
24
  "send": "^1.1.0",
25
- "tap": "^21.0.1"
25
+ "tap": "^21.0.1",
26
+ "undici-types": "^7.2.3"
26
27
  },
27
28
  "scripts": {
28
29
  "prepare": "husky",