import type { FetchExecuteMiddleware, FetchHook, FetchHooks, FetchOptions, FetchPlugin } from './types'; // --------------------------------------------------------------------------- // definePlugin — identity factory with type-safe inference // --------------------------------------------------------------------------- /** * @name definePlugin * @category Fetch * @description Declares a typed fetch plugin. Identity function — returns its input * verbatim at runtime, used only to narrow generics for strong option inference. * * @typeParam Name - Unique plugin identifier * @typeParam OptionsExt - Extra fields contributed to FetchOptions by this plugin * @typeParam ContextExt - Extra fields advisory for FetchContext * * @example Bearer token injection with typed per-request override * const auth = definePlugin<'auth', { token?: string }>({ * name: 'auth', * hooks: { * onRequest: (ctx) => { * const token = (ctx.options as { token?: string }).token; * if (token !== undefined) ctx.options.headers.set('authorization', `Bearer ${token}`); * }, * }, * }); * * const api = createFetch({ plugins: [auth] }); * await api('/me', { token: 'xyz' }); * * @example Auto-refresh on 401 using a shared factory closure * function createAuthPlugin(getAccessToken: () => Promise) { * let current: Promise | undefined; * const refresh = () => (current ??= getAccessToken().finally(() => { current = undefined; })); * * return definePlugin<'auth', { skipAuth?: boolean }>({ * name: 'auth', * hooks: { * onRequest: async (ctx) => { * if ((ctx.options as { skipAuth?: boolean }).skipAuth) return; * ctx.options.headers.set('authorization', `Bearer ${await refresh()}`); * }, * onResponseError: async (ctx) => { * if (ctx.response.status !== 401) return; * // Invalidate cached token; next attempt via `retry` will pick up a fresh one. * current = undefined; * ctx.options.headers.set('authorization', `Bearer ${await refresh()}`); * }, * }, * defaults: { retry: 1, retryStatusCodes: [401, 408, 429, 500, 502, 503, 504] }, * }); * } * * @example Idempotency-Key for unsafe methods * const idempotency = definePlugin<'idempotency', { idempotencyKey?: string }>({ * name: 'idempotency', * hooks: { * onRequest: (ctx) => { * const method = (ctx.options.method ?? 'GET').toUpperCase(); * if (method === 'GET' || method === 'HEAD') return; * const key = (ctx.options as { idempotencyKey?: string }).idempotencyKey ?? crypto.randomUUID(); * ctx.options.headers.set('idempotency-key', key); * }, * }, * }); * * @example Response envelope unwrapping — { data, meta } → data * interface Envelope { readonly data: T; readonly meta?: Record } * * const unwrap = definePlugin({ * name: 'unwrap', * hooks: { * onResponse: (ctx) => { * const body = ctx.response._data as Envelope | undefined; * if (body !== undefined && typeof body === 'object' && 'data' in body) { * ctx.response._data = body.data; * } * }, * }, * }); * * @example Timing + structured logger using WeakMap-keyed state * function createLoggerPlugin(sink: (record: { url: string; status: number; ms: number }) => void) { * const started = new WeakMap(); * * return definePlugin({ * name: 'logger', * hooks: { * onRequest: (ctx) => { * started.set(ctx, performance.now()); * }, * onResponse: (ctx) => { * const t = started.get(ctx); * if (t === undefined) return; * sink({ url: String(ctx.request), status: ctx.response.status, ms: performance.now() - t }); * }, * onResponseError: (ctx) => { * const t = started.get(ctx); * if (t === undefined) return; * sink({ url: String(ctx.request), status: ctx.response.status, ms: performance.now() - t }); * }, * }, * }); * } * * @example Request ID / correlation header * const requestId = definePlugin<'requestId', { requestId?: string }>({ * name: 'requestId', * hooks: { * onRequest: (ctx) => { * const id = (ctx.options as { requestId?: string }).requestId ?? crypto.randomUUID(); * ctx.options.headers.set('x-request-id', id); * }, * }, * }); * * @example Composing multiple plugins — order matters * // Hooks execute in registration order, then any user per-request hook runs last. * // Here: requestId → auth → logger → user-provided onRequest. * const api = createFetch({ * plugins: [requestId, createAuthPlugin(fetchToken), createLoggerPlugin(console.log), unwrap], * defaults: { baseURL: 'https://api.example.com' }, * }); * * // Per-domain instance inherits every parent plugin and may add its own. * const billing = api.extend({ baseURL: 'https://billing.example.com' }, { * plugins: [idempotency], * }); * await billing('/invoices', { method: 'POST', body: { amount: 100 } }); * * @since 0.1.0 */ export function definePlugin< const Name extends string, OptionsExt = unknown, ContextExt = unknown, >( plugin: FetchPlugin, ): FetchPlugin { return plugin; } // --------------------------------------------------------------------------- // composePlugins — runs once per createFetch // --------------------------------------------------------------------------- /** * @name ComposedPlugins * @category Fetch * @description Flattened hook lists and merged defaults produced by composePlugins. */ export interface ComposedPlugins { /** Merged defaults — plugin defaults first, then user defaults (user wins) */ defaults: FetchOptions; /** Pre-flattened readonly hook arrays; undefined when no plugin contributed a phase */ readonly hooks: { readonly onRequest: readonly FetchHook[] | undefined; readonly onRequestError: readonly FetchHook[] | undefined; readonly onResponse: readonly FetchHook[] | undefined; readonly onResponseError: readonly FetchHook[] | undefined; }; /** * Pre-composed onion chain of plugin `execute` middlewares, or `undefined` * when no plugin contributed one (fast path: caller invokes the core * executor directly without constructing a `next` closure). */ readonly execute: FetchExecuteMiddleware | undefined; } /** Empty hooks shape reused when no plugins are attached — preserves a single hidden class */ const EMPTY_HOOKS: ComposedPlugins['hooks'] = /* @__PURE__ */ Object.freeze({ onRequest: undefined, onRequestError: undefined, onResponse: undefined, onResponseError: undefined, }); type HeadersInput = Headers | Record | Array<[string, string]>; function appendHeaders(target: Headers, source: HeadersInput): void { if (source instanceof Headers) { source.forEach((value, key) => { target.set(key, value); }); return; } const headers = new Headers(source as Record | Array<[string, string]>); headers.forEach((value, key) => { target.set(key, value); }); } function pushHook( target: Array>, source: FetchHook | ReadonlyArray> | undefined, ): void { if (source === undefined) return; if (typeof source === 'function') { target.push(source); return; } for (let i = 0; i < source.length; i++) { target.push(source[i]!); } } function applyDefaults( merged: FetchOptions, mergedHeaders: Headers | undefined, next: FetchOptions, ): { defaults: FetchOptions; headers: Headers | undefined } { const { headers, ...rest } = next; const out = { ...merged, ...rest }; let nextHeaders = mergedHeaders; if (headers !== undefined) { nextHeaders ??= new Headers(); appendHeaders(nextHeaders, headers as HeadersInput); } return { defaults: out, headers: nextHeaders }; } /** * @name composePlugins * @category Fetch * @description Flattens plugin defaults and hook arrays into a single shape suitable * for long-lived storage on a fetch instance. Runs exactly once per createFetch call. * * Ordering: plugin defaults (in declaration order) → user defaults (user wins). * Headers are merged independently through a single Headers instance. * * @since 0.1.0 */ export function composePlugins( plugins: readonly FetchPlugin[] | undefined, userDefaults: FetchOptions | undefined, ): ComposedPlugins { // Fast path — no plugins: avoid allocating hook arrays and header instances if (plugins === undefined || plugins.length === 0) { return { defaults: userDefaults ?? {}, hooks: EMPTY_HOOKS, execute: undefined, }; } let defaults: FetchOptions = {}; let headers: Headers | undefined; const onRequest: FetchHook[] = []; const onRequestError: FetchHook[] = []; const onResponse: FetchHook[] = []; const onResponseError: FetchHook[] = []; const executes: FetchExecuteMiddleware[] = []; for (let i = 0; i < plugins.length; i++) { const plugin = plugins[i]!; if (plugin.defaults !== undefined) { const merged = applyDefaults(defaults, headers, plugin.defaults); defaults = merged.defaults; headers = merged.headers; } if (plugin.hooks !== undefined) { const hooks: FetchHooks = plugin.hooks; pushHook(onRequest, hooks.onRequest as FetchHook | readonly FetchHook[] | undefined); pushHook(onRequestError, hooks.onRequestError as FetchHook | readonly FetchHook[] | undefined); pushHook(onResponse, hooks.onResponse as FetchHook | readonly FetchHook[] | undefined); pushHook(onResponseError, hooks.onResponseError as FetchHook | readonly FetchHook[] | undefined); } if (plugin.execute !== undefined) { executes.push(plugin.execute); } } if (userDefaults !== undefined) { const merged = applyDefaults(defaults, headers, userDefaults); defaults = merged.defaults; headers = merged.headers; } if (headers !== undefined) { defaults = { ...defaults, headers }; } // Invoke setup AFTER defaults are fully merged, so plugins observe the final shape for (let i = 0; i < plugins.length; i++) { plugins[i]!.setup?.({ defaults }); } return { defaults, hooks: { onRequest: onRequest.length > 0 ? onRequest : undefined, onRequestError: onRequestError.length > 0 ? onRequestError : undefined, onResponse: onResponse.length > 0 ? onResponse : undefined, onResponseError: onResponseError.length > 0 ? onResponseError : undefined, }, execute: executes.length === 0 ? undefined : executes.length === 1 ? executes[0] : composeExecute(executes), }; } /** * Classic onion composition — dispatch(i) invokes middleware i or, past the end, * delegates to the supplied `next`. Middlewares MAY call next() multiple times * (retry-style) — the dispatcher is re-entrant. */ function composeExecute(middlewares: readonly FetchExecuteMiddleware[]): FetchExecuteMiddleware { return (context, next) => { const dispatch = (i: number): Promise => { const mw = middlewares[i]; if (mw === undefined) return next(); return mw(context, () => dispatch(i + 1)); }; return dispatch(0); }; } // --------------------------------------------------------------------------- // runHookPhase — dispatches instance hooks then optional per-request hook(s) // --------------------------------------------------------------------------- /** * @name runHookPhase * @category Fetch * @description Runs all instance-level (plugin) hooks for a single phase, then the * optional user per-request hook(s). Avoids allocating an intermediate array per call. * * @since 0.1.0 */ export async function runHookPhase( instance: ReadonlyArray> | undefined, user: FetchHook | ReadonlyArray> | undefined, context: C, ): Promise { if (instance !== undefined) { for (let i = 0; i < instance.length; i++) { await instance[i]!(context); } } if (user === undefined) return; if (typeof user === 'function') { await user(context); return; } for (let i = 0; i < user.length; i++) { await user[i]!(context); } }