@nxtedition/nxt-undici 7.2.10 → 7.3.0

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.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ import type { Dispatcher } from 'undici-types'
2
+ import type { Logger } from 'pino'
3
+
4
+ export type Headers =
5
+ | Record<string, string | string[] | null | undefined>
6
+ | (Buffer | string | (Buffer | string)[])[]
7
+
8
+ export interface NxtUndiciRequestInit extends RequestInit {
9
+ headers?: Headers
10
+ throwOnError?: boolean
11
+ logger?: Logger
12
+ }
13
+
14
+ export function request(options: NxtUndiciRequestInit): Promise<Dispatcher.ResponseData>
15
+ export function request(
16
+ url: string | URL,
17
+ options?: NxtUndiciRequestInit,
18
+ ): Promise<Dispatcher.ResponseData>
package/lib/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import undici from '@nxtedition/undici'
2
+ import { Scheduler } from '@nxtedition/scheduler'
2
3
  import { parseHeaders } from './utils.js'
3
4
  import { request as _request } from './request.js'
4
5
  import { SqliteCacheStore } from './sqlite-cache-store.js'
@@ -79,6 +80,22 @@ export function compose(...interceptors) {
79
80
  return dispatch
80
81
  }
81
82
 
83
+ const PRIORITY_TOS_MAP = {}
84
+ PRIORITY_TOS_MAP[Scheduler.HIGHEST] = 0xb8 // EF
85
+ PRIORITY_TOS_MAP['highest'] = 0xb8 // EF
86
+ PRIORITY_TOS_MAP[Scheduler.HIGHER] = 0x88 // AF41
87
+ PRIORITY_TOS_MAP['higher'] = 0x88 // AF41
88
+ PRIORITY_TOS_MAP[Scheduler.HIGH] = 0x68 // AF31
89
+ PRIORITY_TOS_MAP['high'] = 0x68 // AF31
90
+ PRIORITY_TOS_MAP[Scheduler.NORMAL] = 0x00 // BE
91
+ PRIORITY_TOS_MAP['normal'] = 0x00 // BE
92
+ PRIORITY_TOS_MAP[Scheduler.LOW] = 0x04 // LE
93
+ PRIORITY_TOS_MAP['low'] = 0x04 // LE
94
+ PRIORITY_TOS_MAP[Scheduler.LOWER] = 0x04 // LE
95
+ PRIORITY_TOS_MAP['lower'] = 0x04 // LE
96
+ PRIORITY_TOS_MAP[Scheduler.LOWEST] = 0x04 // LE
97
+ PRIORITY_TOS_MAP['lowest'] = 0x04 // LE
98
+
82
99
  function wrapDispatch(dispatcher) {
83
100
  let wrappedDispatcher = dispatcherCache.get(dispatcher)
84
101
  if (wrappedDispatcher == null) {
@@ -107,8 +124,7 @@ function wrapDispatch(dispatcher) {
107
124
  const userAgent =
108
125
  opts.userAgent ?? globalThis.userAgent ?? globalThis.__nxt_undici_user_agent
109
126
  if (userAgent != null) {
110
- headers['user-agent'] ??=
111
- opts.userAgent ?? globalThis.userAgent ?? globalThis.__nxt_undici_user_agent
127
+ headers['user-agent'] ??= userAgent
112
128
  }
113
129
 
114
130
  if (opts.priority != null) {
@@ -135,7 +151,8 @@ function wrapDispatch(dispatcher) {
135
151
  opts.timeout?.headers ?? opts.headersTimeout ?? opts.headerTimeout ?? opts.timeout,
136
152
  bodyTimeout: opts.timeout?.body ?? opts.bodyTimeout ?? opts.timeout,
137
153
  idempotent: opts.idempotent,
138
- typeOfService: opts.typeOfService ?? 0,
154
+ typeOfService:
155
+ opts.typeOfService ?? (opts.priority ? (PRIORITY_TOS_MAP[opts.priority] ?? 0) : 0),
139
156
  retry: opts.retry ?? 8,
140
157
  proxy: opts.proxy ?? false,
141
158
  cache: opts.cache ?? false,
@@ -121,7 +121,7 @@ class CacheHandler extends DecoratorHandler {
121
121
  const start = contentRange ? contentRange.start : 0
122
122
  const end = contentRange ? contentRange.end : contentLength
123
123
 
124
- if (end == null || end - start < this.#maxEntrySize) {
124
+ if (end == null || end - start <= this.#maxEntrySize) {
125
125
  const cachedAt = Date.now()
126
126
  this.#value = {
127
127
  body: [],
@@ -94,7 +94,10 @@ class Handler extends DecoratorHandler {
94
94
  headers: this.#headers,
95
95
  timing: this.#timing,
96
96
  bytesRead: this.#pos,
97
- bytesReadPerSecond: (this.#pos * 1e3) / (this.#timing.end - this.#timing.data),
97
+ bytesReadPerSecond:
98
+ this.#timing.data >= 0 && this.#timing.end > this.#timing.data
99
+ ? (this.#pos * 1e3) / (this.#timing.end - this.#timing.data)
100
+ : 0,
98
101
  },
99
102
  elapsedTime: this.#timing.end,
100
103
  }
@@ -119,7 +122,10 @@ class Handler extends DecoratorHandler {
119
122
  headers: this.#headers,
120
123
  timing: this.#timing,
121
124
  bytesRead: this.#pos,
122
- bytesReadPerSecond: (this.#pos * 1e3) / (this.#timing.end - this.#timing.data),
125
+ bytesReadPerSecond:
126
+ this.#timing.data >= 0 && this.#timing.end > this.#timing.data
127
+ ? (this.#pos * 1e3) / (this.#timing.end - this.#timing.data)
128
+ : 0,
123
129
  },
124
130
  elapsedTime: this.#timing.end,
125
131
  err,
@@ -59,7 +59,7 @@ class Handler extends DecoratorHandler {
59
59
  this.#location = typeof headers.location === 'string' ? headers.location : ''
60
60
 
61
61
  if (!this.#location) {
62
- throw new Error(`Missing redirection location .`)
62
+ throw new Error(`Missing redirection location.`)
63
63
  }
64
64
 
65
65
  this.#history.push(this.#location)
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert'
2
2
  import tp from 'node:timers/promises'
3
- import { DecoratorHandler, isDisturbed, decorateError, parseRangeHeader } from '../utils.js'
3
+ import { DecoratorHandler, isDisturbed, decorateError, parseContentRange } from '../utils.js'
4
4
 
5
5
  function noop() {}
6
6
 
@@ -103,7 +103,7 @@ class Handler extends DecoratorHandler {
103
103
  }
104
104
 
105
105
  if (statusCode === 206) {
106
- const { start, size, end = size } = parseRangeHeader(headers['content-range']) ?? {}
106
+ const { start, size, end = size } = parseContentRange(headers['content-range']) ?? {}
107
107
  if (start == null || end == null || contentLength !== end - start) {
108
108
  this.#headersSent = true
109
109
  return super.onHeaders(statusCode, headers, resume)
@@ -147,7 +147,7 @@ class Handler extends DecoratorHandler {
147
147
  return false
148
148
  }
149
149
 
150
- const contentRange = parseRangeHeader(headers['content-range'])
150
+ const contentRange = parseContentRange(headers['content-range'])
151
151
  if (!contentRange) {
152
152
  this.#maybeError(null)
153
153
  return false
@@ -175,9 +175,8 @@ class Handler extends DecoratorHandler {
175
175
  this.#pos += chunk.byteLength
176
176
  }
177
177
 
178
- this.#retryCount = 0
179
-
180
178
  if (this.#statusCode < 400) {
179
+ this.#retryCount = 0
181
180
  return super.onData(chunk)
182
181
  }
183
182
 
@@ -195,6 +194,7 @@ class Handler extends DecoratorHandler {
195
194
  this.#trailers = trailers
196
195
 
197
196
  if (this.#statusCode < 400) {
197
+ this.#retryCount = 0
198
198
  return super.onComplete(trailers)
199
199
  }
200
200
 
@@ -285,6 +285,7 @@ class Handler extends DecoratorHandler {
285
285
  assert(Number.isFinite(this.#pos))
286
286
  assert(this.#end == null || (Number.isFinite(this.#end) && this.#end > 0))
287
287
 
288
+ this.#opts = { ...this.#opts, headers: { ...this.#opts.headers } }
288
289
  this.#opts.headers['if-match'] = this.#etag
289
290
  this.#opts.headers.range = `bytes=${this.#pos}-${this.#end ? this.#end - 1 : ''}`
290
291
  this.#opts.logger?.debug({ err, retryCount: this.#retryCount }, 'retry response body')
@@ -313,7 +314,7 @@ class Handler extends DecoratorHandler {
313
314
 
314
315
  const retryMax = retryOpts?.count ?? 8
315
316
 
316
- if (retryCount > retryMax) {
317
+ if (retryCount >= retryMax) {
317
318
  return false
318
319
  }
319
320
 
@@ -46,14 +46,14 @@ class Handler extends DecoratorHandler {
46
46
 
47
47
  if (this.#contentLength != null && this.#pos !== Number(this.#contentLength)) {
48
48
  super.onError(
49
- Object.assign(new Error('Request Content-Length mismatch'), {
49
+ Object.assign(new Error('Response Content-Length mismatch'), {
50
50
  expected: Number(this.#contentLength),
51
51
  actual: this.#pos,
52
52
  }),
53
53
  )
54
54
  } else if (this.#contentMD5 != null && contentMD5 !== this.#contentMD5) {
55
55
  super.onError(
56
- Object.assign(new Error('Request Content-MD5 mismatch'), {
56
+ Object.assign(new Error('Response Content-MD5 mismatch'), {
57
57
  expected: this.#contentMD5,
58
58
  actual: contentMD5,
59
59
  }),
@@ -4,6 +4,23 @@ import { parseRangeHeader, getFastNow } from './utils.js'
4
4
 
5
5
  const VERSION = 7
6
6
 
7
+ /** @typedef {{ purgeStale: () => void } } */
8
+ const stores = new Set()
9
+
10
+ {
11
+ const offPeakBC = new BroadcastChannel('nxt:offPeak')
12
+ offPeakBC.unref()
13
+ offPeakBC.onmessage = () => {
14
+ for (const store of stores) {
15
+ try {
16
+ store.purgeStale()
17
+ } catch (err) {
18
+ process.emitWarning(err)
19
+ }
20
+ }
21
+ }
22
+ }
23
+
7
24
  /**
8
25
  * @typedef {import('undici-types/cache-interceptor.d.ts').default.CacheStore} CacheStore
9
26
  * @implements {CacheStore}
@@ -51,10 +68,10 @@ export class SqliteCacheStore {
51
68
  #deleteExpiredValuesTime = getFastNow()
52
69
 
53
70
  /**
54
- * @param {import('undici-types/cache-interceptor.d.ts').default.SqliteCacheStoreOpts & { maxEntryCount?: number } | undefined} opts
71
+ * @param {import('undici-types/cache-interceptor.d.ts').default.SqliteCacheStoreOpts & { maxSize?: number } | undefined} opts
55
72
  */
56
73
  constructor(opts) {
57
- this.#db = new DatabaseSync(opts?.location ?? ':memory:', { timeout: 100, ...opts?.db })
74
+ this.#db = new DatabaseSync(opts?.location ?? ':memory:', { timeout: 40, ...opts?.db })
58
75
 
59
76
  this.#db.exec(`
60
77
  PRAGMA journal_mode = WAL;
@@ -84,10 +101,18 @@ export class SqliteCacheStore {
84
101
  CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_deleteExpiredValuesQuery ON cacheInterceptorV${VERSION}(deleteAt);
85
102
  `)
86
103
 
104
+ const maxSize = opts?.maxSize ?? 256 * 1024 * 1024
105
+ {
106
+ const { page_size: pageSize } = this.#db.prepare('PRAGMA page_size').get()
107
+ this.#db.exec(`PRAGMA max_page_count = ${Math.ceil(maxSize / pageSize)}`)
108
+ }
109
+
87
110
  this.#getValuesQuery = this.#db.prepare(`
88
111
  SELECT
89
112
  id,
90
113
  body,
114
+ start,
115
+ end,
91
116
  deleteAt,
92
117
  statusCode,
93
118
  statusMessage,
@@ -128,9 +153,16 @@ export class SqliteCacheStore {
128
153
  this.#deleteExpiredValuesQuery = this.#db.prepare(
129
154
  `DELETE FROM cacheInterceptorV${VERSION} WHERE deleteAt <= ?`,
130
155
  )
156
+
157
+ stores.add(this)
158
+ }
159
+
160
+ purgeStale() {
161
+ this.#prune()
131
162
  }
132
163
 
133
164
  close() {
165
+ stores.delete(this)
134
166
  this.#db.close()
135
167
  }
136
168
 
@@ -141,8 +173,6 @@ export class SqliteCacheStore {
141
173
  get(key) {
142
174
  assertCacheKey(key)
143
175
 
144
- this.#prune()
145
-
146
176
  const value = this.#findValue(key)
147
177
  return value ? makeResult(value) : undefined
148
178
  }
@@ -161,8 +191,19 @@ export class SqliteCacheStore {
161
191
  assert(Number.isFinite(value.end))
162
192
  assert(!body || body?.byteLength === value.end - value.start)
163
193
 
164
- this.#prune()
194
+ try {
195
+ this.#insert(key, value, body)
196
+ } catch (err) {
197
+ if (err?.errcode === 13 /* SQLITE_FULL */) {
198
+ this.#prune()
199
+ this.#insert(key, value, body)
200
+ } else {
201
+ throw err
202
+ }
203
+ }
204
+ }
165
205
 
206
+ #insert(key, value, body) {
166
207
  this.#insertValueQuery.run(
167
208
  makeValueUrl(key),
168
209
  key.method,
@@ -184,8 +225,8 @@ export class SqliteCacheStore {
184
225
  #prune() {
185
226
  const now = getFastNow()
186
227
  if (now > this.#deleteExpiredValuesTime) {
187
- this.#deleteExpiredValuesQuery.run(this.#deleteExpiredValuesTime)
188
- this.#deleteExpiredValuesTime += 60e3
228
+ this.#deleteExpiredValuesQuery.run(now)
229
+ this.#deleteExpiredValuesTime = now + 60e3
189
230
  }
190
231
  }
191
232
 
@@ -203,6 +244,10 @@ export class SqliteCacheStore {
203
244
 
204
245
  const range = parseRangeHeader(headers?.range)
205
246
 
247
+ if (range === null) {
248
+ return undefined
249
+ }
250
+
206
251
  /**
207
252
  * @type {SqliteStoreValue[]}
208
253
  */
package/lib/utils.js CHANGED
@@ -66,7 +66,7 @@ export function parseContentRange(range) {
66
66
  }
67
67
 
68
68
  // Parsed accordingly to RFC 9110
69
- // https://www.rfc-editor.org/rfc/rfc9110#field.content-range
69
+ // https://www.rfc-editor.org/rfc/rfc9110#field.range
70
70
  /**
71
71
  * @param {string} [range]
72
72
  * @returns {RangeHeader|null|undefined}
@@ -80,12 +80,12 @@ export function parseRangeHeader(range) {
80
80
  return null
81
81
  }
82
82
 
83
- const m = range.match(/^bytes (\d+)-(\d+)\/(\d+)?$/)
83
+ const m = range.match(/^bytes=(\d+)-(\d+)?$/)
84
84
  return m
85
85
  ? {
86
86
  start: parseInt(m[1]),
87
87
  end: m[2] ? parseInt(m[2]) + 1 : null,
88
- size: m[3] ? parseInt(m[3]) : null,
88
+ size: null,
89
89
  }
90
90
  : null
91
91
  }
@@ -154,7 +154,7 @@ export function parseURL(url) {
154
154
  }
155
155
 
156
156
  export function parseOrigin(url) {
157
- url = module.exports.parseURL(url)
157
+ url = parseURL(url)
158
158
 
159
159
  if (url.pathname !== '/' || url.search || url.hash) {
160
160
  throw new Error('invalid url')
@@ -390,7 +390,7 @@ export function decorateError(err, opts, { statusCode, headers, trailers, body }
390
390
 
391
391
  if (
392
392
  typeof body === 'string' &&
393
- (!headers['content-type'] || headers['content-type'].startsWith('application/json'))
393
+ (!headers?.['content-type'] || headers['content-type'].startsWith('application/json'))
394
394
  ) {
395
395
  try {
396
396
  body = JSON.parse(body)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxtedition/nxt-undici",
3
- "version": "7.2.10",
3
+ "version": "7.3.0",
4
4
  "license": "MIT",
5
5
  "author": "Robert Nagy <robert.nagy@boffins.se>",
6
6
  "main": "lib/index.js",
@@ -9,30 +9,33 @@
9
9
  "lib/*"
10
10
  ],
11
11
  "dependencies": {
12
- "@nxtedition/scheduler": "^3.0.1",
12
+ "@nxtedition/scheduler": "^3.0.8",
13
13
  "@nxtedition/undici": "^11.1.3",
14
- "cache-control-parser": "^2.0.6",
14
+ "cache-control-parser": "^2.2.0",
15
15
  "fast-querystring": "^1.1.2",
16
16
  "http-errors": "^2.0.1",
17
17
  "xxhash-wasm": "^1.1.0"
18
18
  },
19
19
  "devDependencies": {
20
- "@types/node": "^25.2.0",
21
- "eslint": "^9.39.2",
22
- "eslint-plugin-n": "^17.23.2",
20
+ "@eslint/js": "^10.0.1",
21
+ "@types/node": "^25.2.3",
22
+ "eslint": "^10.0.0",
23
+ "eslint-plugin-n": "^17.24.0",
23
24
  "husky": "^9.1.7",
24
25
  "lint-staged": "^16.2.7",
26
+ "pino": "^9.6.0",
25
27
  "pinst": "^3.0.0",
26
28
  "prettier": "^3.8.1",
27
29
  "send": "^1.2.1",
28
- "tap": "^21.5.0",
29
- "undici-types": "^7.20.0"
30
+ "tap": "^21.5.1",
31
+ "undici-types": "^7.22.0"
30
32
  },
31
33
  "scripts": {
32
34
  "prepare": "husky",
33
35
  "prepublishOnly": "pinst --disable",
34
36
  "postpublish": "pinst --enable",
35
- "test": "tap test"
37
+ "test": "tap test",
38
+ "test:coverage": "tap test --coverage-report=text --coverage-report=html"
36
39
  },
37
40
  "lint-staged": {
38
41
  "*.{js,jsx,md,ts}": [