fn

definePlugin

v0.0.1

Declares a typed fetch plugin. Identity function — returns its input verbatim at runtime, used only to narrow generics for strong option inference.

Examples

ts
// 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' });
ts
// Auto-refresh on 401 using a shared factory closure
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] },
  });
}
ts
// 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);
    },
  },
});
ts
// Response envelope unwrapping — { data, meta } → data
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;
      }
    },
  },
});
ts
// Timing + structured logger using WeakMap-keyed state
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 });
      },
    },
  });
}
ts
// 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);
    },
  },
});
ts
// 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 } });

Signature

ts
export function definePlugin<
  const Name extends string,
  OptionsExt = unknown,
  ContextExt = unknown,
>(
  plugin: FetchPlugin<Name, OptionsExt, ContextExt>,
): FetchPlugin<Name, OptionsExt, ContextExt>{ ... }

Type Parameters

Nameextends string
OptionsExt= unknown
ContextExt= unknown

Parameters

ParameterTypeDescription
pluginFetchPlugin<Name, OptionsExt, ContextExt>

Returns

FetchPlugin<Name, OptionsExt, ContextExt>
PropertyTypeDescription
nameNamePlugin identifier
defaults?FetchOptionsDefault options contributed by the plugin — merged under user defaults
hooks?FetchHooksLifecycle hooks executed before any user per-request hooks
execute?FetchExecuteMiddlewareOnion-style middleware wrapping the fetch attempt + response parse. Plugins compose in registration order; calling next() invokes the next middleware or ultimately the core executor. May call next() multiple times (e.g. to implement retries).
setup?(context: { readonly defaults: FetchOptions }) => voidInvoked once per createFetch, after all plugin defaults are merged
__types?{ options: OptionsExt; context: ContextExt }Phantom marker for type-only option/context extensions — never present at runtime. Populated via dummy field in definePlugin generics.