Huge News!Announcing our $40M Series B led by Abstract Ventures.Learn More
Socket
Sign inDemoInstall
Socket

undici

Package Overview
Dependencies
Maintainers
3
Versions
225
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

undici - npm Package Compare versions

Comparing version 7.0.0-alpha.7 to 7.0.0-alpha.8

2

docs/docs/api/Dispatcher.md

@@ -1263,2 +1263,4 @@ # Dispatcher

- `methods` - The [**safe** HTTP methods](https://www.rfc-editor.org/rfc/rfc9110#section-9.2.1) to cache the response of.
- `cacheByDefault` - The default expiration time to cache responses by if they don't have an explicit expiration. If this isn't present, responses without explicit expiration will not be cached. Default `undefined`.
- `type` - The type of cache for Undici to act as. Can be `shared` or `private`. Default `shared`.

@@ -1265,0 +1267,0 @@ ## Instance Events

@@ -92,3 +92,5 @@ 'use strict'

body: entry.body,
vary: entry.vary ? entry.vary : undefined,
etag: entry.etag,
cacheControlDirectives: entry.cacheControlDirectives,
cachedAt: entry.cachedAt,

@@ -95,0 +97,0 @@ staleAt: entry.staleAt,

75

lib/cache/sqlite-cache-store.js

@@ -7,4 +7,7 @@ 'use strict'

const VERSION = 2
const VERSION = 3
// 2gb
const MAX_ENTRY_SIZE = 2 * 1000 * 1000 * 1000
/**

@@ -21,4 +24,4 @@ * @typedef {import('../../types/cache-interceptor.d.ts').default.CacheStore} CacheStore

*/
class SqliteCacheStore {
#maxEntrySize = Infinity
module.exports = class SqliteCacheStore {
#maxEntrySize = MAX_ENTRY_SIZE
#maxCount = Infinity

@@ -83,2 +86,7 @@

}
if (opts.maxEntrySize > MAX_ENTRY_SIZE) {
throw new TypeError('SqliteCacheStore options.maxEntrySize must be less than 2gb')
}
this.#maxEntrySize = opts.maxEntrySize

@@ -109,3 +117,3 @@ }

-- Data returned to the interceptor
body TEXT NULL,
body BUF NULL,
deleteAt INTEGER NOT NULL,

@@ -115,2 +123,3 @@ statusCode INTEGER NOT NULL,

headers TEXT NULL,
cacheControlDirectives TEXT NULL,
etag TEXT NULL,

@@ -136,2 +145,3 @@ vary TEXT NULL,

etag,
cacheControlDirectives,
vary,

@@ -156,2 +166,3 @@ cachedAt,

etag = ?,
cacheControlDirectives = ?,
cachedAt = ?,

@@ -174,2 +185,3 @@ staleAt = ?,

etag,
cacheControlDirectives,
vary,

@@ -179,3 +191,3 @@ cachedAt,

deleteAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)

@@ -230,3 +242,3 @@

