import { attempt, wait, assert, generateId, } from '@logosdx/utils'; import type { HttpMethods, _InternalHttpMethods, FetchResponse, RetryConfig, DictAndT, FetchConfig, } from '../types.ts'; import type { EngineRequestConfig, CallConfig } from '../options/types.ts'; import { FetchError } from '../helpers/index.ts'; import type { FetchEngineCore, InternalReqOptions } from './types.ts'; import { FetchPromise } from './fetch-promise.ts'; import type { ResponseDirective } from './fetch-promise.ts'; import { resolveRetryConfig } from '../plugins/retry.ts'; import { HookScope } from '@logosdx/hooks'; /** * Handles request execution with the hook-based pipeline. * * The RequestExecutor builds normalized request options and runs them * through the 3-phase pipeline: * 1. `beforeRequest` (run) - plugins can modify args or short-circuit * 2. `execute` (pipe) - onion-wrapped execution (retry, dedupe, etc.) * 3. `afterRequest` (run) - plugins can modify or cache the response * * @template S - Instance state type * @template H - Headers type * @template P - Params type */ export class RequestExecutor< H = unknown, P = unknown, S = unknown > { /** Reference to the FetchEngine instance */ engine: FetchEngineCore; constructor(engine: FetchEngineCore) { this.engine = engine; } /** * Resolve the retry config actually used for a request — the engine's * base config merged with the request's per-call override, through the * same resolution the retry plugin applies. Used for `res.config.retry` * so the reported value can never drift from the retry behavior the * request actually ran with (a per-call override otherwise wouldn't * show up here, since it never touches the engine-level config). */ #resolveRetryConfig(perCallRetry: RetryConfig | undefined): Required { const baseConfig = this.engine.config.get('retry'); return resolveRetryConfig( baseConfig === true ? undefined : baseConfig, perCallRetry ); } /** * Get base URL from engine options. */ get baseUrl(): string { return this.engine.config.get('baseUrl'); } /** * Get default type from engine options. */ get defaultType(): 'json' | 'text' | 'blob' | 'arrayBuffer' { return (this.engine.config.get('defaultType') as string || 'json') as 'json' | 'text' | 'blob' | 'arrayBuffer'; } // ===================================================================== // PUBLIC API - Entry points for FetchEngine HTTP methods // ===================================================================== /** * Execute a request with the full lifecycle: timeout, options building, pipeline. * * This is the main entry point called by FetchEngine HTTP methods. */ execute( method: HttpMethods, path: string, payloadOrOptions?: Data | CallConfig, options?: CallConfig ): FetchPromise, DictAndT

, ResHdr> { let payload: Data | undefined; let opts: CallConfig; if (options !== undefined) { payload = payloadOrOptions as Data; opts = options; } else if (payloadOrOptions && typeof payloadOrOptions === 'object' && !Array.isArray(payloadOrOptions)) { const hasCallOptionKeys = 'headers' in payloadOrOptions || 'params' in payloadOrOptions || 'timeout' in payloadOrOptions || 'retry' in payloadOrOptions || 'abortController' in payloadOrOptions || 'onError' in payloadOrOptions || 'onBeforeReq' in payloadOrOptions || 'onAfterReq' in payloadOrOptions; if (hasCallOptionKeys && !/POST|PUT|PATCH|DELETE/i.test(method)) { opts = payloadOrOptions as CallConfig; } else { payload = payloadOrOptions as Data; opts = {}; } } else { payload = payloadOrOptions as Data; opts = {}; } const controller = opts.abortController ?? new AbortController(); const totalTimeoutMs = opts.totalTimeout ?? opts.timeout ?? this.engine.config.get('totalTimeout'); const attemptTimeoutMs = opts.attemptTimeout ?? this.engine.config.get('attemptTimeout'); if (typeof totalTimeoutMs === 'number') { assert(totalTimeoutMs >= 0, 'totalTimeout must be non-negative number'); } if (typeof attemptTimeoutMs === 'number') { assert(attemptTimeoutMs >= 0, 'attemptTimeout must be non-negative number'); } let totalTimeoutFired = false; const totalTimeout = typeof totalTimeoutMs === 'number' ? wait(totalTimeoutMs) : undefined; totalTimeout?.then(() => { totalTimeoutFired = true; controller.abort(); }); const fetchPromise: FetchPromise, DictAndT

, ResHdr> = FetchPromise.create, DictAndT

, ResHdr>( (): Promise, DictAndT

, ResHdr>> => this.#executeWithOptions( method, path, payload, opts, controller, totalTimeout, attemptTimeoutMs, () => totalTimeoutFired, () => fetchPromise.directive ), controller ); return fetchPromise; } /** * Internal method that builds options and executes the request. */ async #executeWithOptions( method: HttpMethods, path: string, payload: unknown, options: CallConfig, controller: AbortController, totalTimeout: ReturnType | undefined, attemptTimeoutMs: number | undefined, getTotalTimeoutFired: () => boolean, getDirective?: () => ResponseDirective | undefined ): Promise, DictAndT

, ResHdr>> { const onAfterReq = (...args: any[]) => { totalTimeout?.clear(); options.onAfterReq?.apply(this, args as never); }; const onError = (...args: any[]) => { totalTimeout?.clear(); options.onError?.apply(this, args as never); }; const normalizedOpts = this.makeRequestOptions( method, path, { ...options, payload, controller, onAfterReq, onError, attemptTimeout: attemptTimeoutMs, getTotalTimeoutFired } ); normalizedOpts.getDirective = getDirective; return this.executeRequest(normalizedOpts, totalTimeout); } /** * Build normalized request options from method/path/options. */ makeRequestOptions( _method: HttpMethods, path: string, options: CallConfig & { payload?: unknown; controller: AbortController; attemptTimeout?: number | undefined; getTotalTimeoutFired?: (() => boolean) | undefined; } ): InternalReqOptions { const { payload, controller, onAfterReq: onAfterRequest, onBeforeReq: onBeforeRequest, onError, timeout = this.engine.config.get('totalTimeout') as number | undefined, attemptTimeout, getTotalTimeoutFired, params: requestParams, signal, determineType, retry, requestId: perRequestId, headers: requestHeaders, ...perRequestInit } = options; const method = _method.toUpperCase() as _InternalHttpMethods; const state = this.engine.state.get(); const url = this.#makeUrl(path, requestParams, method); let headers = this.engine.headerStore.resolve(method, requestHeaders) as DictAndT; let body: BodyInit | undefined; const type = this.defaultType; if (/put|post|patch|delete/i.test(method)) { const isValidBodyInit = ( payload === null || payload === undefined || typeof payload === 'string' || payload instanceof Blob || payload instanceof ArrayBuffer || payload instanceof FormData || payload instanceof URLSearchParams || payload instanceof ReadableStream || ArrayBuffer.isView(payload) ); if (type === 'json' && !isValidBodyInit) { body = JSON.stringify(payload); } else if (payload !== null && payload !== undefined) { body = payload as BodyInit; } } const config = this.engine.config.get(); let opts: EngineRequestConfig = { ...config, ...perRequestInit, method, signal: signal || controller.signal, controller, headers, body: body ?? null, totalTimeout: timeout, retry, }; const validate = this.engine.config.get('validate'); if (validate?.perRequest?.headers && validate.headers) { validate.headers(headers, method); } const normalizedRetry = opts.retry === true ? {} : (opts.retry === false ? { maxAttempts: 0 } : opts.retry); const generateRequestId = this.engine.config.get('generateRequestId'); const requestId = perRequestId || (generateRequestId ? generateRequestId() : generateId()); const requestIdHeader = this.engine.config.get('requestIdHeader'); if (requestIdHeader) { headers = { ...headers, [requestIdHeader]: requestId } as DictAndT; } return { ...opts, requestId, method, path, payload, headers, params: Object.fromEntries(url.searchParams.entries()) as DictAndT

, state, url, signal: opts.signal || controller.signal, controller, body, timeout: opts.totalTimeout, attemptTimeout, getTotalTimeoutFired, retry: normalizedRetry, determineType: determineType as InternalReqOptions['determineType'], onBeforeRequest: onBeforeRequest as InternalReqOptions['onBeforeRequest'], onAfterRequest: onAfterRequest as InternalReqOptions['onAfterRequest'], onError: onError as InternalReqOptions['onError'], }; } /** * Build URL from path and params. */ #makeUrl(path: string, requestParams?: DictAndT

, method?: HttpMethods): URL { const params = this.engine.paramStore.resolve( method || 'GET', requestParams ) as DictAndT

; if (path.startsWith('http')) { const url = new URL(path); Object.entries(params).forEach(([key, value]) => { url.searchParams.set(key, value as string); }); return url; } path = path?.replace(/^\/{1,}/, ''); if (path[0] !== '/') { path = `/${path}`; } const baseUrl = this.baseUrl.replace(/\/$/, ''); const url = new URL(baseUrl + path); for (const [key, value] of Object.entries(params)) { url.searchParams.set(key, value as string); } const validate = this.engine.config.get('validate'); if (validate?.perRequest?.params && validate.params) { validate.params( Object.fromEntries(url.searchParams.entries()) as DictAndT

, method as _InternalHttpMethods | undefined ); } return url; } // ===================================================================== // INTERNAL METHODS // ===================================================================== /** * Determine response type based on content-type header. */ determineType(response: Response): { type: 'json' | 'text' | 'blob' | 'arrayBuffer'; isJson: boolean; isRecognized: boolean } { const contentType = response.headers.get('content-type') || ''; if (contentType.includes('application/json')) { return { type: 'json', isJson: true, isRecognized: true }; } if (contentType.includes('text/')) { return { type: 'text', isJson: false, isRecognized: true }; } return { type: this.defaultType, isJson: false, isRecognized: false }; } /** * Handle errors with proper event emission and error formatting. */ #handleError( normalizedOpts: InternalReqOptions, errorOpts: { error: FetchError | Error, step: 'fetch' | 'parse', status?: number, data?: unknown } ) { const { method, path, headers, controller, onError, attempt: attemptNum } = normalizedOpts; const { error, step, status, data } = errorOpts; const aborted = controller.signal.aborted; let err = error as FetchError>; if (step === 'fetch') { err = new FetchError(err.message) as FetchError>; err.status = 499; err.message = err.message || 'Fetch error'; } if (step === 'parse') { err = new FetchError(err.message) as FetchError>; err.status = status || 999; err.message = err.message || 'Parse error'; } err.requestId = normalizedOpts.requestId; err.attempt = attemptNum; err.status = err.status || status!; err.method = err.method || method!; err.path = err.path || path!; err.aborted = err.aborted || aborted; err.step = err.step || step; err.headers = err.headers || headers; const eventData = { ...normalizedOpts, error: err, step, status, aborted, data, requestEnd: Date.now() }; if (aborted) { this.engine.emit('abort', eventData); } else { this.engine.emit('error', eventData); } onError && onError(err); throw err; } /** * Extracts response headers into a plain object. * * Set-Cookie is treated specially so multi-value headers survive: the * default `Headers#forEach` path overwrites on each iteration for * duplicate keys, and `Headers#get` joins duplicates with ", " which is * ambiguous (Expires dates contain commas). */ #extractResponseHeaders(response: Response): Partial { const responseHeaders: Record = {}; const setCookies: string[] = []; response.headers.forEach((value, key) => { if (key.toLowerCase() === 'set-cookie') { setCookies.push(value); return; } responseHeaders[key] = value; }); // Prefer the native getter when available — on runtimes that support it // (Node 18.14.1+, Chrome 113+, Firefox 112+, Safari 17+), it is the only // lossless way to read multiple Set-Cookie values because `headers.get` // joins them with ", " which is ambiguous (Expires dates contain commas). // // Runtime detection via Reflect.get avoids a type assertion: if the DOM // typings on the target lib do not yet declare `getSetCookie`, the // assertion would lie; instead we ask at runtime and narrow by typeof. const nativeGetter = Reflect.get(response.headers, 'getSetCookie'); const cookies = typeof nativeGetter === 'function' ? nativeGetter.call(response.headers) : setCookies; if (Array.isArray(cookies) && cookies.length > 0) { responseHeaders['set-cookie'] = cookies.filter( (v): v is string => typeof v === 'string' ); } // Boundary cast: HTTP responses are an untyped external source and // `ResHdr` is a user-provided generic. This is the single sanctioned // assertion in this function. return responseHeaders as unknown as Partial; } /** * Builds the `ok`-discriminated {@link FetchResponse} for a completed * exchange. `ok` is a literal boolean branch (not the `boolean` from * `Response#ok`) so the two return statements — not a ternary — are what * let TypeScript narrow `data` correctly on each side of the union. */ #buildResponse( ok: boolean, data: unknown, headers: Partial, status: number, request: Request, config: FetchConfig, DictAndT

> ): FetchResponse, DictAndT

