@nxtedition/nxt-undici 6.2.7 → 6.2.9

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,5 +1,5 @@
1
1
  import undici from '@nxtedition/undici'
2
- import { DecoratorHandler, parseCacheControl } from '../utils.js'
2
+ import { DecoratorHandler, parseCacheControl, parseContentRange } from '../utils.js'
3
3
 
4
4
  const DEFAULT_STORE = new undici.cacheStores.SqliteCacheStore({ location: ':memory:' })
5
5
  const DEFAULT_MAX_ENTRY_SIZE = 128 * 1024
@@ -25,11 +25,14 @@ class CacheHandler extends DecoratorHandler {
25
25
  onConnect(abort) {
26
26
  this.#value = null
27
27
 
28
- super.onConnect(abort)
28
+ super.onConnect((reason) => {
29
+ // TODO (fix): Can we cache partial results?
30
+ abort(reason)
31
+ })
29
32
  }
30
33
 
31
34
  onHeaders(statusCode, headers, resume) {
32
- if (statusCode !== 307 && statusCode !== 200) {
35
+ if (statusCode !== 307 && statusCode !== 200 && statusCode !== 206) {
33
36
  return super.onHeaders(statusCode, headers, resume)
34
37
  }
35
38
 
@@ -38,6 +41,16 @@ class CacheHandler extends DecoratorHandler {
38
41
  return super.onHeaders(statusCode, headers, resume)
39
42
  }
40
43
 
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)
47
+ }
48
+
49
+ if (statusCode === 206) {
50
+ // TODO (fix): enable range requests
51
+ return super.onHeaders(statusCode, headers, resume)
52
+ }
53
+
41
54
  const contentLength = headers['content-length'] ? Number(headers['content-length']) : Infinity
42
55
  if (Number.isFinite(contentLength) && contentLength > DEFAULT_MAX_ENTRY_SIZE) {
43
56
  // We don't support caching responses with body...
@@ -103,7 +116,7 @@ class CacheHandler extends DecoratorHandler {
103
116
  statusMessage: '',
104
117
  headers,
105
118
  cacheControlDirectives,
106
- etag: typeof headers.etag === 'string' && isEtagUsable(headers.etag) ? headers.etag : '',
119
+ etag: isEtagUsable(headers.etag) ? headers.etag : '',
107
120
  vary,
108
121
  cachedAt,
109
122
  staleAt: 0,
@@ -129,6 +142,7 @@ class CacheHandler extends DecoratorHandler {
129
142
  onComplete(trailers) {
130
143
  if (this.#value && (!trailers || Object.keys(trailers).length === 0)) {
131
144
  this.#store.set(this.#key, this.#value)
145
+ this.#value = null
132
146
  }
133
147
 
134
148
  super.onComplete(trailers)
@@ -161,11 +175,80 @@ export default () => (dispatch) => (opts, handler) => {
161
175
  return dispatch(opts, handler)
162
176
  }
163
177
 
164
- // Dump body...
165
- opts.body?.on('error', () => {}).resume()
166
-
167
178
  const store = opts.cache.store ?? DEFAULT_STORE
179
+
180
+ // TODO (fix): enable range requests
168
181
  const entry = store.get(opts)
182
+
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
+ // }
251
+
169
252
  if (!entry && !cacheControlDirectives['only-if-cached']) {
170
253
  return dispatch(
171
254
  opts,
@@ -189,6 +272,9 @@ export default () => (dispatch) => (opts, handler) => {
189
272
  }
190
273
  }
191
274
 
275
+ // Dump body...
276
+ opts.body?.on('error', () => {}).resume()
277
+
192
278
  try {
193
279
  handler.onConnect(abort)
194
280
  if (aborted) {
@@ -219,10 +305,14 @@ export default () => (dispatch) => (opts, handler) => {
219
305
  *
220
306
  * @see https://www.rfc-editor.org/rfc/rfc9110.html#name-etag
221
307
  *
222
- * @param {string} etag
308
+ * @param {string|any} etag
223
309
  * @returns {boolean}
224
310
  */
225
311
  function isEtagUsable(etag) {
312
+ if (typeof etag !== 'string') {
313
+ return false
314
+ }
315
+
226
316
  if (etag.length <= 2) {
227
317
  // Shortest an etag can be is two chars (just ""). This is where we deviate
228
318
  // from the spec requiring a min of 3 chars however
@@ -0,0 +1,479 @@
1
+ import { DatabaseSync } from 'node:sqlite'
2
+
3
+ const VERSION = 4
4
+
5
+ // 2gb
6
+ const MAX_ENTRY_SIZE = 2 * 1000 * 1000 * 1000
7
+
8
+ /**
9
+ * @typedef {import('undici-types/cache-interceptor.d.ts').default.CacheStore} CacheStore
10
+ * @implements {CacheStore}
11
+ *
12
+ * @typedef {{
13
+ * id: Readonly<number>,
14
+ * body?: Uint8Array
15
+ * statusCode: number
16
+ * statusMessage: string
17
+ * headers?: string
18
+ * vary?: string
19
+ * etag?: string
20
+ * cacheControlDirectives?: string
21
+ * cachedAt: number
22
+ * staleAt: number
23
+ * deleteAt: number
24
+ * }} SqliteStoreValue
25
+ */
26
+ export class SqliteCacheStore {
27
+ #maxEntrySize = MAX_ENTRY_SIZE
28
+ #maxEntryCount = 16 * 1024
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
+ #insertValueQuery
44
+
45
+ /**
46
+ * @type {import('node:sqlite').StatementSync}
47
+ */
48
+ #deleteExpiredValuesQuery
49
+
50
+ /**
51
+ * @type {import('node:sqlite').StatementSync}
52
+ */
53
+ #deleteByUrlQuery
54
+
55
+ /**
56
+ * @type {import('node:sqlite').StatementSync}
57
+ */
58
+ #countEntriesQuery
59
+
60
+ /**
61
+ * @type {import('node:sqlite').StatementSync | null}
62
+ */
63
+ #deleteOldValuesQuery
64
+
65
+ /**
66
+ * @param {import('undici-types/cache-interceptor.d.ts').default.SqliteCacheStoreOpts & { maxEntryCount?: number} | undefined} opts
67
+ */
68
+ constructor(opts) {
69
+ if (opts) {
70
+ if (typeof opts !== 'object') {
71
+ throw new TypeError('SqliteCacheStore options must be an object')
72
+ }
73
+
74
+ if (opts.maxEntrySize !== undefined) {
75
+ if (
76
+ typeof opts.maxEntrySize !== 'number' ||
77
+ !Number.isInteger(opts.maxEntrySize) ||
78
+ opts.maxEntrySize < 0
79
+ ) {
80
+ throw new TypeError(
81
+ 'SqliteCacheStore options.maxEntrySize must be a non-negative integer',
82
+ )
83
+ }
84
+
85
+ if (opts.maxEntrySize > MAX_ENTRY_SIZE) {
86
+ throw new TypeError('SqliteCacheStore options.maxEntrySize must be less than 2gb')
87
+ }
88
+
89
+ this.#maxEntrySize = opts.maxEntrySize
90
+ }
91
+
92
+ const maxEntryCount = opts.maxEntryCount ?? opts.maxCount
93
+ if (maxEntryCount !== undefined) {
94
+ if (
95
+ typeof maxEntryCount !== 'number' ||
96
+ !Number.isInteger(maxEntryCount) ||
97
+ maxEntryCount < 0
98
+ ) {
99
+ throw new TypeError(
100
+ 'SqliteCacheStore options.maxEntryCount must be a non-negative integer',
101
+ )
102
+ }
103
+ this.#maxEntryCount = maxEntryCount
104
+ }
105
+ }
106
+
107
+ this.#db = new DatabaseSync(opts?.location ?? ':memory:')
108
+
109
+ this.#db.exec(`
110
+ CREATE TABLE IF NOT EXISTS cacheInterceptorV${VERSION} (
111
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
112
+ url TEXT NOT NULL,
113
+ method TEXT NOT NULL,
114
+ body BUF NULL,
115
+ deleteAt INTEGER NOT NULL,
116
+ statusCode INTEGER NOT NULL,
117
+ statusMessage TEXT NOT NULL,
118
+ headers TEXT NULL,
119
+ cacheControlDirectives TEXT NULL,
120
+ etag TEXT NULL,
121
+ vary TEXT NULL,
122
+ cachedAt INTEGER NOT NULL,
123
+ staleAt INTEGER NOT NULL
124
+ );
125
+
126
+ CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_url ON cacheInterceptorV${VERSION}(url);
127
+ CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_method ON cacheInterceptorV${VERSION}(method);
128
+ CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_deleteAt ON cacheInterceptorV${VERSION}(deleteAt);
129
+ `)
130
+
131
+ this.#getValuesQuery = this.#db.prepare(`
132
+ SELECT
133
+ id,
134
+ body,
135
+ deleteAt,
136
+ statusCode,
137
+ statusMessage,
138
+ headers,
139
+ etag,
140
+ cacheControlDirectives,
141
+ vary,
142
+ cachedAt,
143
+ staleAt
144
+ FROM cacheInterceptorV${VERSION}
145
+ WHERE
146
+ url = ?
147
+ AND method = ?
148
+ ORDER BY
149
+ deleteAt ASC
150
+ `)
151
+
152
+ this.#insertValueQuery = this.#db.prepare(`
153
+ INSERT INTO cacheInterceptorV${VERSION} (
154
+ url,
155
+ method,
156
+ body,
157
+ deleteAt,
158
+ statusCode,
159
+ statusMessage,
160
+ headers,
161
+ etag,
162
+ cacheControlDirectives,
163
+ vary,
164
+ cachedAt,
165
+ staleAt
166
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
167
+ `)
168
+
169
+ this.#deleteByUrlQuery = this.#db.prepare(
170
+ `DELETE FROM cacheInterceptorV${VERSION} WHERE url = ?`,
171
+ )
172
+
173
+ this.#countEntriesQuery = this.#db.prepare(
174
+ `SELECT COUNT(*) AS total FROM cacheInterceptorV${VERSION}`,
175
+ )
176
+
177
+ this.#deleteExpiredValuesQuery = this.#db.prepare(
178
+ `DELETE FROM cacheInterceptorV${VERSION} WHERE deleteAt <= ?`,
179
+ )
180
+
181
+ this.#deleteOldValuesQuery =
182
+ this.#maxEntryCount === Infinity
183
+ ? null
184
+ : this.#db.prepare(`
185
+ DELETE FROM cacheInterceptorV${VERSION}
186
+ WHERE id IN (
187
+ SELECT
188
+ id
189
+ FROM cacheInterceptorV${VERSION}
190
+ ORDER BY cachedAt DESC
191
+ LIMIT ?
192
+ )
193
+ `)
194
+ }
195
+
196
+ close() {
197
+ this.#db.close()
198
+ }
199
+
200
+ /**
201
+ * @param {import('undici-types/cache-interceptor.d.ts').default.CacheKey} key
202
+ * @returns {(import('undici-types/cache-interceptor.d.ts').default.GetResult & { body?: Buffer }) | undefined}
203
+ */
204
+ get(key) {
205
+ assertCacheKey(key)
206
+
207
+ const value = this.#findValue(key)
208
+ return value ? makeResult(value) : undefined
209
+ }
210
+
211
+ /**
212
+ * @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
224
+ */
225
+ set(key, value) {
226
+ assertCacheKey(key)
227
+ assertCacheValue(value)
228
+
229
+ const body = Array.isArray(value.body) ? Buffer.concat(value.body) : value.body
230
+ if ((body?.byteLength ?? 0) > this.#maxEntrySize) {
231
+ return
232
+ }
233
+
234
+ this.#prune()
235
+
236
+ this.#insertValueQuery.run(
237
+ makeValueUrl(key),
238
+ key.method,
239
+ body,
240
+ value.deleteAt,
241
+ value.statusCode,
242
+ value.statusMessage,
243
+ value.headers ? JSON.stringify(value.headers) : null,
244
+ value.etag ? value.etag : null,
245
+ value.cacheControlDirectives ? JSON.stringify(value.cacheControlDirectives) : null,
246
+ value.vary ? JSON.stringify(value.vary) : null,
247
+ value.cachedAt,
248
+ value.staleAt,
249
+ )
250
+ }
251
+
252
+ /**
253
+ * @param {import('undici-types/cache-interceptor.d.ts').default.CacheKey} key
254
+ */
255
+ delete(key) {
256
+ if (typeof key !== 'object') {
257
+ throw new TypeError(`expected key to be object, got ${typeof key}`)
258
+ }
259
+
260
+ this.#deleteByUrlQuery.run(makeValueUrl(key))
261
+ }
262
+
263
+ #prune() {
264
+ if (this.size <= this.#maxEntryCount) {
265
+ return 0
266
+ }
267
+
268
+ {
269
+ const removed = this.#deleteExpiredValuesQuery.run(Date.now()).changes
270
+ if (removed) {
271
+ return removed
272
+ }
273
+ }
274
+
275
+ {
276
+ const removed = this.#deleteOldValuesQuery?.run(
277
+ Math.max(Math.floor(this.#maxEntryCount * 0.1), 1),
278
+ ).changes
279
+ if (removed) {
280
+ return removed
281
+ }
282
+ }
283
+
284
+ return 0
285
+ }
286
+
287
+ /**
288
+ * Counts the number of rows in the cache
289
+ * @returns {Number}
290
+ */
291
+ get size() {
292
+ const { total } = this.#countEntriesQuery.get()
293
+ return total
294
+ }
295
+
296
+ /**
297
+ * @param {import('undici-types/cache-interceptor.d.ts').default.CacheKey} key
298
+ * @param {boolean} [canBeExpired=false]
299
+ * @returns {SqliteStoreValue | undefined}
300
+ */
301
+ #findValue(key, canBeExpired = false) {
302
+ const { headers, method } = key
303
+
304
+ /**
305
+ * @type {SqliteStoreValue[]}
306
+ */
307
+ const values = this.#getValuesQuery.all(makeValueUrl(key), method)
308
+
309
+ if (values.length === 0) {
310
+ return undefined
311
+ }
312
+
313
+ const now = Date.now()
314
+ for (const value of values) {
315
+ if (now >= value.deleteAt && !canBeExpired) {
316
+ return undefined
317
+ }
318
+
319
+ let matches = true
320
+
321
+ if (value.vary) {
322
+ const vary = JSON.parse(value.vary)
323
+
324
+ for (const header in vary) {
325
+ if (!headerValueEquals(headers?.[header], vary[header])) {
326
+ matches = false
327
+ break
328
+ }
329
+ }
330
+ }
331
+
332
+ if (matches) {
333
+ return value
334
+ }
335
+ }
336
+
337
+ return undefined
338
+ }
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
+ }
376
+
377
+ /**
378
+ * @param {string|string[]|null|undefined} lhs
379
+ * @param {string|string[]|null|undefined} rhs
380
+ * @returns {boolean}
381
+ */
382
+ function headerValueEquals(lhs, rhs) {
383
+ if (lhs == null && rhs == null) {
384
+ return true
385
+ }
386
+
387
+ if ((lhs == null && rhs != null) || (lhs != null && rhs == null)) {
388
+ return false
389
+ }
390
+
391
+ if (Array.isArray(lhs) && Array.isArray(rhs)) {
392
+ if (lhs.length !== rhs.length) {
393
+ return false
394
+ }
395
+
396
+ return lhs.every((x, i) => x === rhs[i])
397
+ }
398
+
399
+ return lhs === rhs
400
+ }
401
+
402
+ /**
403
+ * @param {import('undici-types/cache-interceptor.d.ts').default.CacheKey} key
404
+ * @returns {string}
405
+ */
406
+ function makeValueUrl(key) {
407
+ return `${key.origin}/${key.path}`
408
+ }
409
+
410
+ function makeResult(value) {
411
+ return {
412
+ body: value.body
413
+ ? Buffer.from(value.body.buffer, value.body.byteOffset, value.body.byteLength)
414
+ : undefined,
415
+ statusCode: value.statusCode,
416
+ statusMessage: value.statusMessage,
417
+ headers: value.headers ? JSON.parse(value.headers) : undefined,
418
+ etag: value.etag ? value.etag : undefined,
419
+ vary: value.vary ? JSON.parse(value.vary) : undefined,
420
+ cacheControlDirectives: value.cacheControlDirectives
421
+ ? JSON.parse(value.cacheControlDirectives)
422
+ : undefined,
423
+ cachedAt: value.cachedAt,
424
+ staleAt: value.staleAt,
425
+ deleteAt: value.deleteAt,
426
+ }
427
+ }
428
+
429
+ /**
430
+ * @param {any} key
431
+ */
432
+ function assertCacheKey(key) {
433
+ if (typeof key !== 'object') {
434
+ throw new TypeError(`expected key to be object, got ${typeof key}`)
435
+ }
436
+
437
+ for (const property of ['origin', 'method', 'path']) {
438
+ if (typeof key[property] !== 'string') {
439
+ throw new TypeError(`expected key.${property} to be string, got ${typeof key[property]}`)
440
+ }
441
+ }
442
+
443
+ if (key.headers !== undefined && typeof key.headers !== 'object') {
444
+ throw new TypeError(`expected headers to be object, got ${typeof key}`)
445
+ }
446
+ }
447
+
448
+ /**
449
+ * @param {any} value
450
+ */
451
+ function assertCacheValue(value) {
452
+ if (typeof value !== 'object') {
453
+ throw new TypeError(`expected value to be object, got ${typeof value}`)
454
+ }
455
+
456
+ for (const property of ['statusCode', 'cachedAt', 'staleAt', 'deleteAt']) {
457
+ if (typeof value[property] !== 'number') {
458
+ throw new TypeError(`expected value.${property} to be number, got ${typeof value[property]}`)
459
+ }
460
+ }
461
+
462
+ if (typeof value.statusMessage !== 'string') {
463
+ throw new TypeError(
464
+ `expected value.statusMessage to be string, got ${typeof value.statusMessage}`,
465
+ )
466
+ }
467
+
468
+ if (value.headers != null && typeof value.headers !== 'object') {
469
+ throw new TypeError(`expected value.rawHeaders to be object, got ${typeof value.headers}`)
470
+ }
471
+
472
+ if (value.vary !== undefined && typeof value.vary !== 'object') {
473
+ throw new TypeError(`expected value.vary to be object, got ${typeof value.vary}`)
474
+ }
475
+
476
+ if (value.etag !== undefined && typeof value.etag !== 'string') {
477
+ throw new TypeError(`expected value.etag to be string, got ${typeof value.etag}`)
478
+ }
479
+ }
package/lib/utils.js CHANGED
@@ -17,23 +17,6 @@ export function parseCacheControl(str) {
17
17
  return str ? cacheControlParser.parse(str) : null
18
18
  }
19
19
 
20
- // Parsed accordingly to RFC 9110
21
- // https://www.rfc-editor.org/rfc/rfc9110#field.content-range
22
- export function parseRangeHeader(range) {
23
- if (range == null || range === '') {
24
- return { start: 0, end: null, size: null }
25
- }
26
-
27
- const m = range ? range.match(/^bytes (\d+)-(\d+)\/(\d+)?$/) : null
28
- return m
29
- ? {
30
- start: parseInt(m[1]),
31
- end: m[2] ? parseInt(m[2]) + 1 : null,
32
- size: m[3] ? parseInt(m[3]) : null,
33
- }
34
- : null
35
- }
36
-
37
20
  export function isDisturbed(body) {
38
21
  if (
39
22
  body == null ||
@@ -51,32 +34,59 @@ export function isDisturbed(body) {
51
34
  return stream.isDisturbed(body)
52
35
  }
53
36
 
37
+ /**
38
+ * @typedef {object} RangeHeader
39
+ * @property {number} start
40
+ * @property {number | null} end
41
+ * @property {number | null} size
42
+ */
43
+
44
+ /**
45
+ * @param {string} [range]
46
+ * @returns {RangeHeader|null|undefined}
47
+ */
54
48
  export function parseContentRange(range) {
55
- if (typeof range !== 'string') {
56
- return null
49
+ if (range == null || range === '') {
50
+ return undefined
57
51
  }
58
52
 
59
- const m = range.match(/^bytes (\d+)-(\d+)?\/(\d+|\*)$/)
60
- if (!m) {
53
+ if (typeof range !== 'string') {
61
54
  return null
62
55
  }
63
56
 
64
- const start = m[1] == null ? null : Number(m[1])
65
- if (!Number.isFinite(start)) {
66
- return null
67
- }
57
+ const m = range.match(/^bytes (\d+)-(\d+)?\/(\d+|\*)$/)
58
+ return m
59
+ ? {
60
+ start: parseInt(m[1], 10),
61
+ end: m[2] ? parseInt(m[2]) + 1 : null,
62
+ size: m[3] === '*' ? null : parseInt(m[3]),
63
+ }
64
+ : null
65
+ }
68
66
 
69
- const end = m[2] == null ? null : Number(m[2])
70
- if (end !== null && !Number.isFinite(end)) {
71
- return null
67
+ // Parsed accordingly to RFC 9110
68
+ // https://www.rfc-editor.org/rfc/rfc9110#field.content-range
69
+ /**
70
+ * @param {string} [range]
71
+ * @returns {RangeHeader|null|undefined}
72
+ */
73
+ export function parseRangeHeader(range) {
74
+ if (range == null || range === '') {
75
+ return undefined
72
76
  }
73
77
 
74
- const size = m[2] === '*' ? null : Number(m[2])
75
- if (size !== null && !Number.isFinite(size)) {
78
+ if (typeof range !== 'string') {
76
79
  return null
77
80
  }
78
81
 
79
- return { start, end: end ? end + 1 : size, size }
82
+ const m = range.match(/^bytes (\d+)-(\d+)\/(\d+)?$/)
83
+ return m
84
+ ? {
85
+ start: parseInt(m[1]),
86
+ end: m[2] ? parseInt(m[2]) + 1 : null,
87
+ size: m[3] ? parseInt(m[3]) : null,
88
+ }
89
+ : null
80
90
  }
81
91
 
82
92
  export function parseURL(url) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxtedition/nxt-undici",
3
- "version": "6.2.7",
3
+ "version": "6.2.9",
4
4
  "license": "MIT",
5
5
  "author": "Robert Nagy <robert.nagy@boffins.se>",
6
6
  "main": "lib/index.js",
@@ -9,21 +9,21 @@
9
9
  "lib/*"
10
10
  ],
11
11
  "dependencies": {
12
- "@nxtedition/undici": "^10.1.2",
12
+ "@nxtedition/undici": "^11.0.0",
13
13
  "cache-control-parser": "^2.0.6",
14
14
  "http-errors": "^2.0.0"
15
15
  },
16
16
  "devDependencies": {
17
- "@types/node": "^22.10.7",
18
- "eslint": "^9.16.0",
19
- "eslint-plugin-n": "^17.14.0",
17
+ "@types/node": "^22.13.10",
18
+ "eslint": "^9.22.0",
19
+ "eslint-plugin-n": "^17.16.2",
20
20
  "husky": "^9.1.7",
21
- "lint-staged": "^15.4.1",
21
+ "lint-staged": "^15.5.0",
22
22
  "pinst": "^3.0.0",
23
- "prettier": "^3.4.1",
23
+ "prettier": "^3.5.3",
24
24
  "send": "^1.1.0",
25
- "tap": "^21.0.1",
26
- "undici-types": "^7.2.3"
25
+ "tap": "^21.1.0",
26
+ "undici-types": "^7.5.0"
27
27
  },
28
28
  "scripts": {
29
29
  "prepare": "husky",