aboutsummaryrefslogtreecommitdiffstats
path: root/vanilla/node_modules/undici/lib/interceptor/cache.js
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-13 21:34:48 -0800
committerAdam Mathes <adam@adammathes.com>2026-02-13 21:34:48 -0800
commit76cb9c2a39d477a64824a985ade40507e3bbade1 (patch)
tree41e997aa9c6f538d3a136af61dae9424db2005a9 /vanilla/node_modules/undici/lib/interceptor/cache.js
parent819a39a21ac992b1393244a4c283bbb125208c69 (diff)
downloadneko-76cb9c2a39d477a64824a985ade40507e3bbade1.tar.gz
neko-76cb9c2a39d477a64824a985ade40507e3bbade1.tar.bz2
neko-76cb9c2a39d477a64824a985ade40507e3bbade1.zip
feat(vanilla): add testing infrastructure and tests (NK-wjnczv)
Diffstat (limited to 'vanilla/node_modules/undici/lib/interceptor/cache.js')
-rw-r--r--vanilla/node_modules/undici/lib/interceptor/cache.js495
1 files changed, 495 insertions, 0 deletions
diff --git a/vanilla/node_modules/undici/lib/interceptor/cache.js b/vanilla/node_modules/undici/lib/interceptor/cache.js
new file mode 100644
index 0000000..81d7cb1
--- /dev/null
+++ b/vanilla/node_modules/undici/lib/interceptor/cache.js
@@ -0,0 +1,495 @@
+'use strict'
+
+const assert = require('node:assert')
+const { Readable } = require('node:stream')
+const util = require('../core/util')
+const CacheHandler = require('../handler/cache-handler')
+const MemoryCacheStore = require('../cache/memory-cache-store')
+const CacheRevalidationHandler = require('../handler/cache-revalidation-handler')
+const { assertCacheStore, assertCacheMethods, makeCacheKey, normalizeHeaders, parseCacheControlHeader } = require('../util/cache.js')
+const { AbortError } = require('../core/errors.js')
+
+/**
+ * @param {(string | RegExp)[] | undefined} origins
+ * @param {string} name
+ */
+function assertCacheOrigins (origins, name) {
+ if (origins === undefined) return
+ if (!Array.isArray(origins)) {
+ throw new TypeError(`expected ${name} to be an array or undefined, got ${typeof origins}`)
+ }
+ for (let i = 0; i < origins.length; i++) {
+ const origin = origins[i]
+ if (typeof origin !== 'string' && !(origin instanceof RegExp)) {
+ throw new TypeError(`expected ${name}[${i}] to be a string or RegExp, got ${typeof origin}`)
+ }
+ }
+}
+
+const nop = () => {}
+
+/**
+ * @typedef {(options: import('../../types/dispatcher.d.ts').default.DispatchOptions, handler: import('../../types/dispatcher.d.ts').default.DispatchHandler) => void} DispatchFn
+ */
+
+/**
+ * @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result
+ * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives | undefined} cacheControlDirectives
+ * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} opts
+ * @returns {boolean}
+ */
+function needsRevalidation (result, cacheControlDirectives, { headers = {} }) {
+ // Always revalidate requests with the no-cache request directive.
+ if (cacheControlDirectives?.['no-cache']) {
+ return true
+ }
+
+ // Always revalidate requests with unqualified no-cache response directive.
+ if (result.cacheControlDirectives?.['no-cache'] && !Array.isArray(result.cacheControlDirectives['no-cache'])) {
+ return true
+ }
+
+ // Always revalidate requests with conditional headers.
+ if (headers['if-modified-since'] || headers['if-none-match']) {
+ return true
+ }
+
+ return false
+}
+
+/**
+ * @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result
+ * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives | undefined} cacheControlDirectives
+ * @returns {boolean}
+ */
+function isStale (result, cacheControlDirectives) {
+ const now = Date.now()
+ if (now > result.staleAt) {
+ // Response is stale
+ if (cacheControlDirectives?.['max-stale']) {
+ // There's a threshold where we can serve stale responses, let's see if
+ // we're in it
+ // https://www.rfc-editor.org/rfc/rfc9111.html#name-max-stale
+ const gracePeriod = result.staleAt + (cacheControlDirectives['max-stale'] * 1000)
+ return now > gracePeriod
+ }
+
+ return true
+ }
+
+ if (cacheControlDirectives?.['min-fresh']) {
+ // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.3
+
+ // At this point, staleAt is always > now
+ const timeLeftTillStale = result.staleAt - now
+ const threshold = cacheControlDirectives['min-fresh'] * 1000
+
+ return timeLeftTillStale <= threshold
+ }
+
+ return false
+}
+
+/**
+ * Check if we're within the stale-while-revalidate window for a stale response
+ * @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result
+ * @returns {boolean}
+ */
+function withinStaleWhileRevalidateWindow (result) {
+ const staleWhileRevalidate = result.cacheControlDirectives?.['stale-while-revalidate']
+ if (!staleWhileRevalidate) {
+ return false
+ }
+
+ const now = Date.now()
+ const staleWhileRevalidateExpiry = result.staleAt + (staleWhileRevalidate * 1000)
+ return now <= staleWhileRevalidateExpiry
+}
+
+/**
+ * @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, [], nop, '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/dispatcher.d.ts').default.DispatchHandler} handler
+ * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} opts
+ * @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result
+ * @param {number} age
+ * @param {any} context
+ * @param {boolean} isStale
+ */
+function sendCachedValue (handler, opts, result, age, context, isStale) {
+ // 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
+ const headers = { ...result.headers, age: String(age) }
+
+ if (isStale) {
+ // Add warning header
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Warning
+ headers.warning = '110 - "response is stale"'
+ }
+
+ handler.onResponseStart?.(controller, result.statusCode, headers, result.statusMessage)
+
+ 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)
+ }
+
+ 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)
+ }
+
+ const stale = isStale(result, reqCacheControl)
+ const revalidate = needsRevalidation(result, reqCacheControl, opts)
+
+ // Check if the response is stale
+ if (stale || revalidate) {
+ if (util.isStream(opts.body) && util.bodyLength(opts.body) !== 0) {
+ // If body is a stream we can't revalidate...
+ // TODO (fix): This could be less strict...
+ return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler))
+ }
+
+ // RFC 5861: If we're within stale-while-revalidate window, serve stale immediately
+ // and revalidate in background, unless immediate revalidation is necessary
+ if (!revalidate && withinStaleWhileRevalidateWindow(result)) {
+ // Serve stale response immediately
+ sendCachedValue(handler, opts, result, age, null, true)
+
+ // Start background revalidation (fire-and-forget)
+ queueMicrotask(() => {
+ const headers = {
+ ...opts.headers,
+ 'if-modified-since': new Date(result.cachedAt).toUTCString()
+ }
+
+ if (result.etag) {
+ headers['if-none-match'] = result.etag
+ }
+
+ if (result.vary) {
+ for (const key in result.vary) {
+ if (result.vary[key] != null) {
+ headers[key] = result.vary[key]
+ }
+ }
+ }
+
+ // Background revalidation - update cache if we get new data
+ dispatch(
+ {
+ ...opts,
+ headers
+ },
+ new CacheHandler(globalOpts, cacheKey, {
+ // Silent handler that just updates the cache
+ onRequestStart () {},
+ onRequestUpgrade () {},
+ onResponseStart () {},
+ onResponseData () {},
+ onResponseEnd () {},
+ onResponseError () {}
+ })
+ )
+ })
+
+ return true
+ }
+
+ let withinStaleIfErrorThreshold = false
+ const staleIfErrorExpiry = result.cacheControlDirectives['stale-if-error'] ?? reqCacheControl?.['stale-if-error']
+ if (staleIfErrorExpiry) {
+ withinStaleIfErrorThreshold = now < (result.staleAt + (staleIfErrorExpiry * 1000))
+ }
+
+ const headers = {
+ ...opts.headers,
+ 'if-modified-since': new Date(result.cachedAt).toUTCString()
+ }
+
+ if (result.etag) {
+ headers['if-none-match'] = result.etag
+ }
+
+ if (result.vary) {
+ for (const key in result.vary) {
+ if (result.vary[key] != null) {
+ headers[key] = result.vary[key]
+ }
+ }
+ }
+
+ // We need to revalidate the response
+ return dispatch(
+ {
+ ...opts,
+ headers
+ },
+ new CacheRevalidationHandler(
+ (success, context) => {
+ if (success) {
+ // TODO: successful revalidation should be considered fresh (not give stale warning).
+ sendCachedValue(handler, opts, result, age, context, stale)
+ } else if (util.isStream(result.body)) {
+ result.body.on('error', nop).destroy()
+ }
+ },
+ new CacheHandler(globalOpts, cacheKey, handler),
+ withinStaleIfErrorThreshold
+ )
+ )
+ }
+
+ // Dump request body.
+ if (util.isStream(opts.body)) {
+ opts.body.on('error', nop).destroy()
+ }
+
+ sendCachedValue(handler, opts, result, age, null, false)
+}
+
+/**
+ * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions} [opts]
+ * @returns {import('../../types/dispatcher.d.ts').default.DispatcherComposeInterceptor}
+ */
+module.exports = (opts = {}) => {
+ const {
+ store = new MemoryCacheStore(),
+ methods = ['GET'],
+ cacheByDefault = undefined,
+ type = 'shared',
+ origins = undefined
+ } = opts
+
+ if (typeof opts !== 'object' || opts === null) {
+ throw new TypeError(`expected type of opts to be an Object, got ${opts === null ? 'null' : typeof opts}`)
+ }
+
+ assertCacheStore(store, 'opts.store')
+ assertCacheMethods(methods, 'opts.methods')
+ assertCacheOrigins(origins, 'opts.origins')
+
+ if (typeof cacheByDefault !== 'undefined' && typeof cacheByDefault !== 'number') {
+ throw new TypeError(`expected opts.cacheByDefault to be number or undefined, got ${typeof cacheByDefault}`)
+ }
+
+ if (typeof type !== 'undefined' && type !== 'shared' && type !== 'private') {
+ throw new TypeError(`expected opts.type to be shared, private, or undefined, got ${typeof type}`)
+ }
+
+ const globalOpts = {
+ store,
+ methods,
+ cacheByDefault,
+ type
+ }
+
+ const safeMethodsToNotCache = util.safeHTTPMethods.filter(method => methods.includes(method) === false)
+
+ return dispatch => {
+ return (opts, handler) => {
+ if (!opts.origin || safeMethodsToNotCache.includes(opts.method)) {
+ // Not a method we want to cache or we don't have the origin, skip
+ return dispatch(opts, handler)
+ }
+
+ // Check if origin is in whitelist
+ if (origins !== undefined) {
+ const requestOrigin = opts.origin.toString().toLowerCase()
+ let isAllowed = false
+
+ for (let i = 0; i < origins.length; i++) {
+ const allowed = origins[i]
+ if (typeof allowed === 'string') {
+ if (allowed.toLowerCase() === requestOrigin) {
+ isAllowed = true
+ break
+ }
+ } else if (allowed.test(requestOrigin)) {
+ isAllowed = true
+ break
+ }
+ }
+
+ if (!isAllowed) {
+ return dispatch(opts, handler)
+ }
+ }
+
+ opts = {
+ ...opts,
+ headers: normalizeHeaders(opts)
+ }
+
+ const reqCacheControl = opts.headers?.['cache-control']
+ ? parseCacheControlHeader(opts.headers['cache-control'])
+ : undefined
+
+ if (reqCacheControl?.['no-store']) {
+ return dispatch(opts, handler)
+ }
+
+ /**
+ * @type {import('../../types/cache-interceptor.d.ts').default.CacheKey}
+ */
+ const cacheKey = makeCacheKey(opts)
+ const result = store.get(cacheKey)
+
+ if (result && typeof result.then === 'function') {
+ return result
+ .then(result => handleResult(dispatch,
+ globalOpts,
+ cacheKey,
+ handler,
+ opts,
+ reqCacheControl,
+ result
+ ))
+ } else {
+ return handleResult(
+ dispatch,
+ globalOpts,
+ cacheKey,
+ handler,
+ opts,
+ reqCacheControl,
+ result
+ )
+ }
+ }
+ }
+}