Plan Checker, Wochenbericht-Fokus-Modus und Wandtafel-Dashboard-Feinschliff
Plan Checker (neues Studenten-Werkzeug, Seitenleiste + Admin → Plan-Checker- Merkmale): lädt einen Bauplan als PDF hoch und lässt ChatGPT oder Claude ihn gegen eine gepflegte Merkmalsliste prüfen. PDF-Rendering (pdf.js + @napi-rs/canvas) berücksichtigt jetzt Ebenen (Optional Content Groups) und WASM-Decoder korrekt, sendet eine Übersicht plus mehrere hochaufgelöste Kachel-Ausschnitte statt eines einzelnen, zu groben Bilds, und beide Anbieter bekommen dasselbe Bildmaterial. Jedes Prüfergebnis kann auf eines der gesendeten Bilder als Beleg verweisen (bildIndex) — als Screenshot im Ergebnis anklickbar, öffnet vergrößert in einem Modal statt eines von Chrome blockierten target=_blank-Links auf eine data:-URL. KI-Modellname ist jetzt pro Konto überschreibbar (Einstellungen → Mein Konto) für Drittanbieter mit abweichendem Modell-Katalog. Wochenbericht: dichte Kartenansicht bekommt einen Fokus-Modus — nur der Kommentar liegt offen, Leistungsphasen und Beteiligte stecken in aufklappbaren Abschnitten statt dauerhaft nebeneinander zu stehen. Projektbereich: Terminplan-Auslastung rechnet jetzt exakt wie der Auslastungs-Tab/die Wandtafel (statt einer unabhängigen, tageweisen Schätzung); die LPH-Fortschrittsliste im Dashboard zeigt nur noch die beauftragten Leistungsphasen. Wandtafel: Dashboard-Kopf mit Kennzahlen-Reihe und Panel-Reihe neu strukturiert, in den Blautönen des Firmenlogos; dieselbe Palette wurde anschließend auf den gesamten App-weiten Dark Mode übernommen. Dateibereinigung: nicht mehr benötigte Test-Uploads/Backups entfernt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
db63a29365
commit
6ba93b2a92
308 files changed
+64441
-743
No files matched your search
+818
@@ -0,0 +1,818 @@
|
||||
'use strict'
|
||||
|
||||
const util = require('../core/util')
|
||||
const {
|
||||
parseCacheControlHeader,
|
||||
hasInvalidCacheControlDirective,
|
||||
parseVaryHeader,
|
||||
hasVaryStar,
|
||||
isInvalidOrWildcardVaryHeader,
|
||||
isEtagUsable
|
||||
} = require('../util/cache')
|
||||
const { parseHttpDate } = require('../util/date.js')
|
||||
|
||||
function noop () {}
|
||||
|
||||
// Status codes that we can use some heuristics on to cache
|
||||
const HEURISTICALLY_CACHEABLE_STATUS_CODES = [
|
||||
200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 501
|
||||
]
|
||||
|
||||
// Status codes which semantic is not handled by the cache
|
||||
// https://datatracker.ietf.org/doc/html/rfc9111#section-3
|
||||
// This list should not grow beyond 206 unless the RFC is updated
|
||||
// by a newer one including more. Please introduce another list if
|
||||
// implementing caching of responses with the 'must-understand' directive.
|
||||
const NOT_UNDERSTOOD_STATUS_CODES = [
|
||||
206
|
||||
]
|
||||
|
||||
const MAX_RESPONSE_AGE = 2147483647000
|
||||
|
||||
// Retention for revalidation-only entries (zero freshness lifetime but a
|
||||
// validator present); each successful revalidation re-stores the entry.
|
||||
const REVALIDATION_ONLY_RETENTION = 86400000 // 24 hours
|
||||
|
||||
function trimOWS (value) {
|
||||
return value.replace(/^[\t ]+|[\t ]+$/g, '')
|
||||
}
|
||||
|
||||
function arrayIncludes (array, value) {
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
if (array[i] === value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function appendConnectionHeaderTokens (headersToRemove, connectionHeader) {
|
||||
const values = Array.isArray(connectionHeader) ? connectionHeader : [connectionHeader]
|
||||
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const tokens = values[i].split(',')
|
||||
for (let j = 0; j < tokens.length; j++) {
|
||||
headersToRemove.push(trimOWS(tokens[j]).toLowerCase())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSameOriginPath (cacheKey, location) {
|
||||
if (typeof location !== 'string') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let originUrl
|
||||
let requestUrl
|
||||
let locationUrl
|
||||
try {
|
||||
originUrl = new URL(cacheKey.origin)
|
||||
requestUrl = new URL(cacheKey.path, originUrl)
|
||||
locationUrl = new URL(location, requestUrl)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (locationUrl.origin !== originUrl.origin) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return locationUrl.pathname + locationUrl.search
|
||||
}
|
||||
|
||||
function deleteCachedUri (store, cacheKey, path) {
|
||||
deleteCachedValue(store, {
|
||||
...cacheKey,
|
||||
path
|
||||
})
|
||||
|
||||
for (let i = 0; i < util.safeHTTPMethods.length; i++) {
|
||||
const method = util.safeHTTPMethods[i]
|
||||
if (method !== cacheKey.method) {
|
||||
deleteCachedValue(store, {
|
||||
...cacheKey,
|
||||
method,
|
||||
path
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deleteLocationTargets (store, cacheKey, headerValue) {
|
||||
if (headerValue === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const values = Array.isArray(headerValue) ? headerValue : [headerValue]
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const path = getSameOriginPath(cacheKey, values[i])
|
||||
if (path !== undefined) {
|
||||
deleteCachedUri(store, cacheKey, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function invalidateUnsafeRequest (store, cacheKey, resHeaders) {
|
||||
deleteCachedUri(store, cacheKey, cacheKey.path)
|
||||
deleteLocationTargets(store, cacheKey, resHeaders.location)
|
||||
deleteLocationTargets(store, cacheKey, resHeaders['content-location'])
|
||||
}
|
||||
/**
|
||||
* @typedef {import('../../types/dispatcher.d.ts').default.DispatchHandler} DispatchHandler
|
||||
*
|
||||
* @implements {DispatchHandler}
|
||||
*/
|
||||
class CacheHandler {
|
||||
/**
|
||||
* @type {import('../../types/cache-interceptor.d.ts').default.CacheKey}
|
||||
*/
|
||||
#cacheKey
|
||||
|
||||
/**
|
||||
* @type {import('../../types/cache-interceptor.d.ts').default.CacheHandlerOptions['type']}
|
||||
*/
|
||||
#cacheType
|
||||
|
||||
/**
|
||||
* @type {number | undefined}
|
||||
*/
|
||||
#cacheByDefault
|
||||
|
||||
/**
|
||||
* @type {import('../../types/cache-interceptor.d.ts').default.CacheStore}
|
||||
*/
|
||||
#store
|
||||
|
||||
/**
|
||||
* @type {import('../../types/dispatcher.d.ts').default.DispatchHandler}
|
||||
*/
|
||||
#handler
|
||||
|
||||
/**
|
||||
* @type {import('node:stream').Writable | undefined}
|
||||
*/
|
||||
#writeStream
|
||||
|
||||
/**
|
||||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheHandlerOptions} opts
|
||||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey
|
||||
* @param {import('../../types/dispatcher.d.ts').default.DispatchHandler} handler
|
||||
*/
|
||||
constructor ({ store, type, cacheByDefault }, cacheKey, handler) {
|
||||
this.#store = store
|
||||
this.#cacheType = type
|
||||
this.#cacheByDefault = cacheByDefault
|
||||
this.#cacheKey = cacheKey
|
||||
this.#handler = handler
|
||||
}
|
||||
|
||||
onRequestStart (controller, context) {
|
||||
this.#writeStream?.destroy()
|
||||
this.#writeStream = undefined
|
||||
this.#handler.onRequestStart?.(controller, context)
|
||||
}
|
||||
|
||||
onBodySent (chunk) {
|
||||
this.#handler.onBodySent?.(chunk)
|
||||
}
|
||||
|
||||
onRequestSent () {
|
||||
this.#handler.onRequestSent?.()
|
||||
}
|
||||
|
||||
onRequestUpgrade (controller, statusCode, headers, socket) {
|
||||
this.#handler.onRequestUpgrade?.(controller, statusCode, headers, socket)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../../types/dispatcher.d.ts').default.DispatchController} controller
|
||||
* @param {number} statusCode
|
||||
* @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders
|
||||
* @param {string} statusMessage
|
||||
*/
|
||||
onResponseStart (
|
||||
controller,
|
||||
statusCode,
|
||||
resHeaders,
|
||||
statusMessage
|
||||
) {
|
||||
const downstreamOnHeaders = () =>
|
||||
this.#handler.onResponseStart?.(
|
||||
controller,
|
||||
statusCode,
|
||||
resHeaders,
|
||||
statusMessage
|
||||
)
|
||||
const handler = this
|
||||
|
||||
if (
|
||||
!arrayIncludes(util.safeHTTPMethods, this.#cacheKey.method) &&
|
||||
statusCode >= 200 &&
|
||||
statusCode <= 399
|
||||
) {
|
||||
// Successful response to an unsafe method, delete it from cache
|
||||
// https://www.rfc-editor.org/rfc/rfc9111.html#name-invalidating-stored-response
|
||||
invalidateUnsafeRequest(this.#store, this.#cacheKey, resHeaders)
|
||||
return downstreamOnHeaders()
|
||||
}
|
||||
|
||||
const cacheControlHeader = resHeaders['cache-control']
|
||||
const cacheControlDirectives = cacheControlHeader ? parseCacheControlHeader(cacheControlHeader) : {}
|
||||
|
||||
if (revalidationResponseDisallowsCachedReuse(this.#cacheType, resHeaders, cacheControlDirectives)) {
|
||||
deleteCachedValue(this.#store, this.#cacheKey)
|
||||
return downstreamOnHeaders()
|
||||
}
|
||||
|
||||
const heuristicallyCacheable = resHeaders['last-modified'] && arrayIncludes(HEURISTICALLY_CACHEABLE_STATUS_CODES, statusCode)
|
||||
if (
|
||||
!cacheControlHeader &&
|
||||
!resHeaders['expires'] &&
|
||||
!heuristicallyCacheable &&
|
||||
!this.#cacheByDefault
|
||||
) {
|
||||
if (statusCode === 304 && resHeaders.vary && isInvalidOrWildcardVaryHeader(resHeaders.vary)) {
|
||||
deleteCachedValue(this.#store, this.#cacheKey)
|
||||
}
|
||||
|
||||
// Don't have anything to tell us this response is cachable and we're not
|
||||
// caching by default
|
||||
return downstreamOnHeaders()
|
||||
}
|
||||
|
||||
if (!canCacheResponse(this.#cacheType, this.#cacheKey.method, statusCode, resHeaders, cacheControlDirectives, this.#cacheKey.headers)) {
|
||||
if (statusCode === 304 && (cacheControlHeader || revalidationResponseDisallowsCachedReuse(this.#cacheType, resHeaders, cacheControlDirectives))) {
|
||||
deleteCachedValue(this.#store, this.#cacheKey)
|
||||
}
|
||||
|
||||
return downstreamOnHeaders()
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const resAge = Object.hasOwn(resHeaders, 'age') ? getAge(resHeaders.age) : undefined
|
||||
if (resAge !== undefined && resAge >= MAX_RESPONSE_AGE) {
|
||||
// Response considered stale
|
||||
deleteCachedValueIfNotModified(statusCode, this.#store, this.#cacheKey)
|
||||
return downstreamOnHeaders()
|
||||
}
|
||||
|
||||
const resDate = Object.hasOwn(resHeaders, 'date') ? getDate(resHeaders.date) : undefined
|
||||
if (resDate === null) {
|
||||
deleteCachedValueIfNotModified(statusCode, this.#store, this.#cacheKey)
|
||||
return downstreamOnHeaders()
|
||||
}
|
||||
|
||||
const apparentAge = resDate ? Math.max(0, now - resDate.getTime()) : 0
|
||||
const currentAge = Math.max(apparentAge, resAge ?? 0)
|
||||
|
||||
const hasValidator =
|
||||
(typeof resHeaders.etag === 'string' && isEtagUsable(resHeaders.etag)) ||
|
||||
typeof resHeaders['last-modified'] === 'string'
|
||||
|
||||
const staleAt =
|
||||
determineStaleAt(this.#cacheType, now, resAge, resHeaders, resDate, cacheControlDirectives, hasValidator) ??
|
||||
this.#cacheByDefault
|
||||
// Zero freshness lifetime but a validator: stale from the start, yet still
|
||||
// storable since each reuse is preceded by a revalidation request.
|
||||
// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.4
|
||||
const revalidationOnly = staleAt === 0 && hasValidator
|
||||
if (staleAt === undefined || (currentAge >= staleAt && !revalidationOnly)) {
|
||||
if (cacheControlHeader || staleAt !== undefined) {
|
||||
deleteCachedValueIfNotModified(statusCode, this.#store, this.#cacheKey)
|
||||
}
|
||||
|
||||
return downstreamOnHeaders()
|
||||
}
|
||||
|
||||
const baseTime = now - currentAge
|
||||
const absoluteStaleAt = staleAt + baseTime
|
||||
if (now >= absoluteStaleAt && !revalidationOnly) {
|
||||
// Response is already stale
|
||||
deleteCachedValueIfNotModified(statusCode, this.#store, this.#cacheKey)
|
||||
return downstreamOnHeaders()
|
||||
}
|
||||
|
||||
let varyDirectives
|
||||
if (this.#cacheKey.headers && resHeaders.vary) {
|
||||
varyDirectives = parseVaryHeader(resHeaders.vary, this.#cacheKey.headers)
|
||||
if (!varyDirectives) {
|
||||
// Parse error
|
||||
return downstreamOnHeaders()
|
||||
}
|
||||
}
|
||||
|
||||
const cachedAt = baseTime
|
||||
const deleteAt = determineDeleteAt(baseTime, now, cacheControlDirectives, absoluteStaleAt)
|
||||
const strippedHeaders = stripNecessaryHeaders(resHeaders, cacheControlDirectives)
|
||||
|
||||
/**
|
||||
* @type {import('../../types/cache-interceptor.d.ts').default.CacheValue}
|
||||
*/
|
||||
const value = {
|
||||
statusCode,
|
||||
statusMessage,
|
||||
headers: strippedHeaders,
|
||||
vary: varyDirectives,
|
||||
cacheControlDirectives,
|
||||
cachedAt,
|
||||
staleAt: absoluteStaleAt,
|
||||
deleteAt
|
||||
}
|
||||
|
||||
// Not modified, re-use the cached value
|
||||
// https://www.rfc-editor.org/rfc/rfc9111.html#name-handling-304-not-modified
|
||||
if (statusCode === 304) {
|
||||
const handle304 = (cachedValue) => {
|
||||
if (!cachedValue) {
|
||||
// Do not create a new cache entry, as a 304 won't have a body - so cannot be cached.
|
||||
return downstreamOnHeaders()
|
||||
}
|
||||
|
||||
// Re-use the cached value: statuscode, statusmessage, headers and body
|
||||
value.statusCode = cachedValue.statusCode
|
||||
value.statusMessage = cachedValue.statusMessage
|
||||
value.etag = cachedValue.etag
|
||||
value.vary = varyDirectives ?? cachedValue.vary
|
||||
value.headers = { ...cachedValue.headers, ...strippedHeaders }
|
||||
|
||||
downstreamOnHeaders()
|
||||
|
||||
this.#writeStream = this.#store.createWriteStream(this.#cacheKey, value)
|
||||
|
||||
if (!this.#writeStream || !cachedValue?.body) {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof cachedValue.body.values === 'function') {
|
||||
const bodyIterator = cachedValue.body.values()
|
||||
|
||||
const streamCachedBody = () => {
|
||||
for (const chunk of bodyIterator) {
|
||||
const full = this.#writeStream.write(chunk) === false
|
||||
this.#handler.onResponseData?.(controller, chunk)
|
||||
// when stream is full stop writing until we get a 'drain' event
|
||||
if (full) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.#writeStream
|
||||
.on('error', function () {
|
||||
handler.#writeStream = undefined
|
||||
handler.#store.delete(handler.#cacheKey)
|
||||
})
|
||||
.on('drain', () => {
|
||||
streamCachedBody()
|
||||
})
|
||||
.on('close', function () {
|
||||
if (handler.#writeStream === this) {
|
||||
handler.#writeStream = undefined
|
||||
}
|
||||
})
|
||||
|
||||
streamCachedBody()
|
||||
} else if (typeof cachedValue.body.on === 'function') {
|
||||
// Readable stream body (e.g. from async/remote cache stores)
|
||||
cachedValue.body
|
||||
.on('data', (chunk) => {
|
||||
this.#writeStream.write(chunk)
|
||||
this.#handler.onResponseData?.(controller, chunk)
|
||||
})
|
||||
.on('end', () => {
|
||||
this.#writeStream.end()
|
||||
})
|
||||
.on('error', () => {
|
||||
this.#writeStream = undefined
|
||||
this.#store.delete(this.#cacheKey)
|
||||
})
|
||||
|
||||
this.#writeStream
|
||||
.on('error', function () {
|
||||
handler.#writeStream = undefined
|
||||
handler.#store.delete(handler.#cacheKey)
|
||||
})
|
||||
.on('close', function () {
|
||||
if (handler.#writeStream === this) {
|
||||
handler.#writeStream = undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import('../../types/cache-interceptor.d.ts').default.CacheValue}
|
||||
*/
|
||||
const result = this.#store.get(this.#cacheKey)
|
||||
if (result && typeof result.then === 'function') {
|
||||
result.then(handle304)
|
||||
} else {
|
||||
handle304(result)
|
||||
}
|
||||
} else {
|
||||
if (typeof resHeaders.etag === 'string' && isEtagUsable(resHeaders.etag)) {
|
||||
value.etag = resHeaders.etag
|
||||
}
|
||||
|
||||
this.#writeStream = this.#store.createWriteStream(this.#cacheKey, value)
|
||||
|
||||
if (!this.#writeStream) {
|
||||
return downstreamOnHeaders()
|
||||
}
|
||||
|
||||
this.#writeStream
|
||||
.on('drain', () => controller.resume())
|
||||
.on('error', function () {
|
||||
// TODO (fix): Make error somehow observable?
|
||||
handler.#writeStream = undefined
|
||||
|
||||
// Delete the value in case the cache store is holding onto state from
|
||||
// the call to createWriteStream
|
||||
handler.#store.delete(handler.#cacheKey)
|
||||
})
|
||||
.on('close', function () {
|
||||
if (handler.#writeStream === this) {
|
||||
handler.#writeStream = undefined
|
||||
}
|
||||
|
||||
// TODO (fix): Should we resume even if was paused downstream?
|
||||
controller.resume()
|
||||
})
|
||||
|
||||
downstreamOnHeaders()
|
||||
}
|
||||
}
|
||||
|
||||
onResponseData (controller, chunk) {
|
||||
if (this.#writeStream?.write(chunk) === false) {
|
||||
controller.pause()
|
||||
}
|
||||
|
||||
this.#handler.onResponseData?.(controller, chunk)
|
||||
}
|
||||
|
||||
onResponseEnd (controller, trailers) {
|
||||
this.#writeStream?.end()
|
||||
this.#handler.onResponseEnd?.(controller, trailers)
|
||||
}
|
||||
|
||||
onResponseError (controller, err) {
|
||||
this.#writeStream?.destroy(err)
|
||||
this.#writeStream = undefined
|
||||
this.#handler.onResponseError?.(controller, err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheStore} store
|
||||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey
|
||||
*/
|
||||
function deleteCachedValue (store, cacheKey) {
|
||||
try {
|
||||
store.delete(cacheKey)?.catch?.(noop)
|
||||
} catch {
|
||||
// Fail silently
|
||||
}
|
||||
}
|
||||
|
||||
function deleteCachedValueIfNotModified (statusCode, store, cacheKey) {
|
||||
if (statusCode === 304) {
|
||||
deleteCachedValue(store, cacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType
|
||||
* @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders
|
||||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function revalidationResponseDisallowsCachedReuse (cacheType, resHeaders, cacheControlDirectives) {
|
||||
return cacheControlDirectives['no-store'] === true ||
|
||||
(cacheType === 'shared' && (
|
||||
cacheControlDirectives.private === true ||
|
||||
Object.hasOwn(resHeaders, 'set-cookie')
|
||||
)) ||
|
||||
(resHeaders.vary ? isInvalidOrWildcardVaryHeader(resHeaders.vary) : false)
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen
|
||||
*
|
||||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType
|
||||
* @param {string} method
|
||||
* @param {number} statusCode
|
||||
* @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders
|
||||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives
|
||||
* @param {import('../../types/header.d.ts').IncomingHttpHeaders} [reqHeaders]
|
||||
*/
|
||||
function canCacheResponse (cacheType, method, statusCode, resHeaders, cacheControlDirectives, reqHeaders) {
|
||||
if (!arrayIncludes(util.safeHTTPMethods, method)) {
|
||||
return false
|
||||
}
|
||||
// Status code must be final and understood.
|
||||
if (statusCode < 200 || arrayIncludes(NOT_UNDERSTOOD_STATUS_CODES, statusCode)) {
|
||||
return false
|
||||
}
|
||||
// Responses with neither status codes that are heuristically cacheable, nor "explicit enough" caching
|
||||
// directives, are not cacheable. "Explicit enough": see https://www.rfc-editor.org/rfc/rfc9111.html#section-3
|
||||
if (!arrayIncludes(HEURISTICALLY_CACHEABLE_STATUS_CODES, statusCode) && !resHeaders['expires'] &&
|
||||
!cacheControlDirectives.public &&
|
||||
cacheControlDirectives['max-age'] === undefined &&
|
||||
// RFC 9111: a private response directive, if the cache is not shared
|
||||
!(cacheControlDirectives.private && cacheType === 'private') &&
|
||||
!(cacheControlDirectives['s-maxage'] !== undefined && cacheType === 'shared')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (cacheControlDirectives['no-store']) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (cacheType === 'shared' && (
|
||||
cacheControlDirectives.private === true ||
|
||||
Object.hasOwn(resHeaders, 'set-cookie')
|
||||
)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// https://www.rfc-editor.org/rfc/rfc9111.html#section-4.1-5
|
||||
if (resHeaders.vary && hasVaryStar(resHeaders.vary)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen
|
||||
if (reqHeaders != null && Object.hasOwn(reqHeaders, 'authorization')) {
|
||||
if (
|
||||
!cacheControlDirectives.public &&
|
||||
!cacheControlDirectives['s-maxage'] &&
|
||||
!cacheControlDirectives['must-revalidate']
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof reqHeaders.authorization !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
Array.isArray(cacheControlDirectives['no-cache']) &&
|
||||
arrayIncludes(cacheControlDirectives['no-cache'], 'authorization')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
Array.isArray(cacheControlDirectives['private']) &&
|
||||
arrayIncludes(cacheControlDirectives['private'], 'authorization')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | string[]} dateHeader
|
||||
* @returns {Date | null | undefined}
|
||||
*/
|
||||
function getDate (dateHeader) {
|
||||
let dateValue = dateHeader
|
||||
if (Array.isArray(dateValue)) {
|
||||
if (dateValue.length !== 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
dateValue = dateValue[0]
|
||||
}
|
||||
|
||||
if (typeof dateValue !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
return parseHttpDate(dateValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | string[]} ageHeader
|
||||
* @returns {number | undefined}
|
||||
*/
|
||||
function getAge (ageHeader) {
|
||||
let ageValue = ageHeader
|
||||
if (Array.isArray(ageValue)) {
|
||||
if (ageValue.length !== 1) {
|
||||
return MAX_RESPONSE_AGE
|
||||
}
|
||||
|
||||
ageValue = ageValue[0]
|
||||
}
|
||||
|
||||
if (typeof ageValue !== 'string' || !/^[\t ]*[0-9]+[\t ]*$/.test(ageValue)) {
|
||||
return MAX_RESPONSE_AGE
|
||||
}
|
||||
|
||||
const age = BigInt(ageValue.replace(/^[\t ]+|[\t ]+$/g, ''))
|
||||
if (age >= BigInt(MAX_RESPONSE_AGE / 1000)) {
|
||||
return MAX_RESPONSE_AGE
|
||||
}
|
||||
|
||||
return Number(age) * 1000
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType
|
||||
* @param {number} now
|
||||
* @param {number | undefined} age
|
||||
* @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders
|
||||
* @param {Date | undefined} responseDate
|
||||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives
|
||||
* @param {boolean} hasValidator whether the response has a validator (etag or
|
||||
* last-modified) that revalidation requests can be made with
|
||||
*
|
||||
* @returns {number | undefined} time that the value is stale at in seconds or undefined if it shouldn't be cached
|
||||
*/
|
||||
function determineStaleAt (cacheType, now, age, resHeaders, responseDate, cacheControlDirectives, hasValidator) {
|
||||
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
|
||||
if (hasInvalidCacheControlDirective(cacheControlDirectives, 's-maxage')) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const sMaxAge = cacheControlDirectives['s-maxage']
|
||||
if (sMaxAge !== undefined) {
|
||||
if (sMaxAge > 0) {
|
||||
return sMaxAge * 1000
|
||||
}
|
||||
|
||||
// Immediately stale, but storable if we can revalidate it before reuse.
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
if (hasInvalidCacheControlDirective(cacheControlDirectives, 'max-age')) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const maxAge = cacheControlDirectives['max-age']
|
||||
if (maxAge !== undefined) {
|
||||
if (maxAge > 0) {
|
||||
return maxAge * 1000
|
||||
}
|
||||
|
||||
// Immediately stale, but storable if we can revalidate it before reuse.
|
||||
return 0
|
||||
}
|
||||
|
||||
if (Object.hasOwn(resHeaders, 'expires')) {
|
||||
// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.3
|
||||
if (typeof resHeaders.expires !== 'string') {
|
||||
return 0
|
||||
}
|
||||
|
||||
const expiresDate = parseHttpDate(resHeaders.expires)
|
||||
if (!expiresDate) {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (now >= expiresDate.getTime()) {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (responseDate) {
|
||||
if (responseDate >= expiresDate) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const freshnessLifetime = expiresDate.getTime() - responseDate.getTime()
|
||||
if (age !== undefined && age >= freshnessLifetime) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return freshnessLifetime
|
||||
}
|
||||
|
||||
return expiresDate.getTime() - now
|
||||
}
|
||||
|
||||
if (typeof resHeaders['last-modified'] === 'string') {
|
||||
// https://www.rfc-editor.org/rfc/rfc9111.html#name-calculating-heuristic-fresh
|
||||
const lastModified = parseHttpDate(resHeaders['last-modified'])
|
||||
if (lastModified) {
|
||||
if (lastModified.getTime() >= now) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const responseAge = now - lastModified.getTime()
|
||||
|
||||
return responseAge * 0.1
|
||||
}
|
||||
}
|
||||
|
||||
if (cacheControlDirectives.immutable) {
|
||||
// https://www.rfc-editor.org/rfc/rfc8246.html#section-2.2
|
||||
return 31536000000
|
||||
}
|
||||
|
||||
if (cacheControlDirectives['no-cache'] === true && hasValidator) {
|
||||
// No freshness source, but a validator lets us revalidate before reuse.
|
||||
// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.4
|
||||
return 0
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} baseTime
|
||||
* @param {number} cachedAt
|
||||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives
|
||||
* @param {number} staleAt
|
||||
*/
|
||||
function determineDeleteAt (baseTime, cachedAt, cacheControlDirectives, staleAt) {
|
||||
let staleWhileRevalidate = -Infinity
|
||||
let staleIfError = -Infinity
|
||||
let immutable = -Infinity
|
||||
|
||||
if (cacheControlDirectives['stale-while-revalidate']) {
|
||||
staleWhileRevalidate = staleAt + (cacheControlDirectives['stale-while-revalidate'] * 1000)
|
||||
}
|
||||
|
||||
if (cacheControlDirectives['stale-if-error']) {
|
||||
staleIfError = staleAt + (cacheControlDirectives['stale-if-error'] * 1000)
|
||||
}
|
||||
|
||||
if (cacheControlDirectives.immutable && staleWhileRevalidate === -Infinity && staleIfError === -Infinity) {
|
||||
immutable = cachedAt + 31536000000
|
||||
}
|
||||
|
||||
// When no stale directives or immutable flag, add a revalidation buffer
|
||||
// equal to the freshness lifetime so the entry survives past staleAt long
|
||||
// enough to be revalidated instead of silently disappearing.
|
||||
//
|
||||
// Response Date headers only have second precision, so baseTime can trail the
|
||||
// actual cache insertion time by up to ~1s. Pad the buffer by that bounded
|
||||
// skew so short-lived entries do not disappear exactly when they should be
|
||||
// revalidated.
|
||||
if (staleWhileRevalidate === -Infinity && staleIfError === -Infinity && immutable === -Infinity) {
|
||||
const freshnessLifetime = staleAt - baseTime
|
||||
if (freshnessLifetime <= 0) {
|
||||
// Revalidation-only entry: no freshness lifetime to size the buffer on,
|
||||
// so retain it for a bounded window instead.
|
||||
return cachedAt + REVALIDATION_ONLY_RETENTION
|
||||
}
|
||||
const datePrecisionPadding = Math.min(Math.max(cachedAt - baseTime, 0), 1000)
|
||||
return staleAt + freshnessLifetime + datePrecisionPadding
|
||||
}
|
||||
|
||||
return Math.max(staleAt, staleWhileRevalidate, staleIfError, immutable)
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips headers required to be removed in cached responses
|
||||
* @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders
|
||||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives
|
||||
* @returns {Record<string, string | string []>}
|
||||
*/
|
||||
function stripNecessaryHeaders (resHeaders, cacheControlDirectives) {
|
||||
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 (resHeaders['connection']) {
|
||||
appendConnectionHeaderTokens(headersToRemove, resHeaders['connection'])
|
||||
}
|
||||
|
||||
if (Array.isArray(cacheControlDirectives['no-cache'])) {
|
||||
headersToRemove.push(...cacheControlDirectives['no-cache'])
|
||||
}
|
||||
|
||||
if (Array.isArray(cacheControlDirectives['private'])) {
|
||||
headersToRemove.push(...cacheControlDirectives['private'])
|
||||
}
|
||||
|
||||
let strippedHeaders
|
||||
for (const headerName of headersToRemove) {
|
||||
if (Object.hasOwn(resHeaders, headerName)) {
|
||||
strippedHeaders ??= { ...resHeaders }
|
||||
delete strippedHeaders[headerName]
|
||||
}
|
||||
}
|
||||
|
||||
return strippedHeaders ?? resHeaders
|
||||
}
|
||||
|
||||
module.exports = CacheHandler
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert')
|
||||
|
||||
/**
|
||||
* This takes care of revalidation requests we send to the origin. If we get
|
||||
* a response indicating that what we have is cached (via a HTTP 304), we can
|
||||
* continue using the cached value. Otherwise, we'll receive the new response
|
||||
* here, which we then just pass on to the next handler (most likely a
|
||||
* CacheHandler). Note that this assumes the proper headers were already
|
||||
* included in the request to tell the origin that we want to revalidate the
|
||||
* response (i.e. if-modified-since or if-none-match).
|
||||
*
|
||||
* @see https://www.rfc-editor.org/rfc/rfc9111.html#name-validation
|
||||
*
|
||||
* @implements {import('../../types/dispatcher.d.ts').default.DispatchHandler}
|
||||
*/
|
||||
class CacheRevalidationHandler {
|
||||
#successful = false
|
||||
|
||||
/**
|
||||
* @type {((success: boolean, context?: any, statusCode?: number, headers?: import('../../types/header.d.ts').IncomingHttpHeaders) => void) | null}
|
||||
*/
|
||||
#callback
|
||||
|
||||
/**
|
||||
* @type {(import('../../types/dispatcher.d.ts').default.DispatchHandler)}
|
||||
*/
|
||||
#handler
|
||||
|
||||
#context
|
||||
|
||||
/**
|
||||
* @type {boolean}
|
||||
*/
|
||||
#allowErrorStatusCodes
|
||||
|
||||
/**
|
||||
* @param {(success: boolean, context?: any, statusCode?: number, headers?: import('../../types/header.d.ts').IncomingHttpHeaders) => 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') {
|
||||
throw new TypeError('callback must be a function')
|
||||
}
|
||||
|
||||
this.#callback = callback
|
||||
this.#handler = handler
|
||||
this.#allowErrorStatusCodes = allowErrorStatusCodes
|
||||
}
|
||||
|
||||
onRequestStart (_, context) {
|
||||
this.#successful = false
|
||||
this.#context = context
|
||||
}
|
||||
|
||||
onRequestUpgrade (controller, statusCode, headers, socket) {
|
||||
this.#handler.onRequestUpgrade?.(controller, statusCode, headers, socket)
|
||||
}
|
||||
|
||||
onResponseStart (
|
||||
controller,
|
||||
statusCode,
|
||||
headers,
|
||||
statusMessage
|
||||
) {
|
||||
assert(this.#callback != null)
|
||||
|
||||
// https://www.rfc-editor.org/rfc/rfc9111.html#name-handling-a-validation-respo
|
||||
// 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, statusCode, headers)
|
||||
this.#callback = null
|
||||
|
||||
if (this.#successful) {
|
||||
return true
|
||||
}
|
||||
|
||||
this.#handler.onRequestStart?.(controller, this.#context)
|
||||
this.#handler.onResponseStart?.(
|
||||
controller,
|
||||
statusCode,
|
||||
headers,
|
||||
statusMessage
|
||||
)
|
||||
}
|
||||
|
||||
onResponseData (controller, chunk) {
|
||||
if (this.#successful) {
|
||||
return
|
||||
}
|
||||
|
||||
return this.#handler.onResponseData?.(controller, chunk)
|
||||
}
|
||||
|
||||
onResponseEnd (controller, trailers) {
|
||||
if (this.#successful) {
|
||||
return
|
||||
}
|
||||
|
||||
this.#handler.onResponseEnd?.(controller, trailers)
|
||||
}
|
||||
|
||||
onResponseError (controller, err) {
|
||||
if (this.#successful) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.#callback) {
|
||||
// Serve the stale cached response on a connection error, per stale-if-error:
|
||||
// RFC 5861 counts an unreachable origin (a would-be 5xx) as an error.
|
||||
// https://datatracker.ietf.org/doc/html/rfc5861#section-4
|
||||
if (this.#allowErrorStatusCodes) {
|
||||
this.#successful = true
|
||||
this.#callback(true, this.#context)
|
||||
this.#callback = null
|
||||
return
|
||||
}
|
||||
|
||||
this.#callback(false)
|
||||
this.#callback = null
|
||||
}
|
||||
|
||||
if (typeof this.#handler.onResponseError === 'function') {
|
||||
this.#handler.onResponseError(controller, err)
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CacheRevalidationHandler
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert')
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
module.exports = class DecoratorHandler {
|
||||
#handler
|
||||
#onCompleteCalled = false
|
||||
#onErrorCalled = false
|
||||
#onResponseStartCalled = false
|
||||
|
||||
constructor (handler) {
|
||||
if (typeof handler !== 'object' || handler === null) {
|
||||
throw new TypeError('handler must be an object')
|
||||
}
|
||||
this.#handler = handler
|
||||
}
|
||||
|
||||
onRequestStart (...args) {
|
||||
this.#handler.onRequestStart?.(...args)
|
||||
}
|
||||
|
||||
onRequestUpgrade (...args) {
|
||||
assert(!this.#onCompleteCalled)
|
||||
assert(!this.#onErrorCalled)
|
||||
|
||||
return this.#handler.onRequestUpgrade?.(...args)
|
||||
}
|
||||
|
||||
onResponseStart (...args) {
|
||||
assert(!this.#onCompleteCalled)
|
||||
assert(!this.#onErrorCalled)
|
||||
assert(!this.#onResponseStartCalled)
|
||||
|
||||
this.#onResponseStartCalled = true
|
||||
|
||||
return this.#handler.onResponseStart?.(...args)
|
||||
}
|
||||
|
||||
onResponseData (...args) {
|
||||
assert(!this.#onCompleteCalled)
|
||||
assert(!this.#onErrorCalled)
|
||||
|
||||
return this.#handler.onResponseData?.(...args)
|
||||
}
|
||||
|
||||
onResponseEnd (...args) {
|
||||
assert(!this.#onCompleteCalled)
|
||||
assert(!this.#onErrorCalled)
|
||||
|
||||
this.#onCompleteCalled = true
|
||||
return this.#handler.onResponseEnd?.(...args)
|
||||
}
|
||||
|
||||
onResponseError (...args) {
|
||||
this.#onErrorCalled = true
|
||||
return this.#handler.onResponseError?.(...args)
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
onBodySent (...args) {
|
||||
return this.#handler.onBodySent?.(...args)
|
||||
}
|
||||
|
||||
onRequestSent (...args) {
|
||||
return this.#handler.onRequestSent?.(...args)
|
||||
}
|
||||
}
|
||||
+479
@@ -0,0 +1,479 @@
|
||||
'use strict'
|
||||
|
||||
const { RequestAbortedError } = require('../core/errors')
|
||||
|
||||
/**
|
||||
* @typedef {import('../../types/dispatcher.d.ts').default.DispatchHandler} DispatchHandler
|
||||
*/
|
||||
|
||||
const DEFAULT_MAX_BUFFER_SIZE = 5 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* @typedef {Object} WaitingHandler
|
||||
* @property {DispatchHandler} handler
|
||||
* @property {import('../../types/dispatcher.d.ts').default.DispatchController} controller
|
||||
* @property {Buffer[]} bufferedChunks
|
||||
* @property {number} bufferedBytes
|
||||
* @property {object | null} pendingTrailers
|
||||
* @property {boolean} done
|
||||
*/
|
||||
|
||||
/**
|
||||
* Handler that forwards response events to multiple waiting handlers.
|
||||
* Used for request deduplication.
|
||||
*
|
||||
* @implements {DispatchHandler}
|
||||
*/
|
||||
class DeduplicationHandler {
|
||||
/**
|
||||
* @type {DispatchHandler}
|
||||
*/
|
||||
#primaryHandler
|
||||
|
||||
/**
|
||||
* @type {WaitingHandler[]}
|
||||
*/
|
||||
#waitingHandlers = []
|
||||
|
||||
/**
|
||||
* @type {number}
|
||||
*/
|
||||
#maxBufferSize = DEFAULT_MAX_BUFFER_SIZE
|
||||
|
||||
/**
|
||||
* @type {number}
|
||||
*/
|
||||
#statusCode = 0
|
||||
|
||||
/**
|
||||
* @type {Record<string, string | string[]>}
|
||||
*/
|
||||
#headers = {}
|
||||
|
||||
/**
|
||||
* @type {string}
|
||||
*/
|
||||
#statusMessage = ''
|
||||
|
||||
/**
|
||||
* @type {boolean}
|
||||
*/
|
||||
#aborted = false
|
||||
|
||||
/**
|
||||
* @type {boolean}
|
||||
*/
|
||||
#responseStarted = false
|
||||
|
||||
/**
|
||||
* @type {boolean}
|
||||
*/
|
||||
#responseDataStarted = false
|
||||
|
||||
/**
|
||||
* @type {boolean}
|
||||
*/
|
||||
#completed = false
|
||||
|
||||
/**
|
||||
* @type {import('../../types/dispatcher.d.ts').default.DispatchController | null}
|
||||
*/
|
||||
#controller = null
|
||||
|
||||
/**
|
||||
* @type {(() => void) | null}
|
||||
*/
|
||||
#onComplete = null
|
||||
|
||||
/**
|
||||
* @param {DispatchHandler} primaryHandler The primary handler
|
||||
* @param {() => void} onComplete Callback when request completes
|
||||
* @param {number} [maxBufferSize] Maximum paused buffer size per waiting handler
|
||||
*/
|
||||
constructor (primaryHandler, onComplete, maxBufferSize = DEFAULT_MAX_BUFFER_SIZE) {
|
||||
this.#primaryHandler = primaryHandler
|
||||
this.#onComplete = onComplete
|
||||
this.#maxBufferSize = maxBufferSize
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a waiting handler that will receive response events.
|
||||
* Returns false if deduplication can no longer safely attach this handler.
|
||||
*
|
||||
* @param {DispatchHandler} handler
|
||||
* @returns {boolean}
|
||||
*/
|
||||
addWaitingHandler (handler) {
|
||||
if (this.#completed || this.#responseDataStarted) {
|
||||
return false
|
||||
}
|
||||
|
||||
const waitingHandler = this.#createWaitingHandler(handler)
|
||||
const waitingController = waitingHandler.controller
|
||||
|
||||
try {
|
||||
handler.onRequestStart?.(waitingController, null)
|
||||
|
||||
if (waitingController.aborted) {
|
||||
waitingHandler.done = true
|
||||
return true
|
||||
}
|
||||
|
||||
if (this.#responseStarted) {
|
||||
handler.onResponseStart?.(
|
||||
waitingController,
|
||||
this.#statusCode,
|
||||
this.#headers,
|
||||
this.#statusMessage
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors from waiting handlers
|
||||
waitingHandler.done = true
|
||||
return true
|
||||
}
|
||||
|
||||
if (!waitingController.aborted) {
|
||||
this.#waitingHandlers.push(waitingHandler)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../../types/dispatcher.d.ts').default.DispatchController} controller
|
||||
* @param {any} context
|
||||
*/
|
||||
onRequestStart (controller, context) {
|
||||
this.#controller = controller
|
||||
this.#primaryHandler.onRequestStart?.(controller, context)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../../types/dispatcher.d.ts').default.DispatchController} controller
|
||||
* @param {number} statusCode
|
||||
* @param {import('../../types/header.d.ts').IncomingHttpHeaders} headers
|
||||
* @param {Socket} socket
|
||||
*/
|
||||
onRequestUpgrade (controller, statusCode, headers, socket) {
|
||||
this.#primaryHandler.onRequestUpgrade?.(controller, statusCode, headers, socket)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../../types/dispatcher.d.ts').default.DispatchController} controller
|
||||
* @param {number} statusCode
|
||||
* @param {Record<string, string | string[]>} headers
|
||||
* @param {string} statusMessage
|
||||
*/
|
||||
onResponseStart (controller, statusCode, headers, statusMessage) {
|
||||
this.#responseStarted = true
|
||||
this.#statusCode = statusCode
|
||||
this.#headers = headers
|
||||
this.#statusMessage = statusMessage
|
||||
|
||||
this.#primaryHandler.onResponseStart?.(controller, statusCode, headers, statusMessage)
|
||||
|
||||
for (const waitingHandler of this.#waitingHandlers) {
|
||||
const { handler, controller: waitingController } = waitingHandler
|
||||
|
||||
if (waitingHandler.done || waitingController.aborted) {
|
||||
waitingHandler.done = true
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
handler.onResponseStart?.(
|
||||
waitingController,
|
||||
statusCode,
|
||||
headers,
|
||||
statusMessage
|
||||
)
|
||||
} catch {
|
||||
// Ignore errors from waiting handlers
|
||||
}
|
||||
|
||||
if (waitingController.aborted) {
|
||||
waitingHandler.done = true
|
||||
}
|
||||
}
|
||||
|
||||
this.#pruneDoneWaitingHandlers()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../../types/dispatcher.d.ts').default.DispatchController} controller
|
||||
* @param {Buffer} chunk
|
||||
*/
|
||||
onResponseData (controller, chunk) {
|
||||
if (this.#aborted || this.#completed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.#responseDataStarted = true
|
||||
|
||||
this.#primaryHandler.onResponseData?.(controller, chunk)
|
||||
|
||||
for (const waitingHandler of this.#waitingHandlers) {
|
||||
const { handler, controller: waitingController } = waitingHandler
|
||||
|
||||
if (waitingHandler.done || waitingController.aborted) {
|
||||
waitingHandler.done = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (waitingController.paused) {
|
||||
this.#bufferWaitingChunk(waitingHandler, chunk)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
handler.onResponseData?.(waitingController, chunk)
|
||||
} catch {
|
||||
// Ignore errors from waiting handlers
|
||||
}
|
||||
|
||||
if (waitingController.aborted) {
|
||||
waitingHandler.done = true
|
||||
waitingHandler.bufferedChunks = []
|
||||
waitingHandler.bufferedBytes = 0
|
||||
}
|
||||
}
|
||||
|
||||
this.#pruneDoneWaitingHandlers()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../../types/dispatcher.d.ts').default.DispatchController} controller
|
||||
* @param {object} trailers
|
||||
*/
|
||||
onResponseEnd (controller, trailers) {
|
||||
if (this.#aborted || this.#completed) {
|
||||
return
|
||||
}
|
||||
|
||||
// Remove the entry before callbacks can synchronously dispatch a retry.
|
||||
this.#cleanup()
|
||||
this.#primaryHandler.onResponseEnd?.(controller, trailers)
|
||||
// A throwing end callback must still be handled by onResponseError.
|
||||
this.#completed = true
|
||||
|
||||
for (const waitingHandler of this.#waitingHandlers) {
|
||||
if (waitingHandler.done || waitingHandler.controller.aborted) {
|
||||
waitingHandler.done = true
|
||||
continue
|
||||
}
|
||||
|
||||
this.#flushWaitingHandler(waitingHandler)
|
||||
|
||||
if (waitingHandler.done || waitingHandler.controller.aborted) {
|
||||
waitingHandler.done = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (waitingHandler.controller.paused) {
|
||||
waitingHandler.pendingTrailers = trailers
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
waitingHandler.handler.onResponseEnd?.(waitingHandler.controller, trailers)
|
||||
} catch (err) {
|
||||
this.#errorWaitingHandler(waitingHandler, err)
|
||||
}
|
||||
|
||||
waitingHandler.done = true
|
||||
}
|
||||
|
||||
this.#pruneDoneWaitingHandlers()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../../types/dispatcher.d.ts').default.DispatchController} controller
|
||||
* @param {Error} err
|
||||
*/
|
||||
onResponseError (controller, err) {
|
||||
if (this.#completed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.#aborted = true
|
||||
this.#completed = true
|
||||
this.#cleanup()
|
||||
|
||||
this.#primaryHandler.onResponseError?.(controller, err)
|
||||
|
||||
for (const waitingHandler of this.#waitingHandlers) {
|
||||
this.#errorWaitingHandler(waitingHandler, err)
|
||||
}
|
||||
|
||||
this.#waitingHandlers = []
|
||||
}
|
||||
|
||||
#cleanup () {
|
||||
const onComplete = this.#onComplete
|
||||
this.#onComplete = null
|
||||
onComplete?.()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DispatchHandler} handler
|
||||
* @returns {WaitingHandler}
|
||||
*/
|
||||
#createWaitingHandler (handler) {
|
||||
/** @type {WaitingHandler} */
|
||||
const waitingHandler = {
|
||||
handler,
|
||||
controller: null,
|
||||
bufferedChunks: [],
|
||||
bufferedBytes: 0,
|
||||
pendingTrailers: null,
|
||||
done: false
|
||||
}
|
||||
|
||||
const state = {
|
||||
aborted: false,
|
||||
paused: false,
|
||||
reason: null
|
||||
}
|
||||
|
||||
waitingHandler.controller = {
|
||||
resume: () => {
|
||||
if (state.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
state.paused = false
|
||||
this.#flushWaitingHandler(waitingHandler)
|
||||
|
||||
if (
|
||||
this.#completed &&
|
||||
waitingHandler.pendingTrailers &&
|
||||
waitingHandler.bufferedChunks.length === 0 &&
|
||||
!state.paused &&
|
||||
!state.aborted
|
||||
) {
|
||||
try {
|
||||
waitingHandler.handler.onResponseEnd?.(waitingHandler.controller, waitingHandler.pendingTrailers)
|
||||
} catch (err) {
|
||||
this.#errorWaitingHandler(waitingHandler, err)
|
||||
}
|
||||
|
||||
waitingHandler.pendingTrailers = null
|
||||
waitingHandler.done = true
|
||||
}
|
||||
|
||||
this.#pruneDoneWaitingHandlers()
|
||||
},
|
||||
pause: () => {
|
||||
if (!state.aborted) {
|
||||
state.paused = true
|
||||
}
|
||||
},
|
||||
get paused () { return state.paused },
|
||||
get aborted () { return state.aborted },
|
||||
get reason () { return state.reason },
|
||||
abort: (reason) => {
|
||||
if (state.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
state.aborted = true
|
||||
state.reason = reason ?? null
|
||||
waitingHandler.done = true
|
||||
waitingHandler.pendingTrailers = null
|
||||
waitingHandler.bufferedChunks = []
|
||||
waitingHandler.bufferedBytes = 0
|
||||
|
||||
try {
|
||||
handler.onResponseError?.(waitingHandler.controller, state.reason ?? new RequestAbortedError())
|
||||
} catch {
|
||||
// Ignore errors from waiting handlers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return waitingHandler
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {WaitingHandler} waitingHandler
|
||||
* @param {Buffer} chunk
|
||||
*/
|
||||
#bufferWaitingChunk (waitingHandler, chunk) {
|
||||
if (waitingHandler.done || waitingHandler.controller.aborted) {
|
||||
waitingHandler.done = true
|
||||
waitingHandler.bufferedChunks = []
|
||||
waitingHandler.bufferedBytes = 0
|
||||
return
|
||||
}
|
||||
|
||||
const bufferedChunk = Buffer.from(chunk)
|
||||
waitingHandler.bufferedChunks.push(bufferedChunk)
|
||||
waitingHandler.bufferedBytes += bufferedChunk.length
|
||||
|
||||
if (waitingHandler.bufferedBytes > this.#maxBufferSize) {
|
||||
const err = new RequestAbortedError(`Deduplicated waiting handler exceeded maxBufferSize (${this.#maxBufferSize} bytes) while paused`)
|
||||
waitingHandler.controller.abort(err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {WaitingHandler} waitingHandler
|
||||
*/
|
||||
#flushWaitingHandler (waitingHandler) {
|
||||
const { handler, controller } = waitingHandler
|
||||
|
||||
while (
|
||||
!waitingHandler.done &&
|
||||
!controller.aborted &&
|
||||
!controller.paused &&
|
||||
waitingHandler.bufferedChunks.length > 0
|
||||
) {
|
||||
const bufferedChunk = waitingHandler.bufferedChunks.shift()
|
||||
waitingHandler.bufferedBytes -= bufferedChunk.length
|
||||
|
||||
try {
|
||||
handler.onResponseData?.(controller, bufferedChunk)
|
||||
} catch {
|
||||
// Ignore errors from waiting handlers
|
||||
}
|
||||
|
||||
if (controller.aborted) {
|
||||
waitingHandler.done = true
|
||||
waitingHandler.pendingTrailers = null
|
||||
waitingHandler.bufferedChunks = []
|
||||
waitingHandler.bufferedBytes = 0
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {WaitingHandler} waitingHandler
|
||||
* @param {Error} err
|
||||
*/
|
||||
#errorWaitingHandler (waitingHandler, err) {
|
||||
if (waitingHandler.done) {
|
||||
return
|
||||
}
|
||||
|
||||
waitingHandler.done = true
|
||||
waitingHandler.pendingTrailers = null
|
||||
waitingHandler.bufferedChunks = []
|
||||
waitingHandler.bufferedBytes = 0
|
||||
|
||||
// A response failure is not a consumer abort: retry handlers must be able
|
||||
// to retry it just as they would a failure of the primary request.
|
||||
try {
|
||||
waitingHandler.handler.onResponseError?.(waitingHandler.controller, err)
|
||||
} catch {
|
||||
// Ignore errors from waiting handlers
|
||||
}
|
||||
}
|
||||
|
||||
#pruneDoneWaitingHandlers () {
|
||||
this.#waitingHandlers = this.#waitingHandlers.filter(waitingHandler => waitingHandler.done === false)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DeduplicationHandler
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
'use strict'
|
||||
|
||||
const util = require('../core/util')
|
||||
const assert = require('node:assert')
|
||||
const { InvalidArgumentError } = require('../core/errors')
|
||||
const { kRequestOrigin } = require('../core/symbols')
|
||||
|
||||
const redirectableStatusCodes = [300, 301, 302, 303, 307, 308]
|
||||
|
||||
const noop = () => {}
|
||||
|
||||
class RedirectHandler {
|
||||
static buildDispatch (dispatcher, maxRedirections) {
|
||||
if (maxRedirections != null && (!Number.isInteger(maxRedirections) || maxRedirections < 0)) {
|
||||
throw new InvalidArgumentError('maxRedirections must be a positive number')
|
||||
}
|
||||
|
||||
const dispatch = dispatcher.dispatch.bind(dispatcher)
|
||||
return (opts, originalHandler) => dispatch(opts, new RedirectHandler(dispatch, maxRedirections, opts, originalHandler))
|
||||
}
|
||||
|
||||
constructor (dispatch, maxRedirections, opts, handler) {
|
||||
if (maxRedirections != null && (!Number.isInteger(maxRedirections) || maxRedirections < 0)) {
|
||||
throw new InvalidArgumentError('maxRedirections must be a positive number')
|
||||
}
|
||||
|
||||
if (opts.throwOnMaxRedirect != null && typeof opts.throwOnMaxRedirect !== 'boolean') {
|
||||
throw new InvalidArgumentError('throwOnMaxRedirect must be a boolean')
|
||||
}
|
||||
|
||||
this.dispatch = dispatch
|
||||
this.location = null
|
||||
const { maxRedirections: _, stripHeadersOnRedirect, stripHeadersOnCrossOriginRedirect, ...cleanOpts } = opts
|
||||
this.opts = cleanOpts // opts must be a copy, exclude maxRedirections
|
||||
this.opts.body = util.wrapRequestBody(this.opts.body)
|
||||
this.stripHeadersOnRedirect = normalizeStripHeaders(stripHeadersOnRedirect, 'stripHeadersOnRedirect')
|
||||
this.stripHeadersOnCrossOriginRedirect = normalizeStripHeaders(stripHeadersOnCrossOriginRedirect, 'stripHeadersOnCrossOriginRedirect')
|
||||
this.maxRedirections = maxRedirections
|
||||
this.handler = handler
|
||||
this.history = []
|
||||
}
|
||||
|
||||
onRequestStart (controller, context) {
|
||||
this.handler.onRequestStart?.(controller, { ...context, history: this.history })
|
||||
}
|
||||
|
||||
onBodySent (chunk) {
|
||||
this.handler.onBodySent?.(chunk)
|
||||
}
|
||||
|
||||
onRequestSent () {
|
||||
this.handler.onRequestSent?.()
|
||||
}
|
||||
|
||||
onRequestUpgrade (controller, statusCode, headers, socket) {
|
||||
this.handler.onRequestUpgrade?.(controller, statusCode, headers, socket)
|
||||
}
|
||||
|
||||
onResponseStart (controller, statusCode, headers, statusMessage) {
|
||||
if (statusCode < 200) {
|
||||
this.handler.onResponseStart?.(controller, statusCode, headers, statusMessage)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.opts.throwOnMaxRedirect && this.history.length >= this.maxRedirections) {
|
||||
throw new Error('max redirects')
|
||||
}
|
||||
|
||||
let removeContentHeaders = statusCode === 303
|
||||
|
||||
// https://tools.ietf.org/html/rfc7231#section-6.4.2
|
||||
// https://fetch.spec.whatwg.org/#http-redirect-fetch
|
||||
// In case of HTTP 301 or 302 with POST, change the method to GET
|
||||
// QUERY is safe (RFC 10008) and should not change method like GET.
|
||||
if ((statusCode === 301 || statusCode === 302) && this.opts.method === 'POST') {
|
||||
this.opts.method = 'GET'
|
||||
if (util.isStream(this.opts.body)) {
|
||||
util.destroy(this.opts.body.on('error', noop))
|
||||
}
|
||||
this.opts.body = null
|
||||
removeContentHeaders = true
|
||||
}
|
||||
|
||||
// https://tools.ietf.org/html/rfc7231#section-6.4.4
|
||||
// In case of HTTP 303, always replace method to be either HEAD or GET
|
||||
if (statusCode === 303 && this.opts.method !== 'HEAD') {
|
||||
this.opts.method = 'GET'
|
||||
if (util.isStream(this.opts.body)) {
|
||||
util.destroy(this.opts.body.on('error', noop))
|
||||
}
|
||||
this.opts.body = null
|
||||
}
|
||||
|
||||
this.location = this.history.length >= this.maxRedirections || util.isDisturbed(this.opts.body) || redirectableStatusCodes.indexOf(statusCode) === -1
|
||||
? null
|
||||
: headers.location
|
||||
|
||||
const requestOrigin = this.opts[kRequestOrigin] === undefined
|
||||
? this.opts.origin
|
||||
: this.opts[kRequestOrigin]
|
||||
|
||||
if (requestOrigin) {
|
||||
this.history.push(new URL(this.opts.path, requestOrigin))
|
||||
}
|
||||
|
||||
if (!this.location) {
|
||||
this.handler.onResponseStart?.(controller, statusCode, headers, statusMessage)
|
||||
return
|
||||
}
|
||||
|
||||
const baseUrl = requestOrigin
|
||||
? new URL(this.opts.path, requestOrigin)
|
||||
: undefined
|
||||
const { origin, pathname, search } = util.parseURL(new URL(this.location, baseUrl))
|
||||
const path = search ? `${pathname}${search}` : pathname
|
||||
|
||||
// Check for redirect loops by seeing if we've already visited this URL in our history
|
||||
// This catches the case where Client/Pool try to handle cross-origin redirects but fail
|
||||
// and keep redirecting to the same URL in an infinite loop
|
||||
const redirectUrlString = `${origin}${path}`
|
||||
for (const historyUrl of this.history) {
|
||||
if (historyUrl.toString() === redirectUrlString) {
|
||||
throw new InvalidArgumentError(`Redirect loop detected. Cannot redirect to ${origin}. This typically happens when using a Client or Pool with cross-origin redirects. Use an Agent for cross-origin redirects.`)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove headers referring to the original URL.
|
||||
// By default it is Host only. A 303 or a 301/302 POST-to-GET redirect also removes all Content-* headers.
|
||||
// https://tools.ietf.org/html/rfc7231#section-6.4
|
||||
this.opts.headers = cleanRequestHeaders(this.opts.headers, removeContentHeaders, requestOrigin !== origin, this.stripHeadersOnRedirect, this.stripHeadersOnCrossOriginRedirect)
|
||||
this.opts.path = path
|
||||
this.opts.origin = origin
|
||||
this.opts[kRequestOrigin] = origin
|
||||
this.opts.query = null
|
||||
}
|
||||
|
||||
onResponseData (controller, chunk) {
|
||||
if (this.location) {
|
||||
/*
|
||||
https://tools.ietf.org/html/rfc7231#section-6.4
|
||||
|
||||
TLDR: undici always ignores 3xx response bodies.
|
||||
|
||||
Redirection is used to serve the requested resource from another URL, so it assumes that
|
||||
no body is generated (and thus can be ignored). Even though generating a body is not prohibited.
|
||||
|
||||
For status 301, 302, 303, 307 and 308 (the latter from RFC 7238), the specs mention that the body usually
|
||||
(which means it's optional and not mandated) contain just an hyperlink to the value of
|
||||
the Location response header, so the body can be ignored safely.
|
||||
|
||||
For status 300, which is "Multiple Choices", the spec mentions both generating a Location
|
||||
response header AND a response body with the other possible location to follow.
|
||||
Since the spec explicitly chooses not to specify a format for such body and leave it to
|
||||
servers and browsers implementors, we ignore the body as there is no specified way to eventually parse it.
|
||||
*/
|
||||
} else {
|
||||
this.handler.onResponseData?.(controller, chunk)
|
||||
}
|
||||
}
|
||||
|
||||
onResponseEnd (controller, trailers) {
|
||||
if (this.location) {
|
||||
/*
|
||||
https://tools.ietf.org/html/rfc7231#section-6.4
|
||||
|
||||
TLDR: undici always ignores 3xx response trailers as they are not expected in case of redirections
|
||||
and neither are useful if present.
|
||||
|
||||
See comment on onData method above for more detailed information.
|
||||
*/
|
||||
this.dispatch(this.opts, this)
|
||||
} else {
|
||||
this.handler.onResponseEnd(controller, trailers)
|
||||
}
|
||||
}
|
||||
|
||||
onResponseError (controller, error) {
|
||||
this.handler.onResponseError?.(controller, error)
|
||||
}
|
||||
}
|
||||
|
||||
// https://tools.ietf.org/html/rfc7231#section-6.4.4
|
||||
function shouldRemoveHeader (header, removeContent, unknownOrigin, stripHeaders, stripHeadersOnCrossOrigin) {
|
||||
const name = util.headerNameToString(header)
|
||||
if (name === 'host') {
|
||||
return true
|
||||
}
|
||||
if (stripHeaders?.has(name) || (unknownOrigin && stripHeadersOnCrossOrigin?.has(name))) {
|
||||
return true
|
||||
}
|
||||
if (removeContent && name.startsWith('content-')) {
|
||||
return true
|
||||
}
|
||||
if (unknownOrigin) {
|
||||
return name === 'authorization' || name === 'cookie' || name === 'proxy-authorization'
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// https://tools.ietf.org/html/rfc7231#section-6.4
|
||||
function normalizeStripHeaders (headers, optionName) {
|
||||
if (headers == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!Array.isArray(headers)) {
|
||||
throw new InvalidArgumentError(`${optionName} must be an array`)
|
||||
}
|
||||
|
||||
const normalized = new Set()
|
||||
for (const header of headers) {
|
||||
if (typeof header !== 'string') {
|
||||
throw new InvalidArgumentError(`${optionName} must contain header names`)
|
||||
}
|
||||
|
||||
normalized.add(util.headerNameToString(header))
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function cleanRequestHeaders (headers, removeContent, unknownOrigin, stripHeaders, stripHeadersOnCrossOrigin) {
|
||||
const ret = []
|
||||
if (Array.isArray(headers)) {
|
||||
for (let i = 0; i < headers.length; i += 2) {
|
||||
if (!shouldRemoveHeader(headers[i], removeContent, unknownOrigin, stripHeaders, stripHeadersOnCrossOrigin)) {
|
||||
ret.push(headers[i], headers[i + 1])
|
||||
}
|
||||
}
|
||||
} else if (headers && typeof headers === 'object') {
|
||||
const entries = util.hasSafeIterator(headers) ? headers : Object.entries(headers)
|
||||
|
||||
for (const [key, value] of entries) {
|
||||
if (!shouldRemoveHeader(key, removeContent, unknownOrigin, stripHeaders, stripHeadersOnCrossOrigin)) {
|
||||
ret.push(key, value)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
assert(headers == null, 'headers must be an object or an array')
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
module.exports = RedirectHandler
|
||||
+653
@@ -0,0 +1,653 @@
|
||||
'use strict'
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { kRetryHandlerDefaultRetry } = require('../core/symbols')
|
||||
const { RequestRetryError, RequestAbortedError } = require('../core/errors')
|
||||
const {
|
||||
isDisturbed,
|
||||
parseRangeHeader,
|
||||
wrapRequestBody
|
||||
} = require('../core/util')
|
||||
|
||||
function calculateRetryAfterHeader (retryAfter) {
|
||||
const retryTime = new Date(retryAfter).getTime()
|
||||
return isNaN(retryTime) ? null : retryTime - Date.now()
|
||||
}
|
||||
|
||||
function validatePartialResponseContentLength (headers, range, statusCode, retryCount) {
|
||||
const contentLength = headers['content-length']
|
||||
if (contentLength == null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) {
|
||||
return
|
||||
}
|
||||
|
||||
const length = Number(contentLength)
|
||||
const expectedLength = range.end - range.start + 1
|
||||
if (!Number.isFinite(length) || length !== expectedLength) {
|
||||
throw new RequestRetryError('Content-Length mismatch', statusCode, {
|
||||
headers,
|
||||
data: { count: retryCount }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A stable controller handed to the downstream handler for the lifetime of the
|
||||
// request. Each transparent retry/resume is a *separate* dispatch with its
|
||||
// *own* connection controller. Without a stable proxy the downstream body keeps
|
||||
// flow-controlling the original (now-dead) controller while data flows on the
|
||||
// new one: backpressure pauses the new connection's controller, but the
|
||||
// consumer's resume() targets the old one, so the resumed body stalls forever.
|
||||
// The proxy always forwards to the controller of the currently active connection.
|
||||
// An abort is additionally reported to the handler so it can cancel a pending
|
||||
// retry backoff instead of letting the request hang until the backoff elapses.
|
||||
// The notification is a private callback the handler hands over on construction,
|
||||
// so nothing outside the handler can trigger it.
|
||||
class RetryController {
|
||||
#onAbort
|
||||
#paused = false
|
||||
#target = null
|
||||
|
||||
constructor (onAbort) {
|
||||
this.#onAbort = onAbort
|
||||
}
|
||||
|
||||
set target (target) {
|
||||
this.#target = target
|
||||
if (this.#paused) {
|
||||
target?.pause()
|
||||
}
|
||||
}
|
||||
|
||||
get target () { return this.#target }
|
||||
|
||||
pause () {
|
||||
this.#paused = true
|
||||
this.#target?.pause()
|
||||
}
|
||||
|
||||
resume () {
|
||||
this.#paused = false
|
||||
this.#target?.resume()
|
||||
}
|
||||
|
||||
abort (reason) {
|
||||
this.#target?.abort(reason)
|
||||
this.#onAbort(reason)
|
||||
}
|
||||
|
||||
get paused () { return this.#paused || (this.#target?.paused ?? false) }
|
||||
get aborted () { return this.#target?.aborted ?? false }
|
||||
get reason () { return this.#target?.reason ?? null }
|
||||
get rawHeaders () { return this.#target?.rawHeaders ?? null }
|
||||
set rawHeaders (value) {
|
||||
if (this.#target) {
|
||||
this.#target.rawHeaders = value
|
||||
}
|
||||
}
|
||||
|
||||
get rawTrailers () { return this.#target?.rawTrailers ?? null }
|
||||
set rawTrailers (value) {
|
||||
if (this.#target) {
|
||||
this.#target.rawTrailers = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RetryHandler {
|
||||
constructor (opts, { dispatch, handler }) {
|
||||
const { retryOptions, ...dispatchOpts } = opts
|
||||
const {
|
||||
// Retry scoped
|
||||
retry: retryFn,
|
||||
maxRetries,
|
||||
maxTimeout,
|
||||
minTimeout,
|
||||
timeoutFactor,
|
||||
// Response scoped
|
||||
methods,
|
||||
errorCodes,
|
||||
retryAfter,
|
||||
statusCodes,
|
||||
throwOnError
|
||||
} = retryOptions ?? {}
|
||||
|
||||
this.error = null
|
||||
this.dispatch = dispatch
|
||||
this.handler = handler
|
||||
this.opts = { ...dispatchOpts, body: wrapRequestBody(opts.body) }
|
||||
this.retryOpts = {
|
||||
throwOnError: throwOnError ?? true,
|
||||
retry: retryFn ?? RetryHandler[kRetryHandlerDefaultRetry],
|
||||
retryAfter: retryAfter ?? true,
|
||||
maxTimeout: maxTimeout ?? 30 * 1000, // 30s,
|
||||
minTimeout: minTimeout ?? 500, // .5s
|
||||
timeoutFactor: timeoutFactor ?? 2,
|
||||
maxRetries: maxRetries ?? 5,
|
||||
// What errors we should retry
|
||||
methods: methods ?? ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE', 'QUERY'],
|
||||
// Indicates which errors to retry
|
||||
statusCodes: statusCodes ?? [500, 502, 503, 504, 429],
|
||||
// List of errors to retry
|
||||
errorCodes: errorCodes ?? [
|
||||
'ECONNRESET',
|
||||
'ECONNREFUSED',
|
||||
'ENOTFOUND',
|
||||
'ENETDOWN',
|
||||
'ENETUNREACH',
|
||||
'EHOSTDOWN',
|
||||
'EHOSTUNREACH',
|
||||
'EPIPE',
|
||||
'UND_ERR_SOCKET'
|
||||
]
|
||||
}
|
||||
|
||||
this.retryCount = 0
|
||||
this.retryCountCheckpoint = 0
|
||||
this.headersSent = false
|
||||
this.start = 0
|
||||
this.end = null
|
||||
this.etag = null
|
||||
this.statusCode = null
|
||||
this.headers = null
|
||||
this.controllerProxy = new RetryController(reason => this.#onAbort(reason))
|
||||
// A retry decision is in flight (the policy may be holding a backoff
|
||||
// timer). While pending, a consumer abort cancels the wait.
|
||||
this.retryPending = false
|
||||
// Backoff timer returned by the retry policy, so #onAbort can cancel it.
|
||||
// Null for custom policies that do not return their timer.
|
||||
this.retryTimer = null
|
||||
// A response can complete while its controller is paused if the peer closes
|
||||
// the connection. Hold its body until the retry policy decides whether to
|
||||
// discard it for a retry or forward it as the final response.
|
||||
this.pendingResponseData = null
|
||||
this.pendingResponseTrailers = null
|
||||
this.pendingResponseEnded = false
|
||||
// Set once an abort during the backoff delivered the terminal error
|
||||
// downstream; late policy callbacks and connection errors are then moot.
|
||||
this.aborted = false
|
||||
}
|
||||
|
||||
onResponseStartWithRetry (controller, statusCode, headers, statusMessage, err) {
|
||||
if (this.retryOpts.throwOnError) {
|
||||
// Preserve old behavior for status codes that are not eligible for retry
|
||||
if (this.retryOpts.statusCodes.includes(statusCode) === false) {
|
||||
if (this.headersSent) {
|
||||
// The downstream handler already received the response from an
|
||||
// earlier attempt. Forwarding this response would replace the
|
||||
// downstream body and leave the original body pending forever.
|
||||
this.handler.onResponseError?.(this.controllerProxy, err)
|
||||
} else {
|
||||
this.headersSent = true
|
||||
this.checkpointResponseEnd(headers)
|
||||
this.handler.onResponseStart?.(this.controllerProxy, statusCode, headers, statusMessage)
|
||||
}
|
||||
} else {
|
||||
this.error = err
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (isDisturbed(this.opts.body)) {
|
||||
this.headersSent = true
|
||||
this.checkpointResponseEnd(headers)
|
||||
this.handler.onResponseStart?.(this.controllerProxy, statusCode, headers, statusMessage)
|
||||
return
|
||||
}
|
||||
|
||||
function shouldRetry (passedErr) {
|
||||
if (this.aborted) {
|
||||
// Aborted while the policy was deciding; the decision is moot.
|
||||
return
|
||||
}
|
||||
this.retryPending = false
|
||||
this.retryTimer = null
|
||||
|
||||
const pendingData = this.pendingResponseData
|
||||
const pendingTrailers = this.pendingResponseTrailers
|
||||
const pendingEnd = this.pendingResponseEnded
|
||||
this.pendingResponseData = null
|
||||
this.pendingResponseTrailers = null
|
||||
this.pendingResponseEnded = false
|
||||
|
||||
if (passedErr) {
|
||||
if (this.headersSent) {
|
||||
// The downstream handler already received the response from an
|
||||
// earlier attempt. Forwarding this response would replace the
|
||||
// downstream body and leave the original body pending forever.
|
||||
this.handler.onResponseError?.(this.controllerProxy, passedErr)
|
||||
} else {
|
||||
this.headersSent = true
|
||||
this.checkpointResponseEnd(headers)
|
||||
this.handler.onResponseStart?.(this.controllerProxy, statusCode, headers, statusMessage)
|
||||
controller.resume()
|
||||
|
||||
if (pendingEnd) {
|
||||
for (const chunk of pendingData) {
|
||||
this.onResponseData(controller, chunk)
|
||||
}
|
||||
this.onResponseEnd(controller, pendingTrailers)
|
||||
}
|
||||
return
|
||||
}
|
||||
controller.resume()
|
||||
return
|
||||
}
|
||||
|
||||
this.error = err
|
||||
controller.resume()
|
||||
if (pendingEnd) {
|
||||
this.onResponseEnd(controller, pendingTrailers)
|
||||
}
|
||||
}
|
||||
|
||||
// The pause()/resume() pair (here and in shouldRetry) acts on THIS
|
||||
// connection's controller -- never the downstream proxy. We hold this exact
|
||||
// connection while the retry policy decides (possibly after a timeout) and
|
||||
// must resume the same one. Routing this through controllerProxy would risk
|
||||
// resuming a different connection if a later dispatch re-points the proxy in
|
||||
// between, leaving this one paused forever -- the very stall the proxy exists
|
||||
// to prevent.
|
||||
controller.pause()
|
||||
// The default policy returns its backoff timer so an abort can cancel it;
|
||||
// a custom policy may return anything (or nothing), which is ignored.
|
||||
this.retryPending = true
|
||||
this.pendingResponseData = []
|
||||
this.pendingResponseTrailers = null
|
||||
this.pendingResponseEnded = false
|
||||
this.retryTimer = this.retryOpts.retry(
|
||||
err,
|
||||
{
|
||||
state: { counter: this.retryCount },
|
||||
opts: { retryOptions: this.retryOpts, ...this.opts }
|
||||
},
|
||||
shouldRetry.bind(this)
|
||||
) ?? null
|
||||
}
|
||||
|
||||
checkpointResponseEnd (headers) {
|
||||
if (this.end == null && this.opts.method !== 'HEAD') {
|
||||
const contentLength = headers['content-length']
|
||||
this.end = contentLength != null ? Number(contentLength) - 1 : null
|
||||
|
||||
assert(
|
||||
this.end == null || Number.isFinite(this.end),
|
||||
'invalid content-length'
|
||||
)
|
||||
|
||||
this.resume = this.end != null
|
||||
}
|
||||
}
|
||||
|
||||
onRequestStart (controller, context) {
|
||||
// request.js creates a fresh RequestController per dispatch and passes that
|
||||
// same instance to every later callback of the dispatch. onRequestStart is
|
||||
// the first callback (it is where the controller is created), so re-pointing
|
||||
// the proxy here is enough to keep it on the active connection across every
|
||||
// transparent retry/resume.
|
||||
this.controllerProxy.target = controller
|
||||
if (!this.headersSent) {
|
||||
this.handler.onRequestStart?.(this.controllerProxy, context)
|
||||
}
|
||||
}
|
||||
|
||||
onBodySent (chunk) {
|
||||
this.handler.onBodySent?.(chunk)
|
||||
}
|
||||
|
||||
onRequestSent () {
|
||||
this.handler.onRequestSent?.()
|
||||
}
|
||||
|
||||
onRequestUpgrade (_controller, statusCode, headers, socket) {
|
||||
this.handler.onRequestUpgrade?.(this.controllerProxy, statusCode, headers, socket)
|
||||
}
|
||||
|
||||
static [kRetryHandlerDefaultRetry] (err, { state, opts }, cb) {
|
||||
const { statusCode, code, headers } = err
|
||||
const { method, retryOptions } = opts
|
||||
const {
|
||||
maxRetries,
|
||||
minTimeout,
|
||||
maxTimeout,
|
||||
timeoutFactor,
|
||||
statusCodes,
|
||||
errorCodes,
|
||||
methods,
|
||||
retryAfter
|
||||
} = retryOptions
|
||||
const { counter } = state
|
||||
|
||||
// Any code that is not a Undici's originated and allowed to retry
|
||||
if (code && code !== 'UND_ERR_REQ_RETRY' && !errorCodes.includes(code)) {
|
||||
cb(err)
|
||||
return
|
||||
}
|
||||
|
||||
// If a set of method are provided and the current method is not in the list
|
||||
if (Array.isArray(methods) && !methods.includes(method)) {
|
||||
cb(err)
|
||||
return
|
||||
}
|
||||
|
||||
// If a set of status code are provided and the current status code is not in the list
|
||||
if (
|
||||
statusCode != null &&
|
||||
Array.isArray(statusCodes) &&
|
||||
!statusCodes.includes(statusCode)
|
||||
) {
|
||||
cb(err)
|
||||
return
|
||||
}
|
||||
|
||||
// If we reached the max number of retries
|
||||
if (counter > maxRetries) {
|
||||
cb(err)
|
||||
return
|
||||
}
|
||||
|
||||
let retryAfterHeader = retryAfter === false ? undefined : headers?.['retry-after']
|
||||
if (retryAfterHeader) {
|
||||
retryAfterHeader = Number(retryAfterHeader)
|
||||
retryAfterHeader = Number.isNaN(retryAfterHeader)
|
||||
? calculateRetryAfterHeader(headers['retry-after'])
|
||||
: retryAfterHeader * 1e3 // Retry-After is in seconds
|
||||
}
|
||||
|
||||
const retryTimeout =
|
||||
retryAfterHeader === 0
|
||||
? 0
|
||||
: retryAfterHeader > 0
|
||||
? Math.min(retryAfterHeader, maxTimeout)
|
||||
: Math.min(minTimeout * timeoutFactor ** (counter - 1), maxTimeout)
|
||||
|
||||
// Return the backoff timer so the handler can cancel it when the
|
||||
// consumer aborts while the retry decision is pending.
|
||||
return setTimeout(() => cb(null), retryTimeout)
|
||||
}
|
||||
|
||||
onResponseStart (controller, statusCode, headers, statusMessage) {
|
||||
if (statusCode < 200) {
|
||||
this.handler.onResponseStart?.(this.controllerProxy, statusCode, headers, statusMessage)
|
||||
return
|
||||
}
|
||||
|
||||
this.error = null
|
||||
this.retryCount += 1
|
||||
this.statusCode = statusCode
|
||||
this.headers = headers
|
||||
|
||||
// Checkpoint for resume from where we left it
|
||||
if (this.headersSent) {
|
||||
// Only Partial Content 206 supposed to provide Content-Range,
|
||||
// any other status code that partially consumed the payload
|
||||
// should not be retried because it would result in downstream
|
||||
// wrongly concatenate multiple responses.
|
||||
if (statusCode !== 206 && (this.start > 0 || statusCode !== 200)) {
|
||||
throw new RequestRetryError('server does not support the range header and the payload was partially consumed', statusCode, {
|
||||
headers,
|
||||
data: { count: this.retryCount }
|
||||
})
|
||||
}
|
||||
|
||||
const contentRange = parseRangeHeader(headers['content-range'])
|
||||
// If no content range
|
||||
if (!contentRange) {
|
||||
// We always throw here as we want to indicate that we entred unexpected path
|
||||
throw new RequestRetryError('Content-Range mismatch', statusCode, {
|
||||
headers,
|
||||
data: { count: this.retryCount }
|
||||
})
|
||||
}
|
||||
|
||||
// Let's start with a weak etag check
|
||||
if (this.etag != null && this.etag !== headers.etag) {
|
||||
// We always throw here as we want to indicate that we entred unexpected path
|
||||
throw new RequestRetryError('ETag mismatch', statusCode, {
|
||||
headers,
|
||||
data: { count: this.retryCount }
|
||||
})
|
||||
}
|
||||
|
||||
validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount)
|
||||
|
||||
const { start, size, end = size ? size - 1 : null } = contentRange
|
||||
|
||||
if (this.start !== start || (this.end != null && this.end !== end)) {
|
||||
throw new RequestRetryError('Content-Range mismatch', statusCode, {
|
||||
headers,
|
||||
data: { count: this.retryCount }
|
||||
})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (statusCode >= 300) {
|
||||
const err = new RequestRetryError('Request failed', statusCode, {
|
||||
headers,
|
||||
data: {
|
||||
count: this.retryCount
|
||||
}
|
||||
})
|
||||
|
||||
this.onResponseStartWithRetry(controller, statusCode, headers, statusMessage, err)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.end == null) {
|
||||
if (statusCode === 206) {
|
||||
// First time we receive 206
|
||||
const range = parseRangeHeader(headers['content-range'])
|
||||
|
||||
if (range == null || range.end == null) {
|
||||
this.headersSent = true
|
||||
this.handler.onResponseStart?.(
|
||||
this.controllerProxy,
|
||||
statusCode,
|
||||
headers,
|
||||
statusMessage
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
validatePartialResponseContentLength(headers, range, statusCode, this.retryCount)
|
||||
|
||||
const { start, size, end = size ? size - 1 : null } = range
|
||||
assert(
|
||||
start != null && Number.isFinite(start),
|
||||
'content-range mismatch'
|
||||
)
|
||||
assert(end != null && Number.isFinite(end), 'invalid content-length')
|
||||
|
||||
this.start = start
|
||||
this.end = end
|
||||
}
|
||||
|
||||
// We make our best to checkpoint the body for further range headers
|
||||
if (this.end == null && this.opts.method !== 'HEAD') {
|
||||
const contentLength = headers['content-length']
|
||||
this.end = contentLength != null ? Number(contentLength) - 1 : null
|
||||
}
|
||||
|
||||
assert(Number.isFinite(this.start))
|
||||
assert(
|
||||
this.end == null || Number.isFinite(this.end),
|
||||
'invalid content-length'
|
||||
)
|
||||
|
||||
this.resume = true
|
||||
this.etag = headers.etag != null ? headers.etag : null
|
||||
|
||||
// Weak etags are not useful for comparison nor cache
|
||||
// for instance not safe to assume if the response is byte-per-byte
|
||||
// equal
|
||||
if (
|
||||
this.etag != null &&
|
||||
this.etag[0] === 'W' &&
|
||||
this.etag[1] === '/'
|
||||
) {
|
||||
this.etag = null
|
||||
}
|
||||
|
||||
this.headersSent = true
|
||||
this.handler.onResponseStart?.(
|
||||
this.controllerProxy,
|
||||
statusCode,
|
||||
headers,
|
||||
statusMessage
|
||||
)
|
||||
} else {
|
||||
throw new RequestRetryError('Request failed', statusCode, {
|
||||
headers,
|
||||
data: { count: this.retryCount }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onResponseData (_controller, chunk) {
|
||||
if (this.pendingResponseData !== null) {
|
||||
this.pendingResponseData.push(chunk)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.error) {
|
||||
return
|
||||
}
|
||||
|
||||
this.start += chunk.length
|
||||
|
||||
this.handler.onResponseData?.(this.controllerProxy, chunk)
|
||||
}
|
||||
|
||||
onResponseEnd (_controller, trailers) {
|
||||
if (this.pendingResponseData !== null) {
|
||||
this.pendingResponseTrailers = trailers
|
||||
this.pendingResponseEnded = true
|
||||
return
|
||||
}
|
||||
|
||||
if (this.error && this.retryOpts.throwOnError) {
|
||||
throw this.error
|
||||
}
|
||||
|
||||
if (!this.error) {
|
||||
// Verify that the received body length matches the expected range
|
||||
// when we have a finite end position (from Content-Length or Content-Range)
|
||||
if (this.end != null && Number.isFinite(this.end)) {
|
||||
if (this.start !== this.end + 1) {
|
||||
throw new RequestRetryError('Content-Range mismatch', this.statusCode, {
|
||||
headers: this.headers,
|
||||
data: { count: this.retryCount }
|
||||
})
|
||||
}
|
||||
}
|
||||
this.retryCount = 0
|
||||
return this.handler.onResponseEnd?.(this.controllerProxy, trailers)
|
||||
}
|
||||
|
||||
this.retry()
|
||||
}
|
||||
|
||||
retry () {
|
||||
if (this.start !== 0) {
|
||||
const headers = { range: `bytes=${this.start}-${this.end ?? ''}` }
|
||||
|
||||
// Weak etag check - weak etags will make comparison algorithms never match
|
||||
if (this.etag != null) {
|
||||
headers['if-match'] = this.etag
|
||||
}
|
||||
|
||||
this.opts = {
|
||||
...this.opts,
|
||||
headers: {
|
||||
...this.opts.headers,
|
||||
...headers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
this.retryCountCheckpoint = this.retryCount
|
||||
this.dispatch(this.opts, this)
|
||||
} catch (err) {
|
||||
this.handler.onResponseError?.(this.controllerProxy, err)
|
||||
}
|
||||
}
|
||||
|
||||
onResponseError (controller, err) {
|
||||
if (this.aborted) {
|
||||
// #onAbort already delivered the terminal error downstream; the late
|
||||
// error of the torn-down connection must not be forwarded twice.
|
||||
return
|
||||
}
|
||||
|
||||
// controller is THIS failed connection (not the proxy): we inspect whether
|
||||
// the consumer aborted it to decide retry-vs-propagate.
|
||||
if (controller?.aborted || isDisturbed(this.opts.body) || (this.headersSent && !this.resume)) {
|
||||
this.handler.onResponseError?.(this.controllerProxy, err)
|
||||
return
|
||||
}
|
||||
|
||||
function shouldRetry (returnedErr) {
|
||||
if (this.aborted) {
|
||||
// Aborted while the policy was deciding; the decision is moot.
|
||||
return
|
||||
}
|
||||
this.retryPending = false
|
||||
this.retryTimer = null
|
||||
|
||||
if (!returnedErr) {
|
||||
this.retry()
|
||||
return
|
||||
}
|
||||
|
||||
this.handler?.onResponseError?.(this.controllerProxy, returnedErr)
|
||||
}
|
||||
|
||||
// We reconcile in case of a mix between network errors
|
||||
// and server error response
|
||||
if (this.retryCount - this.retryCountCheckpoint > 0) {
|
||||
// We count the difference between the last checkpoint and the current retry count
|
||||
this.retryCount =
|
||||
this.retryCountCheckpoint +
|
||||
(this.retryCount - this.retryCountCheckpoint)
|
||||
} else {
|
||||
this.retryCount += 1
|
||||
}
|
||||
|
||||
this.retryPending = true
|
||||
this.retryTimer = this.retryOpts.retry(
|
||||
err,
|
||||
{
|
||||
state: { counter: this.retryCount },
|
||||
opts: { retryOptions: this.retryOpts, ...this.opts }
|
||||
},
|
||||
shouldRetry.bind(this)
|
||||
) ?? null
|
||||
}
|
||||
|
||||
#onAbort (reason) {
|
||||
// A consumer abort lands on the controller proxy. If the retry policy is
|
||||
// still deciding (typically holding a backoff timer), cancel the wait and
|
||||
// surface the abort immediately instead of letting the request hang until
|
||||
// the backoff elapses.
|
||||
if (!this.retryPending) {
|
||||
return
|
||||
}
|
||||
|
||||
this.aborted = true
|
||||
this.retryPending = false
|
||||
clearTimeout(this.retryTimer)
|
||||
this.retryTimer = null
|
||||
this.pendingResponseData = null
|
||||
this.pendingResponseTrailers = null
|
||||
this.pendingResponseEnded = false
|
||||
this.handler.onResponseError?.(this.controllerProxy, reason ?? new RequestAbortedError())
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RetryHandler
|
||||
Reference in new issue
Block a user