@nxtedition/nxt-undici 7.2.11 → 7.3.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.
package/lib/index.d.ts ADDED
@@ -0,0 +1,252 @@
1
+ import type { Readable } from 'node:stream'
2
+
3
+ export interface URLObject {
4
+ origin?: string | null
5
+ path?: string | null
6
+ host?: string | null
7
+ hostname?: string | null
8
+ port?: string | number | null
9
+ protocol?: string | null
10
+ pathname?: string | null
11
+ search?: string | null
12
+ }
13
+
14
+ export type URLLike = string | URL | URLObject
15
+
16
+ export interface Dispatcher {
17
+ dispatch(opts: object, handler: DispatchHandler): void
18
+ }
19
+
20
+ export interface DispatchHandler {
21
+ onConnect?(abort: (reason?: Error) => void): void
22
+ onHeaders?(
23
+ statusCode: number,
24
+ headers: Record<string, string | string[]>,
25
+ resume: () => void,
26
+ ): boolean | void
27
+ onData?(chunk: Buffer): boolean | void
28
+ onComplete?(trailers?: Record<string, string | string[]>): void
29
+ onError?(err: Error): void
30
+ onUpgrade?(
31
+ statusCode: number,
32
+ headers: Record<string, string | string[]>,
33
+ socket: import('node:net').Socket,
34
+ ): void
35
+ }
36
+
37
+ export type DispatchFn = (opts: DispatchOptions, handler: DispatchHandler) => void | Promise<void>
38
+
39
+ export type Interceptor = (dispatch: DispatchFn) => DispatchFn
40
+
41
+ export interface LoggerLike {
42
+ child(bindings: Record<string, unknown>): LoggerLike
43
+ debug(obj: unknown, msg?: string): void
44
+ error(obj: unknown, msg?: string): void
45
+ warn(obj: unknown, msg?: string): void
46
+ info(obj: unknown, msg?: string): void
47
+ }
48
+
49
+ export interface DispatchOptions {
50
+ id?: string | null
51
+ origin?: string | null
52
+ path?: string | null
53
+ method?: string | null
54
+ body?:
55
+ | Readable
56
+ | Uint8Array
57
+ | string
58
+ | ((signal: AbortSignal) => Readable | Uint8Array | string | Iterable<unknown>)
59
+ | null
60
+ query?: Record<string, unknown> | null
61
+ headers?: Record<string, string | string[] | null | undefined> | null
62
+ signal?: AbortSignal | null
63
+ reset?: boolean | null
64
+ blocking?: boolean | null
65
+ timeout?: number | { headers?: number | null; body?: number | null } | null
66
+ headersTimeout?: number | null
67
+ bodyTimeout?: number | null
68
+ idempotent?: boolean | null
69
+ typeOfService?: number | null
70
+ retry?: RetryOptions | number | boolean | RetryFn | null
71
+ proxy?: ProxyOptions | boolean | null
72
+ cache?: CacheOptions | boolean | null
73
+ upgrade?: boolean | null
74
+ follow?: number | FollowFn | boolean | null
75
+ error?: boolean | null
76
+ verify?: VerifyOptions | boolean | null
77
+ logger?: LoggerLike | null
78
+ dns?: DnsOptions | boolean | null
79
+ connect?: Record<string, unknown> | null
80
+ priority?:
81
+ | 0
82
+ | 1
83
+ | 2
84
+ | 'low'
85
+ | 'normal'
86
+ | 'high'
87
+ | 'lower'
88
+ | 'lowest'
89
+ | 'higher'
90
+ | 'highest'
91
+ | null
92
+ lookup?: LookupFn | null
93
+ }
94
+
95
+ export interface RetryOptions {
96
+ count?: number
97
+ retry?: RetryFn
98
+ }
99
+
100
+ export type RetryFn = (
101
+ err: Error,
102
+ retryCount: number,
103
+ opts: DispatchOptions,
104
+ defaultRetryFn: () => Promise<boolean>,
105
+ ) => boolean | Promise<boolean>
106
+
107
+ export type FollowFn = (location: string, count: number, opts: DispatchOptions) => boolean
108
+
109
+ export type LookupFn = (
110
+ origin: string | URLLike | Array<string | URLLike>,
111
+ opts: { signal?: AbortSignal },
112
+ callback: (err: Error | null, address: string | null) => void,
113
+ ) => void | Promise<string>
114
+
115
+ export interface ProxyOptions {
116
+ httpVersion?: string
117
+ socket?: import('node:net').Socket
118
+ name?: string
119
+ req?: import('node:http').IncomingMessage
120
+ }
121
+
122
+ export interface CacheOptions {
123
+ store?: CacheStore
124
+ maxEntrySize?: number
125
+ }
126
+
127
+ export interface VerifyOptions {
128
+ hash?: boolean
129
+ size?: boolean
130
+ }
131
+
132
+ export interface DnsOptions {
133
+ ttl?: number
134
+ balance?: 'hash'
135
+ }
136
+
137
+ export interface LogInterceptorOptions {
138
+ bindings?: Record<string, unknown>
139
+ }
140
+
141
+ export interface CacheKey {
142
+ origin: string
143
+ method: string
144
+ path: string
145
+ headers?: Record<string, string | string[] | null | undefined>
146
+ }
147
+
148
+ export interface CacheValue {
149
+ statusCode: number
150
+ statusMessage: string
151
+ headers?: Record<string, string | string[]>
152
+ body: Uint8Array | null
153
+ start: number
154
+ end: number
155
+ cacheControlDirectives?: Record<string, unknown>
156
+ etag?: string
157
+ vary?: Record<string, string | string[]>
158
+ cachedAt: number
159
+ staleAt: number
160
+ deleteAt?: number
161
+ }
162
+
163
+ export interface CacheGetResult {
164
+ statusCode: number
165
+ statusMessage: string
166
+ headers?: Record<string, string | string[]>
167
+ body?: Buffer
168
+ etag?: string
169
+ cacheControlDirectives?: Record<string, unknown>
170
+ vary?: Record<string, string | string[]>
171
+ cachedAt: number
172
+ staleAt: number
173
+ deleteAt: number
174
+ }
175
+
176
+ export interface CacheStore {
177
+ get(key: CacheKey): CacheGetResult | undefined
178
+ set(
179
+ key: CacheKey,
180
+ value: CacheValue & { body: null | Buffer | Buffer[]; start: number; end: number },
181
+ ): void
182
+ purgeStale(): void
183
+ close(): void
184
+ }
185
+
186
+ export interface RequestOptions extends DispatchOptions {
187
+ url?: URLLike | null
188
+ dispatch?: DispatchFn | null
189
+ dispatcher?: Dispatcher | null
190
+ }
191
+
192
+ export interface ResponseData {
193
+ statusCode: number
194
+ headers: Record<string, string | string[] | undefined>
195
+ body: Readable
196
+ }
197
+
198
+ export function request(
199
+ urlOrOpts: URLLike | RequestOptions,
200
+ opts?: RequestOptions | null,
201
+ ): Promise<ResponseData>
202
+
203
+ export function dispatch(
204
+ dispatcher: Dispatcher,
205
+ opts: DispatchOptions,
206
+ handler: DispatchHandler,
207
+ ): void | Promise<void>
208
+
209
+ export function compose(
210
+ dispatcherOrInterceptor: Dispatcher | DispatchFn | Interceptor,
211
+ ...interceptors: (Interceptor | null | undefined)[]
212
+ ): DispatchFn
213
+
214
+ export function parseHeaders(
215
+ headers:
216
+ | Record<string, string | string[] | null | undefined>
217
+ | (Buffer | string | (Buffer | string)[])[],
218
+ obj?: Record<string, string | string[]>,
219
+ ): Record<string, string | string[]>
220
+
221
+ export const interceptors: {
222
+ query: () => Interceptor
223
+ requestBodyFactory: () => Interceptor
224
+ responseError: () => Interceptor
225
+ responseRetry: () => Interceptor
226
+ responseVerify: () => Interceptor
227
+ log: (opts?: LogInterceptorOptions) => Interceptor
228
+ redirect: () => Interceptor
229
+ proxy: () => Interceptor
230
+ cache: () => Interceptor
231
+ requestId: () => Interceptor
232
+ dns: () => Interceptor
233
+ lookup: () => Interceptor
234
+ priority: () => Interceptor
235
+ }
236
+
237
+ export const cache: {
238
+ SqliteCacheStore: typeof SqliteCacheStore
239
+ }
240
+
241
+ export class SqliteCacheStore implements CacheStore {
242
+ constructor(opts?: { location?: string; db?: Record<string, unknown>; maxSize?: number })
243
+ get(key: CacheKey): CacheGetResult | undefined
244
+ set(
245
+ key: CacheKey,
246
+ value: CacheValue & { body: null | Buffer | Buffer[]; start: number; end: number },
247
+ ): void
248
+ purgeStale(): void
249
+ close(): void
250
+ }
251
+
252
+ export { Client, Pool, Agent, getGlobalDispatcher, setGlobalDispatcher } from '@nxtedition/undici'
package/lib/index.js CHANGED
@@ -124,8 +124,7 @@ function wrapDispatch(dispatcher) {
124
124
  const userAgent =
125
125
  opts.userAgent ?? globalThis.userAgent ?? globalThis.__nxt_undici_user_agent
126
126
  if (userAgent != null) {
127
- headers['user-agent'] ??=
128
- opts.userAgent ?? globalThis.userAgent ?? globalThis.__nxt_undici_user_agent
127
+ headers['user-agent'] ??= userAgent
129
128
  }
130
129
 
131
130
  if (opts.priority != null) {
@@ -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.11",
3
+ "version": "7.3.1",
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}": [