初始化
This commit is contained in:
433
node_modules/undici/lib/interceptor/cache.js
generated
vendored
Normal file
433
node_modules/undici/lib/interceptor/cache.js
generated
vendored
Normal file
@@ -0,0 +1,433 @@
|
||||
'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')
|
||||
|
||||
/**
|
||||
* @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
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function needsRevalidation (result, cacheControlDirectives) {
|
||||
if (cacheControlDirectives?.['no-cache']) {
|
||||
// Always revalidate requests with the no-cache request directive
|
||||
return true
|
||||
}
|
||||
|
||||
if (result.cacheControlDirectives?.['no-cache'] && !Array.isArray(result.cacheControlDirectives['no-cache'])) {
|
||||
// Always revalidate requests with unqualified no-cache response directive
|
||||
return true
|
||||
}
|
||||
|
||||
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, [], () => {}, '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)
|
||||
}
|
||||
|
||||
// Check if the response is stale
|
||||
if (needsRevalidation(result, reqCacheControl)) {
|
||||
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
|
||||
if (withinStaleWhileRevalidateWindow(result)) {
|
||||
// Serve stale response immediately
|
||||
sendCachedValue(handler, opts, result, age, null, true)
|
||||
|
||||
// Start background revalidation (fire-and-forget)
|
||||
queueMicrotask(() => {
|
||||
let headers = {
|
||||
...opts.headers,
|
||||
'if-modified-since': new Date(result.cachedAt).toUTCString()
|
||||
}
|
||||
|
||||
if (result.etag) {
|
||||
headers['if-none-match'] = result.etag
|
||||
}
|
||||
|
||||
if (result.vary) {
|
||||
headers = {
|
||||
...headers,
|
||||
...result.vary
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
let headers = {
|
||||
...opts.headers,
|
||||
'if-modified-since': new Date(result.cachedAt).toUTCString()
|
||||
}
|
||||
|
||||
if (result.etag) {
|
||||
headers['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, true)
|
||||
} 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, 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'
|
||||
} = 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')
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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') {
|
||||
result.then(result => {
|
||||
handleResult(dispatch,
|
||||
globalOpts,
|
||||
cacheKey,
|
||||
handler,
|
||||
opts,
|
||||
reqCacheControl,
|
||||
result
|
||||
)
|
||||
})
|
||||
} else {
|
||||
handleResult(
|
||||
dispatch,
|
||||
globalOpts,
|
||||
cacheKey,
|
||||
handler,
|
||||
opts,
|
||||
reqCacheControl,
|
||||
result
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user