feat(fetch): plugin system + eslint/tsconfig migration

- Add fetch plugin API (definePlugin, plugins) with type-level option flow.
- Migrate to eslint flat config and composite tsconfig.
This commit is contained in:
2026-06-07 16:29:18 +07:00
parent 96f4cba4a8
commit a7e668ced8
19 changed files with 1759 additions and 233 deletions
+357
View File
@@ -0,0 +1,357 @@
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.1.0
*/
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.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<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.1.0
*/
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);
}
}