diff options
Diffstat (limited to 'vanilla/node_modules/undici/lib/handler')
8 files changed, 1790 insertions, 0 deletions
diff --git a/vanilla/node_modules/undici/lib/handler/cache-handler.js b/vanilla/node_modules/undici/lib/handler/cache-handler.js new file mode 100644 index 0000000..93a70e8 --- /dev/null +++ b/vanilla/node_modules/undici/lib/handler/cache-handler.js @@ -0,0 +1,561 @@ +'use strict' + +const util = require('../core/util') +const { + parseCacheControlHeader, + parseVaryHeader, + 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 + +/** + * @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) + } + + 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 ( + !util.safeHTTPMethods.includes(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 + try { + this.#store.delete(this.#cacheKey)?.catch?.(noop) + } catch { + // Fail silently + } + return downstreamOnHeaders() + } + + const cacheControlHeader = resHeaders['cache-control'] + const heuristicallyCacheable = resHeaders['last-modified'] && HEURISTICALLY_CACHEABLE_STATUS_CODES.includes(statusCode) + if ( + !cacheControlHeader && + !resHeaders['expires'] && + !heuristicallyCacheable && + !this.#cacheByDefault + ) { + // Don't have anything to tell us this response is cachable and we're not + // caching by default + return downstreamOnHeaders() + } + + const cacheControlDirectives = cacheControlHeader ? parseCacheControlHeader(cacheControlHeader) : {} + if (!canCacheResponse(this.#cacheType, statusCode, resHeaders, cacheControlDirectives)) { + return downstreamOnHeaders() + } + + const now = Date.now() + const resAge = resHeaders.age ? getAge(resHeaders.age) : undefined + if (resAge && resAge >= MAX_RESPONSE_AGE) { + // Response considered stale + return downstreamOnHeaders() + } + + const resDate = typeof resHeaders.date === 'string' + ? parseHttpDate(resHeaders.date) + : undefined + + const staleAt = + determineStaleAt(this.#cacheType, now, resAge, resHeaders, resDate, cacheControlDirectives) ?? + this.#cacheByDefault + if (staleAt === undefined || (resAge && resAge > staleAt)) { + return downstreamOnHeaders() + } + + const baseTime = resDate ? resDate.getTime() : now + const absoluteStaleAt = staleAt + baseTime + if (now >= absoluteStaleAt) { + // Response is already stale + return downstreamOnHeaders() + } + + let varyDirectives + if (this.#cacheKey.headers && resHeaders.vary) { + varyDirectives = parseVaryHeader(resHeaders.vary, this.#cacheKey.headers) + if (!varyDirectives) { + // Parse error + return downstreamOnHeaders() + } + } + + const deleteAt = determineDeleteAt(baseTime, 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: resAge ? now - resAge : now, + 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.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) + } +} + +/** + * @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 {number} statusCode + * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders + * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives + */ +function canCacheResponse (cacheType, statusCode, resHeaders, cacheControlDirectives) { + // Status code must be final and understood. + if (statusCode < 200 || NOT_UNDERSTOOD_STATUS_CODES.includes(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 (!HEURISTICALLY_CACHEABLE_STATUS_CODES.includes(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) { + return false + } + + // https://www.rfc-editor.org/rfc/rfc9111.html#section-4.1-5 + if (resHeaders.vary?.includes('*')) { + return false + } + + // https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen + if (resHeaders.authorization) { + if (!cacheControlDirectives.public || typeof resHeaders.authorization !== 'string') { + return false + } + + if ( + Array.isArray(cacheControlDirectives['no-cache']) && + cacheControlDirectives['no-cache'].includes('authorization') + ) { + return false + } + + if ( + Array.isArray(cacheControlDirectives['private']) && + cacheControlDirectives['private'].includes('authorization') + ) { + return false + } + } + + return true +} + +/** + * @param {string | string[]} ageHeader + * @returns {number | undefined} + */ +function getAge (ageHeader) { + const age = parseInt(Array.isArray(ageHeader) ? ageHeader[0] : ageHeader) + + return isNaN(age) ? undefined : 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 + * + * @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) { + if (cacheType === 'shared') { + // Prioritize s-maxage since we're a shared cache + // s-maxage > max-age > Expire + // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.10-3 + const sMaxAge = cacheControlDirectives['s-maxage'] + if (sMaxAge !== undefined) { + return sMaxAge > 0 ? sMaxAge * 1000 : undefined + } + } + + const maxAge = cacheControlDirectives['max-age'] + if (maxAge !== undefined) { + return maxAge > 0 ? maxAge * 1000 : undefined + } + + if (typeof resHeaders.expires === 'string') { + // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.3 + const expiresDate = parseHttpDate(resHeaders.expires) + if (expiresDate) { + if (now >= expiresDate.getTime()) { + return undefined + } + + if (responseDate) { + if (responseDate >= expiresDate) { + return undefined + } + + if (age !== undefined && age > (expiresDate - responseDate)) { + return undefined + } + } + + return expiresDate.getTime() - now + } + } + + if (typeof resHeaders['last-modified'] === 'string') { + // https://www.rfc-editor.org/rfc/rfc9111.html#name-calculating-heuristic-fresh + const lastModified = new Date(resHeaders['last-modified']) + if (isValidDate(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 31536000 + } + + return undefined +} + +/** + * @param {number} now + * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives + * @param {number} staleAt + */ +function determineDeleteAt (now, 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 (staleWhileRevalidate === -Infinity && staleIfError === -Infinity) { + immutable = now + 31536000000 + } + + 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']) { + if (Array.isArray(resHeaders['connection'])) { + // connection: a + // connection: b + headersToRemove.push(...resHeaders['connection'].map(header => header.trim())) + } else { + // connection: a, b + headersToRemove.push(...resHeaders['connection'].split(',').map(header => header.trim())) + } + } + + 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 (resHeaders[headerName]) { + strippedHeaders ??= { ...resHeaders } + delete strippedHeaders[headerName] + } + } + + return strippedHeaders ?? resHeaders +} + +/** + * @param {Date} date + * @returns {boolean} + */ +function isValidDate (date) { + return date instanceof Date && Number.isFinite(date.valueOf()) +} + +module.exports = CacheHandler diff --git a/vanilla/node_modules/undici/lib/handler/cache-revalidation-handler.js b/vanilla/node_modules/undici/lib/handler/cache-revalidation-handler.js new file mode 100644 index 0000000..393d16d --- /dev/null +++ b/vanilla/node_modules/undici/lib/handler/cache-revalidation-handler.js @@ -0,0 +1,124 @@ +'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 {((boolean, any) => void) | null} + */ + #callback + + /** + * @type {(import('../../types/dispatcher.d.ts').default.DispatchHandler)} + */ + #handler + + #context + + /** + * @type {boolean} + */ + #allowErrorStatusCodes + + /** + * @param {(boolean) => void} callback Function to call if the cached value is valid + * @param {import('../../types/dispatcher.d.ts').default.DispatchHandlers} handler + * @param {boolean} allowErrorStatusCodes + */ + constructor (callback, handler, allowErrorStatusCodes) { + if (typeof callback !== 'function') { + 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) + 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) { + this.#callback(false) + this.#callback = null + } + + if (typeof this.#handler.onResponseError === 'function') { + this.#handler.onResponseError(controller, err) + } else { + throw err + } + } +} + +module.exports = CacheRevalidationHandler diff --git a/vanilla/node_modules/undici/lib/handler/decorator-handler.js b/vanilla/node_modules/undici/lib/handler/decorator-handler.js new file mode 100644 index 0000000..50fbb0c --- /dev/null +++ b/vanilla/node_modules/undici/lib/handler/decorator-handler.js @@ -0,0 +1,67 @@ +'use strict' + +const assert = require('node:assert') +const WrapHandler = require('./wrap-handler') + +/** + * @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 = WrapHandler.wrap(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 () {} +} diff --git a/vanilla/node_modules/undici/lib/handler/deduplication-handler.js b/vanilla/node_modules/undici/lib/handler/deduplication-handler.js new file mode 100644 index 0000000..33a2c4f --- /dev/null +++ b/vanilla/node_modules/undici/lib/handler/deduplication-handler.js @@ -0,0 +1,216 @@ +'use strict' + +/** + * @typedef {import('../../types/dispatcher.d.ts').default.DispatchHandler} DispatchHandler + */ + +/** + * Handler that buffers response data and notifies multiple waiting handlers. + * Used for request deduplication. + * + * @implements {DispatchHandler} + */ +class DeduplicationHandler { + /** + * @type {DispatchHandler} + */ + #primaryHandler + + /** + * @type {DispatchHandler[]} + */ + #waitingHandlers = [] + + /** + * @type {Buffer[]} + */ + #chunks = [] + + /** + * @type {number} + */ + #statusCode = 0 + + /** + * @type {Record<string, string | string[]>} + */ + #headers = {} + + /** + * @type {string} + */ + #statusMessage = '' + + /** + * @type {boolean} + */ + #aborted = 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 + */ + constructor (primaryHandler, onComplete) { + this.#primaryHandler = primaryHandler + this.#onComplete = onComplete + } + + /** + * Add a waiting handler that will receive the buffered response + * @param {DispatchHandler} handler + */ + addWaitingHandler (handler) { + this.#waitingHandlers.push(handler) + } + + /** + * @param {() => void} abort + * @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.#statusCode = statusCode + this.#headers = headers + this.#statusMessage = statusMessage + this.#primaryHandler.onResponseStart?.(controller, statusCode, headers, statusMessage) + } + + /** + * @param {import('../../types/dispatcher.d.ts').default.DispatchController} controller + * @param {Buffer} chunk + */ + onResponseData (controller, chunk) { + // Buffer the chunk for waiting handlers + this.#chunks.push(Buffer.from(chunk)) + this.#primaryHandler.onResponseData?.(controller, chunk) + } + + /** + * @param {import('../../types/dispatcher.d.ts').default.DispatchController} controller + * @param {object} trailers + */ + onResponseEnd (controller, trailers) { + this.#primaryHandler.onResponseEnd?.(controller, trailers) + this.#notifyWaitingHandlers() + this.#onComplete?.() + } + + /** + * @param {import('../../types/dispatcher.d.ts').default.DispatchController} controller + * @param {Error} err + */ + onResponseError (controller, err) { + this.#aborted = true + this.#primaryHandler.onResponseError?.(controller, err) + this.#notifyWaitingHandlersError(err) + this.#onComplete?.() + } + + /** + * Notify all waiting handlers with the buffered response + */ + #notifyWaitingHandlers () { + const body = Buffer.concat(this.#chunks) + + for (const handler of this.#waitingHandlers) { + // Create a simple controller for each waiting handler + const waitingController = { + resume () {}, + pause () {}, + get paused () { return false }, + get aborted () { return false }, + get reason () { return null }, + abort () {} + } + + try { + handler.onRequestStart?.(waitingController, null) + + if (waitingController.aborted) { + continue + } + + handler.onResponseStart?.( + waitingController, + this.#statusCode, + this.#headers, + this.#statusMessage + ) + + if (waitingController.aborted) { + continue + } + + if (body.length > 0) { + handler.onResponseData?.(waitingController, body) + } + + handler.onResponseEnd?.(waitingController, {}) + } catch { + // Ignore errors from waiting handlers + } + } + + this.#waitingHandlers = [] + this.#chunks = [] + } + + /** + * Notify all waiting handlers of an error + * @param {Error} err + */ + #notifyWaitingHandlersError (err) { + for (const handler of this.#waitingHandlers) { + const waitingController = { + resume () {}, + pause () {}, + get paused () { return false }, + get aborted () { return true }, + get reason () { return err }, + abort () {} + } + + try { + handler.onRequestStart?.(waitingController, null) + handler.onResponseError?.(waitingController, err) + } catch { + // Ignore errors from waiting handlers + } + } + + this.#waitingHandlers = [] + this.#chunks = [] + } +} + +module.exports = DeduplicationHandler diff --git a/vanilla/node_modules/undici/lib/handler/redirect-handler.js b/vanilla/node_modules/undici/lib/handler/redirect-handler.js new file mode 100644 index 0000000..dd0f471 --- /dev/null +++ b/vanilla/node_modules/undici/lib/handler/redirect-handler.js @@ -0,0 +1,237 @@ +'use strict' + +const util = require('../core/util') +const { kBodyUsed } = require('../core/symbols') +const assert = require('node:assert') +const { InvalidArgumentError } = require('../core/errors') +const EE = require('node:events') + +const redirectableStatusCodes = [300, 301, 302, 303, 307, 308] + +const kBody = Symbol('body') + +const noop = () => {} + +class BodyAsyncIterable { + constructor (body) { + this[kBody] = body + this[kBodyUsed] = false + } + + async * [Symbol.asyncIterator] () { + assert(!this[kBodyUsed], 'disturbed') + this[kBodyUsed] = true + yield * this[kBody] + } +} + +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') + } + + this.dispatch = dispatch + this.location = null + const { maxRedirections: _, ...cleanOpts } = opts + this.opts = cleanOpts // opts must be a copy, exclude maxRedirections + this.maxRedirections = maxRedirections + this.handler = handler + this.history = [] + + if (util.isStream(this.opts.body)) { + // TODO (fix): Provide some way for the user to cache the file to e.g. /tmp + // so that it can be dispatched again? + // TODO (fix): Do we need 100-expect support to provide a way to do this properly? + if (util.bodyLength(this.opts.body) === 0) { + this.opts.body + .on('data', function () { + assert(false) + }) + } + + if (typeof this.opts.body.readableDidRead !== 'boolean') { + this.opts.body[kBodyUsed] = false + EE.prototype.on.call(this.opts.body, 'data', function () { + this[kBodyUsed] = true + }) + } + } else if (this.opts.body && typeof this.opts.body.pipeTo === 'function') { + // TODO (fix): We can't access ReadableStream internal state + // to determine whether or not it has been disturbed. This is just + // a workaround. + this.opts.body = new BodyAsyncIterable(this.opts.body) + } else if ( + this.opts.body && + typeof this.opts.body !== 'string' && + !ArrayBuffer.isView(this.opts.body) && + util.isIterable(this.opts.body) && + !util.isFormDataLike(this.opts.body) + ) { + // TODO: Should we allow re-using iterable if !this.opts.idempotent + // or through some other flag? + this.opts.body = new BodyAsyncIterable(this.opts.body) + } + } + + onRequestStart (controller, context) { + this.handler.onRequestStart?.(controller, { ...context, history: this.history }) + } + + onRequestUpgrade (controller, statusCode, headers, socket) { + this.handler.onRequestUpgrade?.(controller, statusCode, headers, socket) + } + + onResponseStart (controller, statusCode, headers, statusMessage) { + if (this.opts.throwOnMaxRedirect && this.history.length >= this.maxRedirections) { + throw new Error('max redirects') + } + + // 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 + 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 + } + + // 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 + + if (this.opts.origin) { + this.history.push(new URL(this.opts.path, this.opts.origin)) + } + + if (!this.location) { + this.handler.onResponseStart?.(controller, statusCode, headers, statusMessage) + return + } + + const { origin, pathname, search } = util.parseURL(new URL(this.location, this.opts.origin && new URL(this.opts.path, this.opts.origin))) + 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, unless it's a 303 (see below), which removes also all Content-* headers. + // https://tools.ietf.org/html/rfc7231#section-6.4 + this.opts.headers = cleanRequestHeaders(this.opts.headers, statusCode === 303, this.opts.origin !== origin) + this.opts.path = path + this.opts.origin = 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) { + if (header.length === 4) { + return util.headerNameToString(header) === 'host' + } + if (removeContent && util.headerNameToString(header).startsWith('content-')) { + return true + } + if (unknownOrigin && (header.length === 13 || header.length === 6 || header.length === 19)) { + const name = util.headerNameToString(header) + return name === 'authorization' || name === 'cookie' || name === 'proxy-authorization' + } + return false +} + +// https://tools.ietf.org/html/rfc7231#section-6.4 +function cleanRequestHeaders (headers, removeContent, unknownOrigin) { + const ret = [] + if (Array.isArray(headers)) { + for (let i = 0; i < headers.length; i += 2) { + if (!shouldRemoveHeader(headers[i], removeContent, unknownOrigin)) { + ret.push(headers[i], headers[i + 1]) + } + } + } else if (headers && typeof headers === 'object') { + const entries = typeof headers[Symbol.iterator] === 'function' ? headers : Object.entries(headers) + for (const [key, value] of entries) { + if (!shouldRemoveHeader(key, removeContent, unknownOrigin)) { + ret.push(key, value) + } + } + } else { + assert(headers == null, 'headers must be an object or an array') + } + return ret +} + +module.exports = RedirectHandler diff --git a/vanilla/node_modules/undici/lib/handler/retry-handler.js b/vanilla/node_modules/undici/lib/handler/retry-handler.js new file mode 100644 index 0000000..1cbc789 --- /dev/null +++ b/vanilla/node_modules/undici/lib/handler/retry-handler.js @@ -0,0 +1,394 @@ +'use strict' +const assert = require('node:assert') + +const { kRetryHandlerDefaultRetry } = require('../core/symbols') +const { RequestRetryError } = require('../core/errors') +const WrapHandler = require('./wrap-handler') +const { + isDisturbed, + parseRangeHeader, + wrapRequestBody +} = require('../core/util') + +function calculateRetryAfterHeader (retryAfter) { + const retryTime = new Date(retryAfter).getTime() + return isNaN(retryTime) ? 0 : retryTime - Date.now() +} + +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 = WrapHandler.wrap(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'], + // 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 + } + + 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) { + this.headersSent = true + this.handler.onResponseStart?.(controller, statusCode, headers, statusMessage) + } else { + this.error = err + } + + return + } + + if (isDisturbed(this.opts.body)) { + this.headersSent = true + this.handler.onResponseStart?.(controller, statusCode, headers, statusMessage) + return + } + + function shouldRetry (passedErr) { + if (passedErr) { + this.headersSent = true + this.handler.onResponseStart?.(controller, statusCode, headers, statusMessage) + controller.resume() + return + } + + this.error = err + controller.resume() + } + + controller.pause() + this.retryOpts.retry( + err, + { + state: { counter: this.retryCount }, + opts: { retryOptions: this.retryOpts, ...this.opts } + }, + shouldRetry.bind(this) + ) + } + + onRequestStart (controller, context) { + if (!this.headersSent) { + this.handler.onRequestStart?.(controller, context) + } + } + + onRequestUpgrade (controller, statusCode, headers, socket) { + this.handler.onRequestUpgrade?.(controller, 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 + } = 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 = 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 + ? Math.min(retryAfterHeader, maxTimeout) + : Math.min(minTimeout * timeoutFactor ** (counter - 1), maxTimeout) + + setTimeout(() => cb(null), retryTimeout) + } + + onResponseStart (controller, statusCode, headers, statusMessage) { + this.error = null + this.retryCount += 1 + + if (statusCode >= 300) { + const err = new RequestRetryError('Request failed', statusCode, { + headers, + data: { + count: this.retryCount + } + }) + + this.onResponseStartWithRetry(controller, statusCode, headers, statusMessage, err) + return + } + + // 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 } + }) + } + + const { start, size, end = size ? size - 1 : null } = contentRange + + assert(this.start === start, 'content-range mismatch') + assert(this.end == null || this.end === end, 'content-range mismatch') + + return + } + + if (this.end == null) { + if (statusCode === 206) { + // First time we receive 206 + const range = parseRangeHeader(headers['content-range']) + + if (range == null) { + this.headersSent = true + this.handler.onResponseStart?.( + controller, + statusCode, + headers, + statusMessage + ) + return + } + + 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) { + 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?.( + controller, + statusCode, + headers, + statusMessage + ) + } else { + throw new RequestRetryError('Request failed', statusCode, { + headers, + data: { count: this.retryCount } + }) + } + } + + onResponseData (controller, chunk) { + if (this.error) { + return + } + + this.start += chunk.length + + this.handler.onResponseData?.(controller, chunk) + } + + onResponseEnd (controller, trailers) { + if (this.error && this.retryOpts.throwOnError) { + throw this.error + } + + if (!this.error) { + this.retryCount = 0 + return this.handler.onResponseEnd?.(controller, trailers) + } + + this.retry(controller) + } + + retry (controller) { + 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?.(controller, err) + } + } + + onResponseError (controller, err) { + if (controller?.aborted || isDisturbed(this.opts.body)) { + this.handler.onResponseError?.(controller, err) + return + } + + function shouldRetry (returnedErr) { + if (!returnedErr) { + this.retry(controller) + return + } + + this.handler?.onResponseError?.(controller, 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.retryOpts.retry( + err, + { + state: { counter: this.retryCount }, + opts: { retryOptions: this.retryOpts, ...this.opts } + }, + shouldRetry.bind(this) + ) + } +} + +module.exports = RetryHandler diff --git a/vanilla/node_modules/undici/lib/handler/unwrap-handler.js b/vanilla/node_modules/undici/lib/handler/unwrap-handler.js new file mode 100644 index 0000000..865593a --- /dev/null +++ b/vanilla/node_modules/undici/lib/handler/unwrap-handler.js @@ -0,0 +1,96 @@ +'use strict' + +const { parseHeaders } = require('../core/util') +const { InvalidArgumentError } = require('../core/errors') + +const kResume = Symbol('resume') + +class UnwrapController { + #paused = false + #reason = null + #aborted = false + #abort + + [kResume] = null + + constructor (abort) { + this.#abort = abort + } + + pause () { + this.#paused = true + } + + resume () { + if (this.#paused) { + this.#paused = false + this[kResume]?.() + } + } + + abort (reason) { + if (!this.#aborted) { + this.#aborted = true + this.#reason = reason + this.#abort(reason) + } + } + + get aborted () { + return this.#aborted + } + + get reason () { + return this.#reason + } + + get paused () { + return this.#paused + } +} + +module.exports = class UnwrapHandler { + #handler + #controller + + constructor (handler) { + this.#handler = handler + } + + static unwrap (handler) { + // TODO (fix): More checks... + return !handler.onRequestStart ? handler : new UnwrapHandler(handler) + } + + onConnect (abort, context) { + this.#controller = new UnwrapController(abort) + this.#handler.onRequestStart?.(this.#controller, context) + } + + onUpgrade (statusCode, rawHeaders, socket) { + this.#handler.onRequestUpgrade?.(this.#controller, statusCode, parseHeaders(rawHeaders), socket) + } + + onHeaders (statusCode, rawHeaders, resume, statusMessage) { + this.#controller[kResume] = resume + this.#handler.onResponseStart?.(this.#controller, statusCode, parseHeaders(rawHeaders), statusMessage) + return !this.#controller.paused + } + + onData (data) { + this.#handler.onResponseData?.(this.#controller, data) + return !this.#controller.paused + } + + onComplete (rawTrailers) { + this.#handler.onResponseEnd?.(this.#controller, parseHeaders(rawTrailers)) + } + + onError (err) { + if (!this.#handler.onResponseError) { + throw new InvalidArgumentError('invalid onError method') + } + + this.#handler.onResponseError?.(this.#controller, err) + } +} diff --git a/vanilla/node_modules/undici/lib/handler/wrap-handler.js b/vanilla/node_modules/undici/lib/handler/wrap-handler.js new file mode 100644 index 0000000..47caa5f --- /dev/null +++ b/vanilla/node_modules/undici/lib/handler/wrap-handler.js @@ -0,0 +1,95 @@ +'use strict' + +const { InvalidArgumentError } = require('../core/errors') + +module.exports = class WrapHandler { + #handler + + constructor (handler) { + this.#handler = handler + } + + static wrap (handler) { + // TODO (fix): More checks... + return handler.onRequestStart ? handler : new WrapHandler(handler) + } + + // Unwrap Interface + + onConnect (abort, context) { + return this.#handler.onConnect?.(abort, context) + } + + onHeaders (statusCode, rawHeaders, resume, statusMessage) { + return this.#handler.onHeaders?.(statusCode, rawHeaders, resume, statusMessage) + } + + onUpgrade (statusCode, rawHeaders, socket) { + return this.#handler.onUpgrade?.(statusCode, rawHeaders, socket) + } + + onData (data) { + return this.#handler.onData?.(data) + } + + onComplete (trailers) { + return this.#handler.onComplete?.(trailers) + } + + onError (err) { + if (!this.#handler.onError) { + throw err + } + + return this.#handler.onError?.(err) + } + + // Wrap Interface + + onRequestStart (controller, context) { + this.#handler.onConnect?.((reason) => controller.abort(reason), context) + } + + onRequestUpgrade (controller, statusCode, headers, socket) { + const rawHeaders = [] + for (const [key, val] of Object.entries(headers)) { + rawHeaders.push(Buffer.from(key), Array.isArray(val) ? val.map(v => Buffer.from(v)) : Buffer.from(val)) + } + + this.#handler.onUpgrade?.(statusCode, rawHeaders, socket) + } + + onResponseStart (controller, statusCode, headers, statusMessage) { + const rawHeaders = [] + for (const [key, val] of Object.entries(headers)) { + rawHeaders.push(Buffer.from(key), Array.isArray(val) ? val.map(v => Buffer.from(v)) : Buffer.from(val)) + } + + if (this.#handler.onHeaders?.(statusCode, rawHeaders, () => controller.resume(), statusMessage) === false) { + controller.pause() + } + } + + onResponseData (controller, data) { + if (this.#handler.onData?.(data) === false) { + controller.pause() + } + } + + onResponseEnd (controller, trailers) { + const rawTrailers = [] + for (const [key, val] of Object.entries(trailers)) { + rawTrailers.push(Buffer.from(key), Array.isArray(val) ? val.map(v => Buffer.from(v)) : Buffer.from(val)) + } + + this.#handler.onComplete?.(rawTrailers) + } + + onResponseError (controller, err) { + if (!this.#handler.onError) { + throw new InvalidArgumentError('invalid onError method') + } + + this.#handler.onError?.(err) + } +} |