const result = {
body: value.body ? parseBufferArray(JSON.parse(value.body)) : null,
body: Buffer.from(value.body),
statusCode: value.statusCode,

@@ -236,2 +248,6 @@ statusMessage: value.statusMessage,

etag: value.etag ? value.etag : undefined,
vary: value.vary ?? undefined,
cacheControlDirectives: value.cacheControlDirectives
? JSON.parse(value.cacheControlDirectives)
: undefined,
cachedAt: value.cachedAt,

@@ -283,3 +299,3 @@ staleAt: value.staleAt,

store.#updateValueQuery.run(
JSON.stringify(stringifyBufferArray(body)),
Buffer.concat(body),
value.deleteAt,

@@ -289,3 +305,4 @@ value.statusCode,

value.headers ? JSON.stringify(value.headers) : null,
value.etag,
value.etag ? value.etag : null,
value.cacheControlDirectives ? JSON.stringify(value.cacheControlDirectives) : null,
value.cachedAt,

@@ -302,3 +319,3 @@ value.staleAt,

key.method,
JSON.stringify(stringifyBufferArray(body)),
Buffer.concat(body),
value.deleteAt,

@@ -309,2 +326,3 @@ value.statusCode,

value.etag ? value.etag : null,
value.cacheControlDirectives ? JSON.stringify(value.cacheControlDirectives) : null,
value.vary ? JSON.stringify(value.vary) : null,

@@ -334,3 +352,3 @@ value.cachedAt,

#prune () {
if (this.#size <= this.#maxCount) {
if (this.size <= this.#maxCount) {
return 0

@@ -360,3 +378,3 @@ }

*/
get #size () {
get size () {
const { total } = this.#countEntriesQuery.get()

@@ -405,6 +423,6 @@ return total

const vary = JSON.parse(value.vary)
value.vary = JSON.parse(value.vary)
for (const header in vary) {
if (headerValueEquals(headers[header], vary[header])) {
for (const header in value.vary) {
if (!headerValueEquals(headers[header], value.vary[header])) {
matches = false

@@ -447,30 +465,1 @@ break

}
/**
* @param {Buffer[]} buffers
* @returns {string[]}
*/
function stringifyBufferArray (buffers) {
const output = new Array(buffers.length)
for (let i = 0; i < buffers.length; i++) {
output[i] = buffers[i].toString()
}
return output
}
/**
* @param {string[]} strings
* @returns {Buffer[]}
*/
function parseBufferArray (strings) {
const output = new Array(strings.length)
for (let i = 0; i < strings.length; i++) {
output[i] = Buffer.from(strings[i])
}
return output
}
module.exports = SqliteCacheStore

@@ -13,3 +13,5 @@ 'use strict'

/**
* @implements {import('../../types/dispatcher.d.ts').default.DispatchHandler}
* @typedef {import('../../types/dispatcher.d.ts').default.DispatchHandler} DispatchHandler
*
* @implements {DispatchHandler}
*/

@@ -23,2 +25,12 @@ class CacheHandler {

/**
* @type {import('../../types/cache-interceptor.d.ts').default.CacheHandlerOptions['type']}
*/
#cacheType
/**
* @type {number | undefined}
*/
#cacheByDefault
/**
* @type {import('../../types/cache-interceptor.d.ts').default.CacheStore}

@@ -43,6 +55,6 @@ */

*/
constructor (opts, cacheKey, handler) {
const { store } = opts
constructor ({ store, type, cacheByDefault }, cacheKey, handler) {
this.#store = store
this.#cacheType = type
this.#cacheByDefault = cacheByDefault
this.#cacheKey = cacheKey

@@ -91,3 +103,3 @@ this.#handler = handler

const cacheControlHeader = headers['cache-control']
if (!cacheControlHeader) {
if (!cacheControlHeader && !headers['expires'] && !this.#cacheByDefault) {
// Don't have the cache control header or the cache is full

@@ -97,15 +109,38 @@ return downstreamOnHeaders()

const cacheControlDirectives = parseCacheControlHeader(cacheControlHeader)
if (!canCacheResponse(statusCode, headers, cacheControlDirectives)) {
const cacheControlDirectives = cacheControlHeader ? parseCacheControlHeader(cacheControlHeader) : {}
if (!canCacheResponse(this.#cacheType, statusCode, headers, cacheControlDirectives)) {
return downstreamOnHeaders()
}
const age = getAge(headers)
const now = Date.now()
const staleAt = determineStaleAt(now, headers, cacheControlDirectives)
const staleAt = determineStaleAt(this.#cacheType, now, headers, cacheControlDirectives) ?? this.#cacheByDefault
if (staleAt) {
const varyDirectives = this.#cacheKey.headers && headers.vary
? parseVaryHeader(headers.vary, this.#cacheKey.headers)
: undefined
const deleteAt = determineDeleteAt(now, cacheControlDirectives, staleAt)
let baseTime = now
if (headers['date']) {
const parsedDate = parseInt(headers['date'])
const date = new Date(isNaN(parsedDate) ? headers['date'] : parsedDate)
if (date instanceof Date && !isNaN(date)) {
baseTime = date.getTime()
}
}
const absoluteStaleAt = staleAt + baseTime
if (now >= absoluteStaleAt || (age && age >= staleAt)) {
// Response is already stale
return downstreamOnHeaders()
}
let varyDirectives
if (this.#cacheKey.headers && headers.vary) {
varyDirectives = parseVaryHeader(headers.vary, this.#cacheKey.headers)
if (!varyDirectives) {
// Parse error
return downstreamOnHeaders()
}
}
const deleteAt = determineDeleteAt(cacheControlDirectives, absoluteStaleAt)
const strippedHeaders = stripNecessaryHeaders(headers, cacheControlDirectives)

@@ -121,4 +156,5 @@

vary: varyDirectives,
cachedAt: now,
staleAt,
cacheControlDirectives,
cachedAt: age ? now - (age * 1000) : now,
staleAt: absoluteStaleAt,
deleteAt

@@ -139,2 +175,3 @@ }

// TODO (fix): Make error somehow observable?
handler.#writeStream = undefined
})

@@ -178,7 +215,8 @@ .on('close', function () {

*
* @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType
* @param {number} statusCode
* @param {Record<string, string | string[]>} headers
* @param {import('../util/cache.js').CacheControlDirectives} cacheControlDirectives
* @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives
*/
function canCacheResponse (statusCode, headers, cacheControlDirectives) {
function canCacheResponse (cacheType, statusCode, headers, cacheControlDirectives) {
if (statusCode !== 200 && statusCode !== 307) {

@@ -189,3 +227,2 @@ return false

if (
cacheControlDirectives.private === true ||
cacheControlDirectives['no-cache'] === true ||

@@ -197,4 +234,8 @@ cacheControlDirectives['no-store']

if (cacheType === 'shared' && cacheControlDirectives.private === true) {
return false
}
// https://www.rfc-editor.org/rfc/rfc9111.html#section-4.1-5
if (headers.vary === '*') {
if (headers.vary?.includes('*')) {
return false

@@ -228,35 +269,59 @@ }

/**
* @param {Record<string, string | string[]>} headers
* @returns {number | undefined}
*/
function getAge (headers) {
if (!headers.age) {
return undefined
}
const age = parseInt(Array.isArray(headers.age) ? headers.age[0] : headers.age)
if (isNaN(age) || age >= 2147483647) {
return undefined
}
return age
}
/**
* @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType
* @param {number} now
* @param {Record<string, string | string[]>} headers
* @param {import('../util/cache.js').CacheControlDirectives} cacheControlDirectives
* @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives
*
* @returns {number | undefined} time that the value is stale at or undefined if it shouldn't be cached
*/
function determineStaleAt (now, headers, cacheControlDirectives) {
// Prioritize s-maxage since we're a shared cache
// s-maxage > max-age > Expire
// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.10-3
const sMaxAge = cacheControlDirectives['s-maxage']
if (sMaxAge) {
return now + (sMaxAge * 1000)
function determineStaleAt (cacheType, now, headers, cacheControlDirectives) {
if (cacheType === 'shared') {
// Prioritize s-maxage since we're a shared cache
// s-maxage > max-age > Expire
// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.10-3
const sMaxAge = cacheControlDirectives['s-maxage']
if (sMaxAge) {
return sMaxAge * 1000
}
}
if (cacheControlDirectives.immutable) {
// https://www.rfc-editor.org/rfc/rfc8246.html#section-2.2
return now + 31536000
}
const maxAge = cacheControlDirectives['max-age']
if (maxAge) {
return now + (maxAge * 1000)
return maxAge * 1000
}
if (headers.expire && typeof headers.expire === 'string') {
if (headers.expires && typeof headers.expires === 'string') {
// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.3
const expiresDate = new Date(headers.expire)
const expiresDate = new Date(headers.expires)
if (expiresDate instanceof Date && Number.isFinite(expiresDate.valueOf())) {
return now + (Date.now() - expiresDate.getTime())
if (now >= expiresDate.getTime()) {
return undefined
}
return expiresDate.getTime() - now
}
}
if (cacheControlDirectives.immutable) {
// https://www.rfc-editor.org/rfc/rfc8246.html#section-2.2
return 31536000
}
return undefined

@@ -266,12 +331,23 @@ }

/**
* @param {number} now
* @param {import('../util/cache.js').CacheControlDirectives} cacheControlDirectives
* @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives
* @param {number} staleAt
*/
function determineDeleteAt (now, cacheControlDirectives, staleAt) {
function determineDeleteAt (cacheControlDirectives, staleAt) {
let staleWhileRevalidate = -Infinity
let staleIfError = -Infinity
let immutable = -Infinity
if (cacheControlDirectives['stale-while-revalidate']) {
return now + (cacheControlDirectives['stale-while-revalidate'] * 1000)
staleWhileRevalidate = staleAt + (cacheControlDirectives['stale-while-revalidate'] * 1000)
}
return staleAt
if (cacheControlDirectives['stale-if-error']) {
staleIfError = staleAt + (cacheControlDirectives['stale-if-error'] * 1000)
}
if (staleWhileRevalidate === -Infinity && staleIfError === -Infinity) {
immutable = 31536000
}
return Math.max(staleAt, staleWhileRevalidate, staleIfError, immutable)
}

@@ -282,8 +358,30 @@

* @param {Record<string, string | string[]>} headers
* @param {import('../util/cache.js').CacheControlDirectives} cacheControlDirectives
* @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives
* @returns {Record<string, string | string []>}
*/
function stripNecessaryHeaders (headers, cacheControlDirectives) {
const headersToRemove = ['connection']
const headersToRemove = [
'connection',
'proxy-authenticate',
'proxy-authentication-info',
'proxy-authorization',
'proxy-connection',
'te',
'transfer-encoding',
'upgrade',
// We'll add age back when serving it
'age'
]
if (headers['connection']) {
if (Array.isArray(headers['connection'])) {
// connection: a
// connection: b
headersToRemove.push(...headers['connection'].map(header => header.trim()))
} else {
// connection: a, b
headersToRemove.push(...headers['connection'].split(',').map(header => header.trim()))
}
}
if (Array.isArray(cacheControlDirectives['no-cache'])) {

@@ -298,8 +396,9 @@ headersToRemove.push(...cacheControlDirectives['no-cache'])

let strippedHeaders
for (const headerName of Object.keys(headers)) {
if (headersToRemove.includes(headerName)) {
for (const headerName of headersToRemove) {
if (headers[headerName]) {
strippedHeaders ??= { ...headers }
delete headers[headerName]
delete strippedHeaders[headerName]
}
}
return strippedHeaders ?? headers

@@ -306,0 +405,0 @@ }

@@ -20,2 +20,3 @@ 'use strict'

#successful = false
/**

@@ -25,2 +26,3 @@ * @type {((boolean, any) => void) | null}

#callback
/**

@@ -34,6 +36,12 @@ * @type {(import('../../types/dispatcher.d.ts').default.DispatchHandler)}

/**
* @param {(boolean, any) => void} callback Function to call if the cached value is valid
* @param {import('../../types/dispatcher.d.ts').default.DispatchHandler} handler
* @type {boolean}
*/
constructor (callback, handler) {
#allowErrorStatusCodes
/**
* @param {(boolean) => void} callback Function to call if the cached value is valid
* @param {import('../../types/dispatcher.d.ts').default.DispatchHandlers} handler
* @param {boolean} allowErrorStatusCodes
*/
constructor (callback, handler, allowErrorStatusCodes) {
if (typeof callback !== 'function') {

@@ -45,5 +53,6 @@ throw new TypeError('callback must be a function')

this.#handler = handler
this.#allowErrorStatusCodes = allowErrorStatusCodes
}
onRequestStart (controller, context) {
onRequestStart (_, context) {
this.#successful = false

@@ -66,3 +75,5 @@ this.#context = context

// https://www.rfc-editor.org/rfc/rfc9111.html#name-handling-a-validation-respo
this.#successful = statusCode === 304
// https://datatracker.ietf.org/doc/html/rfc5861#section-4
this.#successful = statusCode === 304 ||
(this.#allowErrorStatusCodes && statusCode >= 500 && statusCode <= 504)
this.#callback(this.#successful, this.#context)

@@ -69,0 +80,0 @@ this.#callback = null

@@ -5,2 +5,5 @@ 'use strict'

/**
* @deprecated
*/
module.exports = class DecoratorHandler {

@@ -7,0 +10,0 @@ #handler

@@ -45,3 +45,2 @@ 'use strict'

this.location = null
this.abort = null
this.opts = { ...opts, maxRedirections: 0 } // opts must be a copy

@@ -87,16 +86,11 @@ this.maxRedirections = maxRedirections

onConnect (abort) {
this.abort = abort
this.handler.onConnect(abort, { history: this.history })
onRequestStart (controller, context) {
this.handler.onRequestStart?.(controller, { ...context, history: this.history })
}
onUpgrade (statusCode, headers, socket) {
this.handler.onUpgrade(statusCode, headers, socket)
onRequestUpgrade (controller, statusCode, headers, socket) {
this.handler.onRequestUpgrade?.(controller, statusCode, headers, socket)
}
onError (error) {
this.handler.onError(error)
}
onHeaders (statusCode, rawHeaders, resume, statusText) {
onResponseStart (controller, statusCode, statusMessage, headers) {
if (this.opts.throwOnMaxRedirect && this.history.length >= this.maxRedirections) {

@@ -127,5 +121,5 @@ throw new Error('max redirects')

this.location = this.history.length >= this.maxRedirections || util.isDisturbed(this.opts.body)
this.location = this.history.length >= this.maxRedirections || util.isDisturbed(this.opts.body) || redirectableStatusCodes.indexOf(statusCode) === -1
? null
: parseLocation(statusCode, rawHeaders)
: headers.location

@@ -137,3 +131,4 @@ if (this.opts.origin) {

if (!this.location) {
return this.handler.onHeaders(statusCode, rawHeaders, resume, statusText)
this.handler.onResponseStart?.(controller, statusCode, statusMessage, headers)
return
}

@@ -154,3 +149,3 @@

onData (chunk) {
onResponseData (controller, chunk) {
if (this.location) {

@@ -175,7 +170,7 @@ /*

} else {
return this.handler.onData(chunk)
this.handler.onResponseData?.(controller, chunk)
}
}
onComplete (trailers) {
onResponseEnd (controller, trailers) {
if (this.location) {

@@ -190,31 +185,13 @@ /*

*/
this.location = null
this.abort = null
this.dispatch(this.opts, this)
} else {
this.handler.onComplete(trailers)
this.handler.onResponseEnd(controller, trailers)
}
}
onBodySent (chunk) {
if (this.handler.onBodySent) {
this.handler.onBodySent(chunk)
}
onResponseError (controller, error) {
this.handler.onResponseError?.(controller, error)
}
}
function parseLocation (statusCode, rawHeaders) {
if (redirectableStatusCodes.indexOf(statusCode) === -1) {
return null
}
for (let i = 0; i < rawHeaders.length; i += 2) {
if (rawHeaders[i].length === 8 && util.headerNameToString(rawHeaders[i]) === 'location') {
return rawHeaders[i + 1]
}
}
}
// https://tools.ietf.org/html/rfc7231#section-6.4.4

@@ -221,0 +198,0 @@ function shouldRemoveHeader (header, removeContent, unknownOrigin) {

@@ -90,3 +90,3 @@ 'use strict'

onError (err) {
if (!this.#handler.onError) {
if (!this.#handler.onResponseError) {
throw new InvalidArgumentError('invalid onError method')

@@ -93,0 +93,0 @@ }

@@ -13,41 +13,11 @@ 'use strict'

/**
* @param {import('../../types/dispatcher.d.ts').default.DispatchHandler} handler
* @typedef {(options: import('../../types/dispatcher.d.ts').default.DispatchOptions, handler: import('../../types/dispatcher.d.ts').default.DispatchHandler) => void} DispatchFn
*/
function sendGatewayTimeout (handler) {
let aborted = false
try {
if (typeof handler.onConnect === 'function') {
handler.onConnect(() => {
aborted = true
})
if (aborted) {
return
}
}
if (typeof handler.onHeaders === 'function') {
handler.onHeaders(504, [], () => {}, 'Gateway Timeout')
if (aborted) {
return
}
}
if (typeof handler.onComplete === 'function') {
handler.onComplete([])
}
} catch (err) {
if (typeof handler.onError === 'function') {
handler.onError(err)
}
}
}
/**
* @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result
* @param {number} age
* @param {import('../util/cache.js').CacheControlDirectives | undefined} cacheControlDirectives
* @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives | undefined} cacheControlDirectives
* @returns {boolean}
*/
function needsRevalidation (result, age, cacheControlDirectives) {
function needsRevalidation (result, cacheControlDirectives) {
if (cacheControlDirectives?.['no-cache']) {

@@ -86,2 +56,218 @@ // Always revalidate requests with the no-cache directive

/**
* @param {DispatchFn} dispatch
* @param {import('../../types/cache-interceptor.d.ts').default.CacheHandlerOptions} globalOpts
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey
* @param {import('../../types/dispatcher.d.ts').default.DispatchHandler} handler
* @param {import('../../types/dispatcher.d.ts').default.RequestOptions} opts
* @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives | undefined} reqCacheControl
*/
function handleUncachedResponse (
dispatch,
globalOpts,
cacheKey,
handler,
opts,
reqCacheControl
) {
if (reqCacheControl?.['only-if-cached']) {
let aborted = false
try {
if (typeof handler.onConnect === 'function') {
handler.onConnect(() => {
aborted = true
})
if (aborted) {
return
}
}
if (typeof handler.onHeaders === 'function') {
handler.onHeaders(504, [], () => {}, 'Gateway Timeout')
if (aborted) {
return
}
}
if (typeof handler.onComplete === 'function') {
handler.onComplete([])
}
} catch (err) {
if (typeof handler.onError === 'function') {
handler.onError(err)
}
}
return true
}
return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler))
}
/**
* @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result
* @param {number} age
*/
function sendCachedValue (handler, opts, result, age, context) {
// TODO (perf): Readable.from path can be optimized...
const stream = util.isStream(result.body)
? result.body
: Readable.from(result.body ?? [])
assert(!stream.destroyed, 'stream should not be destroyed')
assert(!stream.readableDidRead, 'stream should not be readableDidRead')
const controller = {
resume () {
stream.resume()
},
pause () {
stream.pause()
},
get paused () {
return stream.isPaused()
},
get aborted () {
return stream.destroyed
},
get reason () {
return stream.errored
},
abort (reason) {
stream.destroy(reason ?? new AbortError())
}
}
stream
.on('error', function (err) {
if (!this.readableEnded) {
if (typeof handler.onResponseError === 'function') {
handler.onResponseError(controller, err)
} else {
throw err
}
}
})
.on('close', function () {
if (!this.errored) {
handler.onResponseEnd?.(controller, {})
}
})
handler.onRequestStart?.(controller, context)
if (stream.destroyed) {
return
}
// Add the age header
// https://www.rfc-editor.org/rfc/rfc9111.html#name-age
// TODO (fix): What if headers.age already exists?
const headers = age != null ? { ...result.headers, age: String(age) } : result.headers
handler.onResponseStart?.(controller, result.statusCode, result.statusMessage, headers)
if (opts.method === 'HEAD') {
stream.destroy()
} else {
stream.on('data', function (chunk) {
handler.onResponseData?.(controller, chunk)
})
}
}
/**
* @param {DispatchFn} dispatch
* @param {import('../../types/cache-interceptor.d.ts').default.CacheHandlerOptions} globalOpts
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey
* @param {import('../../types/dispatcher.d.ts').default.DispatchHandler} handler
* @param {import('../../types/dispatcher.d.ts').default.RequestOptions} opts
* @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives | undefined} reqCacheControl
* @param {import('../../types/cache-interceptor.d.ts').default.GetResult | undefined} result
*/
function handleResult (
dispatch,
globalOpts,
cacheKey,
handler,
opts,
reqCacheControl,
result
) {
if (!result) {
return handleUncachedResponse(dispatch, globalOpts, cacheKey, handler, opts, reqCacheControl)
}
if (!result.body && opts.method !== 'HEAD') {
throw new Error('body is undefined but method isn\'t HEAD')
}
const now = Date.now()
if (now > result.deleteAt) {
// Response is expired, cache store shouldn't have given this to us
return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler))
}
const age = Math.round((now - result.cachedAt) / 1000)
if (reqCacheControl?.['max-age'] && age >= reqCacheControl['max-age']) {
// Response is considered expired for this specific request
// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.1
return dispatch(opts, handler)
}
// Check if the response is stale
if (needsRevalidation(result, reqCacheControl)) {
if (util.isStream(opts.body) && util.bodyLength(opts.body) !== 0) {
// If body is is stream we can't revalidate...
// TODO (fix): This could be less strict...
return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler))
}
let withinStaleIfErrorThreshold = false
const staleIfErrorExpiry = result.cacheControlDirectives['stale-if-error'] ?? reqCacheControl?.['stale-if-error']
if (staleIfErrorExpiry) {
withinStaleIfErrorThreshold = now < (result.staleAt + (staleIfErrorExpiry * 1000))
}
let headers = {
...opts.headers,
'if-modified-since': new Date(result.cachedAt).toUTCString(),
'if-none-match': result.etag
}
if (result.vary) {
headers = {
...headers,
...result.vary
}
}
// We need to revalidate the response
return dispatch(
{
...opts,
headers
},
new CacheRevalidationHandler(
(success, context) => {
if (success) {
sendCachedValue(handler, opts, result, age, context)
} else if (util.isStream(result.body)) {
result.body.on('error', () => {}).destroy()
}
},
new CacheHandler(globalOpts, cacheKey, handler),
withinStaleIfErrorThreshold
)
)
}
// Dump request body.
if (util.isStream(opts.body)) {
opts.body.on('error', () => {}).destroy()
}
sendCachedValue(handler, opts, result, age, null)
}
/**
* @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions} [opts]

@@ -93,3 +279,5 @@ * @returns {import('../../types/dispatcher.d.ts').default.DispatcherComposeInterceptor}

store = new MemoryCacheStore(),
methods = ['GET']
methods = ['GET'],
cacheByDefault = undefined,
type = 'shared'
} = opts

@@ -104,5 +292,15 @@

if (typeof cacheByDefault !== 'undefined' && typeof cacheByDefault !== 'number') {
throw new TypeError(`exepcted opts.cacheByDefault to be number or undefined, got ${typeof cacheByDefault}`)
}
if (typeof type !== 'undefined' && type !== 'shared' && type !== 'private') {
throw new TypeError(`exepcted opts.type to be shared, private, or undefined, got ${typeof type}`)
}
const globalOpts = {
store,
methods
methods,
cacheByDefault,
type
}

@@ -114,5 +312,2 @@

return (opts, handler) => {
// TODO (fix): What if e.g. opts.headers has if-modified-since header? Or other headers
// that make things ambigious?
if (!opts.origin || safeMethodsToNotCache.includes(opts.method)) {

@@ -123,7 +318,7 @@ // Not a method we want to cache or we don't have the origin, skip

const requestCacheControl = opts.headers?.['cache-control']
const reqCacheControl = opts.headers?.['cache-control']
? parseCacheControlHeader(opts.headers['cache-control'])
: undefined
if (requestCacheControl?.['no-store']) {
if (reqCacheControl?.['no-store']) {
return dispatch(opts, handler)

@@ -136,168 +331,25 @@ }

const cacheKey = makeCacheKey(opts)
// TODO (perf): For small entries support returning a Buffer instead of a stream.
// Maybe store should return { staleAt, headers, body, etc... } instead of a stream + stream.value?
// Where body can be a Buffer, string, stream or blob?
const result = store.get(cacheKey)
if (!result) {
if (requestCacheControl?.['only-if-cached']) {
// We only want cached responses
// https://www.rfc-editor.org/rfc/rfc9111.html#name-only-if-cached
sendGatewayTimeout(handler)
return true
}
return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler))
}
/**
* @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result
* @param {number} age
*/
const respondWithCachedValue = ({ headers, statusCode, statusMessage, body }, age, context) => {
const stream = util.isStream(body)
? body
: Readable.from(body ?? [])
assert(!stream.destroyed, 'stream should not be destroyed')
assert(!stream.readableDidRead, 'stream should not be readableDidRead')
const controller = {
resume () {
stream.resume()
},
pause () {
stream.pause()
},
get paused () {
return stream.isPaused()
},
get aborted () {
return stream.destroyed
},
get reason () {
return stream.errored
},
abort (reason) {
stream.destroy(reason ?? new AbortError())
}
}
stream
.on('error', function (err) {
if (!this.readableEnded) {
if (typeof handler.onResponseError === 'function') {
handler.onResponseError(controller, err)
} else {
throw err
}
}
})
.on('close', function () {
if (!this.errored) {
handler.onResponseEnd?.(controller, {})
}
})
handler.onRequestStart?.(controller, context)
if (stream.destroyed) {
return
}
// Add the age header
// https://www.rfc-editor.org/rfc/rfc9111.html#name-age
// TODO (fix): What if headers.age already exists?
headers = age != null ? { ...headers, age: String(age) } : headers
handler.onResponseStart?.(controller, statusCode, statusMessage, headers)
if (opts.method === 'HEAD') {
stream.destroy()
} else {
stream.on('data', function (chunk) {
handler.onResponseData?.(controller, chunk)
})
}
}
/**
* @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result
*/
const handleResult = (result) => {
// TODO (perf): Readable.from path can be optimized...
if (!result.body && opts.method !== 'HEAD') {
throw new Error('stream is undefined but method isn\'t HEAD')
}
const age = Math.round((Date.now() - result.cachedAt) / 1000)
if (requestCacheControl?.['max-age'] && age >= requestCacheControl['max-age']) {
// Response is considered expired for this specific request
// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.1
return dispatch(opts, handler)
}
// Check if the response is stale
if (needsRevalidation(result, age, requestCacheControl)) {
if (util.isStream(opts.body) && util.bodyLength(opts.body) !== 0) {
// If body is is stream we can't revalidate...
// TODO (fix): This could be less strict...
return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler))
}
// We need to revalidate the response
return dispatch(
{
...opts,
headers: {
...opts.headers,
'if-modified-since': new Date(result.cachedAt).toUTCString(),
etag: result.etag
}
},
new CacheRevalidationHandler(
(success, context) => {
if (success) {
respondWithCachedValue(result, age, context)
} else if (util.isStream(result.body)) {
result.body.on('error', () => {}).destroy()
}
},
new CacheHandler(globalOpts, cacheKey, handler)
)
if (result && typeof result.then === 'function') {
result.then(result => {
handleResult(dispatch,
globalOpts,
cacheKey,
handler,
opts,
reqCacheControl,
result
)
}
// Dump request body.
if (util.isStream(opts.body)) {
opts.body.on('error', () => {}).destroy()
}
respondWithCachedValue(result, age, null)
}
if (typeof result.then === 'function') {
result.then((result) => {
if (!result) {
if (requestCacheControl?.['only-if-cached']) {
// We only want cached responses
// https://www.rfc-editor.org/rfc/rfc9111.html#name-only-if-cached
sendGatewayTimeout(handler)
return true
}
dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler))
} else {
handleResult(result)
}
}, err => {
if (typeof handler.onError === 'function') {
handler.onError(err)
} else {
throw err
}
})
} else {
handleResult(result)
handleResult(
dispatch,
globalOpts,
cacheKey,
handler,
opts,
reqCacheControl,
result
)
}

@@ -304,0 +356,0 @@

@@ -99,32 +99,23 @@ 'use strict'

* @see https://www.iana.org/assignments/http-cache-directives/http-cache-directives.xhtml
*
* @typedef {{
* 'max-stale'?: number;
* 'min-fresh'?: number;
* 'max-age'?: number;
* 's-maxage'?: number;
* 'stale-while-revalidate'?: number;
* 'stale-if-error'?: number;
* public?: true;
* private?: true | string[];
* 'no-store'?: true;
* 'no-cache'?: true | string[];
* 'must-revalidate'?: true;
* 'proxy-revalidate'?: true;
* immutable?: true;
* 'no-transform'?: true;
* 'must-understand'?: true;
* 'only-if-cached'?: true;
* }} CacheControlDirectives
*
* @param {string | string[]} header
* @returns {CacheControlDirectives}
* @returns {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives}
*/
function parseCacheControlHeader (header) {
/**
* @type {import('../util/cache.js').CacheControlDirectives}
* @type {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives}
*/
const output = {}
const directives = Array.isArray(header) ? header : header.split(',')
let directives
if (Array.isArray(header)) {
directives = []
for (const directive of header) {
directives.push(...directive.split(','))
}
} else {
directives = header.split(',')
}
for (let i = 0; i < directives.length; i++) {

@@ -137,6 +128,4 @@ const directive = directives[i].toLowerCase()

if (keyValueDelimiter !== -1) {
key = directive.substring(0, keyValueDelimiter).trim()
value = directive
.substring(keyValueDelimiter + 1)
.trim()
key = directive.substring(0, keyValueDelimiter).trimStart()
value = directive.substring(keyValueDelimiter + 1)
} else {

@@ -153,6 +142,14 @@ key = directive.trim()

case 'stale-if-error': {
if (value === undefined) {
if (value === undefined || value[0] === ' ') {
continue
}
if (
value.length >= 2 &&
value[0] === '"' &&
value[value.length - 1] === '"'
) {
value = value.substring(1, value.length - 1)
}
const parsedValue = parseInt(value, 10)

@@ -164,2 +161,6 @@ // eslint-disable-next-line no-self-compare

if (key === 'max-age' && key in output && output[key] >= parsedValue) {
continue
}
output[key] = parsedValue

@@ -213,7 +214,15 @@

output[key] = headers
if (key in output) {
output[key] = output[key].concat(headers)
} else {
output[key] = headers
}
}
} else {
// Something like `no-cache=some-header`
output[key] = [value]
if (key in output) {
output[key] = output[key].concat(value)
} else {
output[key] = [value]
}
}

@@ -256,3 +265,3 @@

function parseVaryHeader (varyHeader, headers) {
if (typeof varyHeader === 'string' && varyHeader === '*') {
if (typeof varyHeader === 'string' && varyHeader.includes('*')) {
return headers

@@ -271,2 +280,4 @@ }

output[trimmedHeader] = headers[trimmedHeader]
} else {
return undefined
}

@@ -273,0 +284,0 @@ }

{
"name": "undici",
"version": "7.0.0-alpha.7",
"version": "7.0.0-alpha.8",
"description": "An HTTP/1.1 client, written from scratch for Node.js",

@@ -76,2 +76,3 @@ "homepage": "https://undici.nodejs.org",

"test:cache": "borp -p \"test/cache/*.js\"",
"test:sqlite": "NODE_OPTIONS=--experimental-sqlite borp -p \"test/cache-interceptor/*.js\"",
"test:cache-interceptor": "borp -p \"test/cache-interceptor/*.js\"",

@@ -114,3 +115,3 @@ "test:cookies": "borp -p \"test/cookie/*.js\"",

"abort-controller": "^3.0.0",
"borp": "^0.18.0",
"borp": "^0.19.0",
"c8": "^10.0.0",

@@ -133,3 +134,3 @@ "cross-env": "^7.0.3",

"engines": {
"node": ">=18.17"
"node": ">=20.18.1"
},

@@ -136,0 +137,0 @@ "tsd": {

@@ -10,2 +10,6 @@ import { Readable, Writable } from 'node:stream'

store: CacheStore
cacheByDefault?: number
type?: CacheOptions['type']
}

@@ -24,4 +28,37 @@

methods?: CacheMethods[]
/**
* RFC9111 allows for caching responses that we aren't explicitly told to
* cache or to not cache.
* @see https://www.rfc-editor.org/rfc/rfc9111.html#section-3-5
* @default undefined
*/
cacheByDefault?: number
/**
* TODO docs
* @default 'shared'
*/
type?: 'shared' | 'private'
}
export interface CacheControlDirectives {
'max-stale'?: number;
'min-fresh'?: number;
'max-age'?: number;
's-maxage'?: number;
'stale-while-revalidate'?: number;
'stale-if-error'?: number;
public?: true;
private?: true | string[];
'no-store'?: true;
'no-cache'?: true | string[];
'must-revalidate'?: true;
'proxy-revalidate'?: true;
immutable?: true;
'no-transform'?: true;
'must-understand'?: true;
'only-if-cached'?: true;
}
export interface CacheKey {

@@ -40,2 +77,3 @@ origin: string

etag?: string
cacheControlDirectives?: CacheControlDirectives
cachedAt: number

@@ -56,4 +94,6 @@ staleAt: number

headers: Record<string, string | string[]>
vary?: Record<string, string | string[]>
etag?: string
body: null | Readable | Iterable<Buffer> | AsyncIterable<Buffer> | Buffer | Iterable<string> | AsyncIterable<string> | string
cacheControlDirectives: CacheControlDirectives,
cachedAt: number

@@ -60,0 +100,0 @@ staleAt: number

SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc