UNPKG

undici

Version:

An HTTP/1.1 client, written from scratch for Node.js

541 lines (460 loc) • 14.5 kB
'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 * @property {boolean} [pendingStart] onRequestStart deferred until the primary request's context is known */ /** * Handler that forwards response events to multiple waiting handlers. * Used for request deduplication. * * @implements {DispatchHandler} */ class DeduplicationHandler { /** * @type {DispatchHandler} */ #primaryHandler /** * Whether the primary request has started (onRequestStart received). Until * then the request context is unknown and cannot be given to waiting handlers. * @type {boolean} */ #requestStarted = false /** * The primary request context, replayed to waiting handlers so they observe * the same redirect history as the primary. * @type {any} */ #context = null /** * @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 { if (this.#requestStarted) { // The primary request has started, so its context (including the // redirect history) is known and can be delivered immediately. handler.onRequestStart?.(waitingController, this.#context) if (waitingController.aborted) { waitingHandler.done = true return true } if (this.#responseStarted) { handler.onResponseStart?.( waitingController, this.#statusCode, this.#headers, this.#statusMessage ) } } else { // The primary request has not started yet. Defer onRequestStart until // its context is known, so the waiting handler observes the same // redirect history as the primary (a cache handler relies on it to // reject cross-origin redirect responses). waitingHandler.pendingStart = true } } 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 if (!this.#requestStarted) { this.#requestStarted = true this.#context = context // Deliver the now-known context to handlers that joined before the // primary request started. for (const waitingHandler of this.#waitingHandlers) { if (waitingHandler.pendingStart && !waitingHandler.done && !waitingHandler.controller.aborted) { waitingHandler.pendingStart = false try { waitingHandler.handler.onRequestStart?.(waitingHandler.controller, context) } catch { // Ignore errors from waiting handlers waitingHandler.done = true } } } this.#pruneDoneWaitingHandlers() } 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 } this.#completed = true // Remove the completed request before callbacks can redispatch into this // interceptor. Cleanup must not depend on downstream handlers succeeding. this.#cleanup() try { this.#primaryHandler.onResponseEnd?.(controller, trailers) } catch (err) { // Completion callbacks may throw to initiate a retry. The transport's // error callback would be ignored now that this handler is completed. this.#primaryHandler.onResponseError?.(controller, err) } finally { 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 } this.#endWaitingHandler(waitingHandler, trailers) } 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() try { this.#primaryHandler.onResponseError?.(controller, err) } finally { 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, pendingStart: 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 ) { this.#endWaitingHandler(waitingHandler, waitingHandler.pendingTrailers) } 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 {object} trailers */ #endWaitingHandler (waitingHandler, trailers) { waitingHandler.done = true waitingHandler.pendingTrailers = null try { waitingHandler.handler.onResponseEnd?.(waitingHandler.controller, trailers) } catch (err) { // This is a response failure, not a consumer abort. Aborting the waiting // controller here would prevent downstream handlers from retrying. try { waitingHandler.handler.onResponseError?.(waitingHandler.controller, err) } catch { // An error callback must not prevent other waiting handlers completing. } } } /** * @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