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:
erikandClaude Sonnet 5 committed 2026-10-05 14:01:41 +02:00
1 parent db63a29365
commit 6ba93b2a92
308 files changed
+64441 -743

No files matched your search

+818
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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