358 lines
12 KiB
TypeScript
358 lines
12 KiB
TypeScript
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 <caption>Bearer token injection with typed per-request override</caption>
|
|
* 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 <caption>Auto-refresh on 401 using a shared factory closure</caption>
|
|
* function createAuthPlugin(getAccessToken: () => Promise<string>) {
|
|
* let current: Promise<string> | 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 <caption>Idempotency-Key for unsafe methods</caption>
|
|
* 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 <caption>Response envelope unwrapping — { data, meta } → data</caption>
|
|
* interface Envelope<T> { readonly data: T; readonly meta?: Record<string, unknown> }
|
|
*
|
|
* const unwrap = definePlugin({
|
|
* name: 'unwrap',
|
|
* hooks: {
|
|
* onResponse: (ctx) => {
|
|
* const body = ctx.response._data as Envelope<unknown> | undefined;
|
|
* if (body !== undefined && typeof body === 'object' && 'data' in body) {
|
|
* ctx.response._data = body.data;
|
|
* }
|
|
* },
|
|
* },
|
|
* });
|
|
*
|
|
* @example <caption>Timing + structured logger using WeakMap-keyed state</caption>
|
|
* function createLoggerPlugin(sink: (record: { url: string; status: number; ms: number }) => void) {
|
|
* const started = new WeakMap<object, number>();
|
|
*
|
|
* 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 <caption>Request ID / correlation header</caption>
|
|
* 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 <caption>Composing multiple plugins — order matters</caption>
|
|
* // 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.0.1
|
|
*/
|
|
export function definePlugin<
|
|
const Name extends string,
|
|
OptionsExt = unknown,
|
|
ContextExt = unknown,
|
|
>(
|
|
plugin: FetchPlugin<Name, OptionsExt, ContextExt>,
|
|
): FetchPlugin<Name, OptionsExt, ContextExt> {
|
|
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<string, string | undefined> | 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<string, string> | Array<[string, string]>);
|
|
headers.forEach((value, key) => {
|
|
target.set(key, value);
|
|
});
|
|
}
|
|
|
|
function pushHook<C>(
|
|
target: Array<FetchHook<C>>,
|
|
source: FetchHook<C> | ReadonlyArray<FetchHook<C>> | 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.0.1
|
|
*/
|
|
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<void> => {
|
|
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.0.1
|
|
*/
|
|
export async function runHookPhase<C>(
|
|
instance: ReadonlyArray<FetchHook<C>> | undefined,
|
|
user: FetchHook<C> | ReadonlyArray<FetchHook<C>> | undefined,
|
|
context: C,
|
|
): Promise<void> {
|
|
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);
|
|
}
|
|
}
|