, ResHdr> { if (ok) { return { ok: true, data: data as Res, headers, status, request, config }; } return { ok: false, data, headers, status, request, config }; } /** * Emits `response` for every completed exchange, plus `response-4xx` / * `response-5xx` for their status ranges. Called once per attempt. */ #emitResponseEvents( normalizedOpts: InternalReqOptions, response: Response, data: unknown ): void { const eventData = { ...normalizedOpts, response, data, status: response.status, requestEnd: Date.now() }; this.engine.emit('response', eventData); if (response.status >= 400 && response.status < 500) { this.engine.emit('response-4xx', eventData); } else if (response.status >= 500 && response.status < 600) { this.engine.emit('response-5xx', eventData); } } /** * Makes an API call using fetch and returns enhanced response object. */ async makeCall( options: InternalReqOptions ): Promise, DictAndT

, ResHdr>> { const { method, headers: reqHeaders, params, url, signal, controller, body, timeout, retry, determineType, onBeforeRequest, onAfterRequest, ...requestInit } = options; this.engine.emit('before-request', options); const callbackOpts = { method, signal, controller, headers: reqHeaders, body: body ?? null, timeout, retry, determineType }; onBeforeRequest && await onBeforeRequest(callbackOpts); const fetchOpts: RequestInit = { ...requestInit, method, signal, headers: reqHeaders as HeadersInit, body: body ?? null, }; const [response, resErr] = await attempt(async () => { return await fetch(url, fetchOpts) as Response; }); if (resErr) { this.#handleError(options, { error: resErr, step: 'fetch' }); throw resErr; } this.engine.emit('after-request', { ...options, response: ( this.engine.$has('after-request') ? response.clone() : response ), }); onAfterRequest && await onAfterRequest(response.clone(), callbackOpts); const directive = options.getDirective?.(); if (directive === 'stream' || directive === 'raw') { const responseHeaders = this.#extractResponseHeaders(response); this.#emitResponseEvents(options, response, response); const config: FetchConfig, DictAndT

> = { baseUrl: this.baseUrl.toString(), attemptTimeout: options.attemptTimeout, method, headers: reqHeaders, params, retry: this.#resolveRetryConfig(retry), determineType, }; return this.#buildResponse( response.ok, response, responseHeaders, response.status, new Request(url, fetchOpts), config ); } const responseHeaders = this.#extractResponseHeaders(response); // The body can only be cloned before it is read. Status is known // synchronously (no need to wait on the parse attempt), so clone // up front — but only for a non-2xx response, where a parse // failure falls back to raw text instead of raising a FetchError. const fallbackClone = response.ok ? undefined : response.clone(); const request = new Request(url, fetchOpts); const config: FetchConfig, DictAndT

> = { baseUrl: this.baseUrl.toString(), attemptTimeout: options.attemptTimeout, method, headers: reqHeaders, params, retry: this.#resolveRetryConfig(retry), determineType, }; const [data, parseErr] = directive && directive !== 'json' ? await attempt(async () => { if (response.status === 204) { return null; } return await response[directive]() as Res; }) : await attempt(async () => { const typeResult = determineType ? determineType(response) : this.determineType(response); const { type, isJson } = typeResult; const isRecognized = 'isRecognized' in typeResult ? (typeResult as any).isRecognized : true; if (response.status === 204) { return null; } if (isJson) { const text = await response.text(); if (text) { return JSON.parse(text) as Res; } return null; } else if (isRecognized) { return await response[type]() as Res; } else { const text = await response.text(); if (text) { if (type === 'json') { return JSON.parse(text) as Res; } return text as Res; } throw new Error(`Unknown content-type: ${response.headers.get('content-type')}`); } }); if (parseErr) { // The status is never masked by a body-format failure: a non-2xx // response still resolves, falling back to the raw text body. if (!response.ok) { const [fallbackText] = await attempt(() => fallbackClone!.text()); const fallbackData = fallbackText ?? null; this.#emitResponseEvents(options, response, fallbackData); return this.#buildResponse( false, fallbackData, responseHeaders, response.status, request, config ); } this.#handleError(options, { error: parseErr, step: 'parse', status: response.status, data }); throw parseErr; } this.#emitResponseEvents(options, response, data); return this.#buildResponse( response.ok, data, responseHeaders, response.status, request, config ); } /** * Executes a request through the 3-phase hook pipeline. * * 1. beforeRequest (run) - plugins can modify args or short-circuit with cached response * 2. execute (pipe) - onion-wrapped execution (retry wraps dedupe wraps makeCall) * 3. afterRequest (run) - plugins can modify response or store in cache */ async executeRequest( normalizedOpts: InternalReqOptions, totalTimeout: ReturnType | undefined ): Promise, DictAndT

, ResHdr>> { normalizedOpts.requestStart = Date.now(); const scope = new HookScope(); // Phase 1: beforeRequest (run) const pre = await this.engine.hooks.run( 'beforeRequest', normalizedOpts.url, normalizedOpts as any, { scope } ); if (pre.returned) { totalTimeout?.clear(); return pre.result as FetchResponse, DictAndT

, ResHdr>; } // Use potentially-modified opts from hooks const finalOpts = (pre.args[1] ?? normalizedOpts) as InternalReqOptions; // Phase 2: execute (pipe) const response = await this.engine.hooks.pipe<'execute', FetchResponse, DictAndT

, ResHdr>>( 'execute', () => this.makeCall(finalOpts), finalOpts, { scope } ); totalTimeout?.clear(); // Phase 3: afterRequest (run) const post = await this.engine.hooks.run( 'afterRequest', response as any, finalOpts.url, finalOpts as any, { scope } ); if (post.returned) { return post.result as FetchResponse, DictAndT

, ResHdr>; } return response; } }