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:
@@ -212,6 +212,21 @@ describe('JSON body serialisation', () => {
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.body).toBe('key=value');
|
||||
});
|
||||
|
||||
it('passes a raw string body through without forcing a JSON content-type', async () => {
|
||||
const fetchMock = makeFetchMock({ ok: true });
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
await $fetch('https://api.example.com/raw', {
|
||||
method: 'POST',
|
||||
body: 'plain text payload',
|
||||
});
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.body).toBe('plain text payload');
|
||||
expect((init.headers as Headers).get('content-type')).toBeNull();
|
||||
expect((init.headers as Headers).get('accept')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -328,6 +343,50 @@ describe('retry', () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(data).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('does not retry a user-initiated abort', async () => {
|
||||
const controller = new AbortController();
|
||||
const fetchMock = vi.fn().mockImplementation((_url: string, init: RequestInit) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
const signal = init.signal as AbortSignal;
|
||||
signal.addEventListener('abort', () => reject(signal.reason));
|
||||
}),
|
||||
);
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
const promise = $fetch('https://api.example.com/cancel', {
|
||||
signal: controller.signal,
|
||||
retry: 3,
|
||||
});
|
||||
controller.abort();
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(FetchError);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clears a stale error on a successful retry before onResponse runs', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new TypeError('network down'))
|
||||
.mockResolvedValueOnce(
|
||||
new Response('{"ok":true}', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
let errorInResponseHook: unknown = 'unset';
|
||||
const data = await $fetch('https://api.example.com/flaky', {
|
||||
onResponse: (ctx) => {
|
||||
errorInResponseHook = ctx.error;
|
||||
},
|
||||
});
|
||||
|
||||
expect(data).toEqual({ ok: true });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(errorInResponseHook).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -524,4 +583,37 @@ describe('timeout', () => {
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(FetchError);
|
||||
});
|
||||
|
||||
it('uses a fresh, un-aborted timeout signal on each retry attempt', async () => {
|
||||
let attempt = 0;
|
||||
const fetchMock = vi.fn().mockImplementation((_url: string, init: RequestInit) => {
|
||||
attempt += 1;
|
||||
const signal = init.signal as AbortSignal;
|
||||
|
||||
// First attempt hangs until its own timeout fires.
|
||||
if (attempt === 1) {
|
||||
return new Promise((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(signal.reason));
|
||||
});
|
||||
}
|
||||
|
||||
// Retry must receive a brand-new signal, not the already-aborted one.
|
||||
expect(signal.aborted).toBe(false);
|
||||
return Promise.resolve(
|
||||
new Response('{"ok":true}', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
const promise = $fetch('https://api.example.com/slow', { timeout: 100 });
|
||||
|
||||
// Fire attempt-1 timeout and let the retry proceed to attempt 2.
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
await expect(promise).resolves.toEqual({ ok: true });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
+216
-202
@@ -1,32 +1,127 @@
|
||||
import type { $Fetch, CreateFetchOptions, FetchContext, FetchOptions, FetchRequest, FetchResponse, MappedResponseType, ResponseType } from './types';
|
||||
import type { $Fetch, CreateFetchOptions, Fetch, FetchContext, FetchHook, FetchOptions, FetchPlugin, FetchRequest, FetchResponse, MappedResponseType, MergePluginOptions, ResolvedFetchOptions, ResponseType } from './types';
|
||||
import { isString } from '@robonen/stdlib';
|
||||
import { FetchError, createFetchError } from './error';
|
||||
import { composePlugins, runHookPhase } from './plugin';
|
||||
import { retryPlugin, timeoutPlugin } from './plugins';
|
||||
import {
|
||||
NULL_BODY_STATUSES,
|
||||
buildURL,
|
||||
callHooks,
|
||||
detectResponseType,
|
||||
isJSONSerializable,
|
||||
isPayloadMethod,
|
||||
joinURL,
|
||||
resolveFetchOptions,
|
||||
} from './utils';
|
||||
import { isFunction, isNumber, isString, retry } from '@robonen/stdlib';
|
||||
|
||||
function assignResponseData(response: { _data?: unknown }, data: unknown): void {
|
||||
response._data = data;
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module-scope constants and helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Built-in plugins prepended to every `createFetch` call.
|
||||
* Frozen tuple: stable array identity + V8 can treat entries as constants.
|
||||
*/
|
||||
const BUILTIN_PLUGINS: readonly FetchPlugin[] = /* @__PURE__ */ Object.freeze([
|
||||
retryPlugin(),
|
||||
timeoutPlugin(),
|
||||
]) as readonly FetchPlugin[];
|
||||
|
||||
/** Default HTTP method — hoisted once so string literal is interned and reused */
|
||||
const DEFAULT_METHOD = 'GET';
|
||||
|
||||
/**
|
||||
* V8/Node expose `Error.captureStackTrace`, browsers do not. Resolve once at
|
||||
* module load so the error path doesn't pay a dynamic lookup + typeof check.
|
||||
*/
|
||||
const captureStackTrace: typeof Error.captureStackTrace | undefined
|
||||
= typeof Error.captureStackTrace === 'function' ? Error.captureStackTrace : undefined;
|
||||
|
||||
/**
|
||||
* Parse a successful fetch Response body into `response._data` according to
|
||||
* the caller's `parseResponse` / `responseType` options (or detected from
|
||||
* Content-Type). Hoisted to module scope so `$fetchRaw` / `runAttempt`
|
||||
* stay small and inlineable.
|
||||
*/
|
||||
async function parseResponseBody(
|
||||
response: FetchResponse<unknown>,
|
||||
options: ResolvedFetchOptions<ResponseType, unknown>,
|
||||
method: string,
|
||||
): Promise<void> {
|
||||
if (
|
||||
response.body === null
|
||||
|| method === 'HEAD'
|
||||
|| NULL_BODY_STATUSES.has(response.status)
|
||||
) return;
|
||||
|
||||
const parseResponse = options.parseResponse;
|
||||
const responseType = parseResponse !== undefined
|
||||
? 'json'
|
||||
: (options.responseType ?? detectResponseType(response.headers.get('content-type') ?? ''));
|
||||
|
||||
switch (responseType) {
|
||||
case 'json': {
|
||||
const text = await response.text();
|
||||
if (text.length > 0) {
|
||||
response._data = parseResponse !== undefined ? parseResponse(text) : JSON.parse(text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'stream': {
|
||||
response._data = response.body;
|
||||
return;
|
||||
}
|
||||
default: {
|
||||
response._data = await response[responseType]();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** HTTP status codes that trigger automatic retry by default */
|
||||
const DEFAULT_RETRY_STATUS_CODES: ReadonlySet<number> = /* @__PURE__ */ new Set([
|
||||
408, // Request Timeout
|
||||
409, // Conflict
|
||||
425, // Too Early (Experimental)
|
||||
429, // Too Many Requests
|
||||
500, // Internal Server Error
|
||||
502, // Bad Gateway
|
||||
503, // Service Unavailable
|
||||
504, // Gateway Timeout
|
||||
]);
|
||||
/**
|
||||
* Serialize the request body for payload-bearing methods and set the
|
||||
* appropriate content-type / accept / duplex hints. Mutates `options` in place.
|
||||
*/
|
||||
function serializeRequestBody(options: ResolvedFetchOptions<ResponseType, unknown>, method: string): void {
|
||||
const body = options.body;
|
||||
if (body === undefined || body === null || !isPayloadMethod(method)) return;
|
||||
|
||||
if (isJSONSerializable(body)) {
|
||||
// A raw string body is passed through untouched — the caller owns its
|
||||
// content-type (it may be plain text, NDJSON, GraphQL, etc.).
|
||||
if (isString(body)) return;
|
||||
|
||||
const headers = options.headers;
|
||||
const contentType = headers.get('content-type');
|
||||
|
||||
options.body = contentType === 'application/x-www-form-urlencoded'
|
||||
? new URLSearchParams(body as Record<string, string>).toString()
|
||||
: JSON.stringify(body);
|
||||
|
||||
if (contentType === null) {
|
||||
headers.set('content-type', 'application/json');
|
||||
}
|
||||
if (!headers.has('accept')) {
|
||||
headers.set('accept', 'application/json');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Web Streams body — mark duplex if caller didn't set it explicitly.
|
||||
// `options.duplex === undefined` avoids the `in` operator slow path on
|
||||
// dictionary-mode objects and keeps the IC monomorphic on a single key load.
|
||||
if (typeof (body as ReadableStream).pipeTo === 'function' && options.duplex === undefined) {
|
||||
options.duplex = 'half';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcut for method-specialized helpers (get/post/...). Avoids a spread
|
||||
* allocation when `options` is undefined, which is the common case.
|
||||
*/
|
||||
function withMethod<O>(options: O | undefined, method: string): O & { method: string } {
|
||||
return options === undefined
|
||||
? ({ method } as O & { method: string })
|
||||
: ({ ...options, method } as O & { method: string });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createFetch
|
||||
@@ -37,94 +132,85 @@ const DEFAULT_RETRY_STATUS_CODES: ReadonlySet<number> = /* @__PURE__ */ new Set(
|
||||
* @category Fetch
|
||||
* @description Creates a configured $fetch instance
|
||||
*
|
||||
* @param {CreateFetchOptions} [globalOptions={}] - Global defaults and custom fetch implementation
|
||||
* @param {CreateFetchOptions} [globalOptions={}] - Global defaults, custom fetch implementation, and plugins
|
||||
* @returns {$Fetch} Configured fetch instance
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch {
|
||||
export function createFetch<Plugins extends readonly FetchPlugin[] = []>(
|
||||
globalOptions: CreateFetchOptions<Plugins> = {},
|
||||
): $Fetch<Plugins> {
|
||||
const fetchImpl = globalOptions.fetch ?? globalThis.fetch;
|
||||
|
||||
// Built-ins compose first, user plugins layer on top. composePlugins runs once.
|
||||
const userPlugins = (globalOptions.plugins ?? []) as readonly FetchPlugin[];
|
||||
const allPlugins = userPlugins.length === 0
|
||||
? BUILTIN_PLUGINS
|
||||
: [...BUILTIN_PLUGINS, ...userPlugins];
|
||||
|
||||
const composed = composePlugins(
|
||||
allPlugins,
|
||||
globalOptions.defaults as FetchOptions | undefined,
|
||||
);
|
||||
const composedHooks = composed.hooks;
|
||||
const composedExecute = composed.execute;
|
||||
const composedDefaults = composed.defaults;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// executeFetch — performs a single fetch attempt (no retry logic)
|
||||
// runAttempt — a single fetch attempt (fetch + body parse + response hooks)
|
||||
// Closure over fetchImpl + composedHooks; stable shape per instance.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async function executeFetch<T = unknown, R extends ResponseType = 'json'>(context: FetchContext<T, R>): Promise<FetchResponse<T>> {
|
||||
// Actual fetch call
|
||||
async function runAttempt<T, R extends ResponseType>(context: FetchContext<T, R>): Promise<void> {
|
||||
const options = context.options;
|
||||
|
||||
// Reset per-attempt outcome so a successful retry never carries a stale
|
||||
// error/response from a previous attempt into the response hooks.
|
||||
context.error = undefined;
|
||||
context.response = undefined;
|
||||
|
||||
try {
|
||||
context.response = await fetchImpl(context.request, context.options as RequestInit);
|
||||
context.response = await fetchImpl(context.request, options as RequestInit);
|
||||
}
|
||||
catch (err) {
|
||||
context.error = err as Error;
|
||||
|
||||
if (context.options.onRequestError !== undefined) {
|
||||
await callHooks(
|
||||
if (composedHooks.onRequestError !== undefined || options.onRequestError !== undefined) {
|
||||
await runHookPhase(
|
||||
composedHooks.onRequestError as ReadonlyArray<FetchHook<FetchContext<T, R> & { error: Error }>> | undefined,
|
||||
options.onRequestError,
|
||||
context as FetchContext<T, R> & { error: Error },
|
||||
context.options.onRequestError,
|
||||
);
|
||||
}
|
||||
|
||||
throw createFetchError(context);
|
||||
}
|
||||
|
||||
// Response body parsing
|
||||
const method = context.options.method ?? 'GET';
|
||||
const hasBody
|
||||
= context.response.body !== null
|
||||
&& !NULL_BODY_STATUSES.has(context.response.status)
|
||||
&& method !== 'HEAD';
|
||||
const response = context.response;
|
||||
const method = options.method ?? DEFAULT_METHOD;
|
||||
|
||||
if (hasBody) {
|
||||
const responseType
|
||||
= context.options.parseResponse !== undefined
|
||||
? 'json'
|
||||
: (context.options.responseType
|
||||
?? detectResponseType(context.response.headers.get('content-type') ?? ''));
|
||||
await parseResponseBody(response as FetchResponse<unknown>, options as unknown as ResolvedFetchOptions<ResponseType, unknown>, method);
|
||||
|
||||
switch (responseType) {
|
||||
case 'json': {
|
||||
const text = await context.response.text();
|
||||
if (text) {
|
||||
context.response._data
|
||||
= context.options.parseResponse !== undefined
|
||||
? context.options.parseResponse(text)
|
||||
: JSON.parse(text);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'stream': {
|
||||
assignResponseData(context.response, context.response.body);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assignResponseData(context.response, await context.response[responseType]());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (context.options.onResponse !== undefined) {
|
||||
await callHooks(
|
||||
if (composedHooks.onResponse !== undefined || options.onResponse !== undefined) {
|
||||
await runHookPhase(
|
||||
composedHooks.onResponse as ReadonlyArray<FetchHook<FetchContext<T, R> & { response: FetchResponse<T> }>> | undefined,
|
||||
options.onResponse,
|
||||
context as FetchContext<T, R> & { response: FetchResponse<T> },
|
||||
context.options.onResponse,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!context.options.ignoreResponseError
|
||||
&& context.response.status >= 400
|
||||
&& context.response.status < 600
|
||||
) {
|
||||
if (context.options.onResponseError !== undefined) {
|
||||
await callHooks(
|
||||
const status = response.status;
|
||||
if (!options.ignoreResponseError && status >= 400 && status < 600) {
|
||||
if (composedHooks.onResponseError !== undefined || options.onResponseError !== undefined) {
|
||||
await runHookPhase(
|
||||
composedHooks.onResponseError as ReadonlyArray<FetchHook<FetchContext<T, R> & { response: FetchResponse<T> }>> | undefined,
|
||||
options.onResponseError,
|
||||
context as FetchContext<T, R> & { response: FetchResponse<T> },
|
||||
context.options.onResponseError,
|
||||
);
|
||||
}
|
||||
|
||||
throw createFetchError(context);
|
||||
}
|
||||
|
||||
return context.response;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -138,149 +224,69 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch {
|
||||
_request: FetchRequest,
|
||||
_options?: FetchOptions<R, T>,
|
||||
): Promise<FetchResponse<MappedResponseType<R, T>>> {
|
||||
// Fixed key order → single hidden class for FetchContext across all requests
|
||||
const context: FetchContext<T, R> = {
|
||||
request: _request,
|
||||
options: resolveFetchOptions(
|
||||
_request,
|
||||
_options,
|
||||
globalOptions.defaults,
|
||||
),
|
||||
options: resolveFetchOptions(_request, _options, composedDefaults),
|
||||
response: undefined,
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
// Normalise method to uppercase before any hook or header logic
|
||||
if (context.options.method !== undefined) {
|
||||
context.options.method = context.options.method.toUpperCase();
|
||||
const options = context.options;
|
||||
|
||||
// Normalise method to uppercase before any hook or header logic, then
|
||||
// cache the resolved method string for reuse below (avoids repeating
|
||||
// `options.method ?? DEFAULT_METHOD` at every downstream call site).
|
||||
let method: string;
|
||||
if (options.method !== undefined) {
|
||||
method = options.method.toUpperCase();
|
||||
options.method = method;
|
||||
}
|
||||
else {
|
||||
method = DEFAULT_METHOD;
|
||||
}
|
||||
|
||||
if (context.options.onRequest !== undefined) {
|
||||
await callHooks(context, context.options.onRequest);
|
||||
if (composedHooks.onRequest !== undefined || options.onRequest !== undefined) {
|
||||
await runHookPhase(
|
||||
composedHooks.onRequest as ReadonlyArray<FetchHook<FetchContext<T, R>>> | undefined,
|
||||
options.onRequest,
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
// URL transformations — only when request is a plain string
|
||||
if (isString(context.request)) {
|
||||
if (context.options.baseURL !== undefined) {
|
||||
context.request = joinURL(context.options.baseURL, context.request);
|
||||
if (options.baseURL !== undefined) {
|
||||
context.request = joinURL(options.baseURL, context.request);
|
||||
}
|
||||
|
||||
const query = context.options.query ?? context.options.params;
|
||||
const query = options.query ?? options.params;
|
||||
if (query !== undefined) {
|
||||
context.request = buildURL(context.request, query);
|
||||
}
|
||||
}
|
||||
|
||||
// Body serialisation
|
||||
const method = context.options.method ?? 'GET';
|
||||
if (context.options.body !== undefined && context.options.body !== null && isPayloadMethod(method)) {
|
||||
if (isJSONSerializable(context.options.body)) {
|
||||
const contentType = context.options.headers.get('content-type');
|
||||
|
||||
if (!isString(context.options.body)) {
|
||||
context.options.body
|
||||
= contentType === 'application/x-www-form-urlencoded'
|
||||
? new URLSearchParams(
|
||||
context.options.body as Record<string, string>,
|
||||
).toString()
|
||||
: JSON.stringify(context.options.body);
|
||||
}
|
||||
|
||||
if (contentType === null) {
|
||||
context.options.headers.set('content-type', 'application/json');
|
||||
}
|
||||
if (!context.options.headers.has('accept')) {
|
||||
context.options.headers.set('accept', 'application/json');
|
||||
}
|
||||
}
|
||||
else if (
|
||||
// Web Streams API body
|
||||
typeof (context.options.body as ReadableStream | null)?.pipeTo === 'function'
|
||||
) {
|
||||
if (!('duplex' in context.options)) {
|
||||
context.options.duplex = 'half';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout via AbortSignal — compose with any caller-supplied signal
|
||||
if (context.options.timeout !== undefined) {
|
||||
const timeoutSignal = AbortSignal.timeout(context.options.timeout);
|
||||
context.options.signal
|
||||
= context.options.signal !== undefined
|
||||
? AbortSignal.any([timeoutSignal, context.options.signal as AbortSignal])
|
||||
: timeoutSignal;
|
||||
}
|
||||
serializeRequestBody(options as unknown as ResolvedFetchOptions<ResponseType, unknown>, method);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Retry configuration — computed once, not per attempt
|
||||
// Execute — fast path (no execute middleware) vs onion chain
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const retryDisabled = context.options.retry === false;
|
||||
const maxRetries = retryDisabled
|
||||
? 0
|
||||
: isNumber(context.options.retry)
|
||||
? context.options.retry
|
||||
: isPayloadMethod(method)
|
||||
? 0
|
||||
: 1;
|
||||
|
||||
if (maxRetries === 0) {
|
||||
try {
|
||||
return await executeFetch(context) as FetchResponse<MappedResponseType<R, T>>;
|
||||
}
|
||||
catch (err) {
|
||||
const error = err instanceof FetchError ? err : createFetchError(context);
|
||||
|
||||
if (isFunction(Error.captureStackTrace)) {
|
||||
Error.captureStackTrace(error, $fetchRaw);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Retry path — delegates to stdlib retry with iterative while-loop
|
||||
try {
|
||||
return await retry(
|
||||
async ({ stop }) => {
|
||||
try {
|
||||
return await executeFetch(context) as FetchResponse<MappedResponseType<R, T>>;
|
||||
}
|
||||
catch (error) {
|
||||
// User-initiated abort (not timeout) should not be retried
|
||||
const isAbort
|
||||
= context.error !== undefined
|
||||
&& context.error.name === 'AbortError'
|
||||
&& context.options.timeout === undefined;
|
||||
|
||||
if (isAbort) {
|
||||
stop(error);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
// stdlib retry counts total attempts; fetch `retry` means retries only
|
||||
times: maxRetries + 1,
|
||||
delay: isFunction(context.options.retryDelay)
|
||||
? () => (context.options.retryDelay as (ctx: FetchContext<T, R>) => number)(context)
|
||||
: (context.options.retryDelay ?? 0),
|
||||
shouldRetry: () => {
|
||||
const status = context.response?.status ?? 500;
|
||||
return context.options.retryStatusCodes !== undefined
|
||||
? context.options.retryStatusCodes.includes(status)
|
||||
: DEFAULT_RETRY_STATUS_CODES.has(status);
|
||||
},
|
||||
},
|
||||
);
|
||||
if (composedExecute === undefined) {
|
||||
await runAttempt(context);
|
||||
}
|
||||
else {
|
||||
await composedExecute(context as unknown as FetchContext, () => runAttempt(context));
|
||||
}
|
||||
return context.response as FetchResponse<MappedResponseType<R, T>>;
|
||||
}
|
||||
catch (err) {
|
||||
const error = err instanceof FetchError ? err : createFetchError(context);
|
||||
|
||||
// V8 / Node.js — clip internal frames from the error stack trace
|
||||
if (isFunction(Error.captureStackTrace)) {
|
||||
Error.captureStackTrace(error, $fetchRaw);
|
||||
if (captureStackTrace !== undefined) {
|
||||
captureStackTrace(error, $fetchRaw);
|
||||
}
|
||||
|
||||
throw error;
|
||||
@@ -297,35 +303,43 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch {
|
||||
): Promise<MappedResponseType<R, T>> {
|
||||
const response = await $fetchRaw<T, R>(request, options);
|
||||
return response._data as MappedResponseType<R, T>;
|
||||
} as $Fetch;
|
||||
} as $Fetch<Plugins>;
|
||||
|
||||
$fetch.raw = $fetchRaw;
|
||||
|
||||
$fetch.native = (...args: Parameters<typeof fetchImpl>) => fetchImpl(...args);
|
||||
$fetch.native = fetchImpl as Fetch;
|
||||
|
||||
$fetch.create = (defaults: FetchOptions = {}, customGlobalOptions: CreateFetchOptions = {}) =>
|
||||
createFetch({
|
||||
...globalOptions,
|
||||
...customGlobalOptions,
|
||||
$fetch.create = (<NewPlugins extends readonly FetchPlugin[] = []>(
|
||||
defaults: FetchOptions = {},
|
||||
customGlobalOptions: CreateFetchOptions<NewPlugins> = {},
|
||||
) =>
|
||||
createFetch<[...Plugins, ...NewPlugins]>({
|
||||
fetch: customGlobalOptions.fetch ?? globalOptions.fetch,
|
||||
plugins: [
|
||||
...((globalOptions.plugins ?? []) as readonly FetchPlugin[]),
|
||||
...((customGlobalOptions.plugins ?? []) as readonly FetchPlugin[]),
|
||||
] as unknown as [...Plugins, ...NewPlugins],
|
||||
defaults: {
|
||||
...globalOptions.defaults,
|
||||
...customGlobalOptions.defaults,
|
||||
...(globalOptions.defaults as FetchOptions | undefined),
|
||||
...(customGlobalOptions.defaults as FetchOptions | undefined),
|
||||
...defaults,
|
||||
},
|
||||
});
|
||||
} as FetchOptions & MergePluginOptions<[...Plugins, ...NewPlugins]>,
|
||||
})) as $Fetch<Plugins>['create'];
|
||||
|
||||
$fetch.extend = $fetch.create;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Method shortcuts
|
||||
// Method shortcuts — `withMethod` keeps the fast path allocation-free
|
||||
// when no per-request options are provided.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
$fetch.get = (request, options) => $fetch(request, { ...options, method: 'GET' });
|
||||
$fetch.post = (request, options) => $fetch(request, { ...options, method: 'POST' });
|
||||
$fetch.put = (request, options) => $fetch(request, { ...options, method: 'PUT' });
|
||||
$fetch.patch = (request, options) => $fetch(request, { ...options, method: 'PATCH' });
|
||||
$fetch.delete = (request, options) => $fetch(request, { ...options, method: 'DELETE' });
|
||||
$fetch.head = (request, options) => $fetchRaw(request, { ...options, method: 'HEAD' });
|
||||
type ShortcutOptions = FetchOptions & MergePluginOptions<Plugins>;
|
||||
$fetch.get = ((req, opt) => $fetch(req, withMethod(opt, 'GET') as ShortcutOptions)) as $Fetch<Plugins>['get'];
|
||||
$fetch.post = ((req, opt) => $fetch(req, withMethod(opt, 'POST') as ShortcutOptions)) as $Fetch<Plugins>['post'];
|
||||
$fetch.put = ((req, opt) => $fetch(req, withMethod(opt, 'PUT') as ShortcutOptions)) as $Fetch<Plugins>['put'];
|
||||
$fetch.patch = ((req, opt) => $fetch(req, withMethod(opt, 'PATCH') as ShortcutOptions)) as $Fetch<Plugins>['patch'];
|
||||
$fetch.delete = ((req, opt) => $fetch(req, withMethod(opt, 'DELETE') as ShortcutOptions)) as $Fetch<Plugins>['delete'];
|
||||
$fetch.head = ((req, opt) => $fetchRaw(req, withMethod(opt, 'HEAD') as ShortcutOptions)) as $Fetch<Plugins>['head'];
|
||||
|
||||
return $fetch;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ import { createFetch } from './fetch';
|
||||
|
||||
export { createFetch } from './fetch';
|
||||
export { FetchError, createFetchError } from './error';
|
||||
export { composePlugins, definePlugin, runHookPhase } from './plugin';
|
||||
export type { ComposedPlugins } from './plugin';
|
||||
export { retryPlugin, timeoutPlugin } from './plugins';
|
||||
export {
|
||||
isPayloadMethod,
|
||||
isJSONSerializable,
|
||||
@@ -17,13 +20,17 @@ export type {
|
||||
Fetch,
|
||||
FetchContext,
|
||||
FetchErrorOptions,
|
||||
FetchExecuteMiddleware,
|
||||
FetchHook,
|
||||
FetchHooks,
|
||||
FetchOptions,
|
||||
FetchPlugin,
|
||||
FetchRequest,
|
||||
FetchResponse,
|
||||
IFetchError,
|
||||
MappedResponseType,
|
||||
MergePluginContext,
|
||||
MergePluginOptions,
|
||||
ResponseMap,
|
||||
ResponseType,
|
||||
ResolvedFetchOptions,
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
import type { Fetch, FetchContext } from './types';
|
||||
import { describe, expect, expectTypeOf, it, vi } from 'vitest';
|
||||
import { FetchError } from './error';
|
||||
import { createFetch } from './fetch';
|
||||
import { definePlugin } from './plugin';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeFetchMock(
|
||||
body: unknown = { ok: true },
|
||||
init: ResponseInit = { status: 200 },
|
||||
contentType = 'application/json',
|
||||
) {
|
||||
return vi.fn<Fetch>().mockResolvedValue(
|
||||
new Response(typeof body === 'string' ? body : JSON.stringify(body), {
|
||||
...init,
|
||||
headers: { 'content-type': contentType, ...init.headers },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// definePlugin — identity + inference
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('definePlugin', () => {
|
||||
it('returns the plugin object verbatim', () => {
|
||||
const plugin = definePlugin({ name: 'noop' });
|
||||
expect(plugin.name).toBe('noop');
|
||||
});
|
||||
|
||||
it('preserves the const Name generic', () => {
|
||||
const plugin = definePlugin({ name: 'auth' });
|
||||
expectTypeOf(plugin.name).toEqualTypeOf<'auth'>();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Defaults merging
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('plugin defaults', () => {
|
||||
it('applies plugin defaults to every request', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const baseUrl = definePlugin({
|
||||
name: 'baseUrl',
|
||||
defaults: { baseURL: 'https://api.example.com' },
|
||||
});
|
||||
const $fetch = createFetch({ fetch: fetchMock, plugins: [baseUrl] });
|
||||
|
||||
await $fetch('/users');
|
||||
|
||||
const [url] = fetchMock.mock.calls[0] as [string];
|
||||
expect(url).toBe('https://api.example.com/users');
|
||||
});
|
||||
|
||||
it('user defaults override plugin defaults', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const plugin = definePlugin({
|
||||
name: 'x',
|
||||
defaults: { baseURL: 'https://plugin.example.com' },
|
||||
});
|
||||
const $fetch = createFetch({
|
||||
fetch: fetchMock,
|
||||
plugins: [plugin],
|
||||
defaults: { baseURL: 'https://user.example.com' },
|
||||
});
|
||||
|
||||
await $fetch('/x');
|
||||
|
||||
const [url] = fetchMock.mock.calls[0] as [string];
|
||||
expect(url).toBe('https://user.example.com/x');
|
||||
});
|
||||
|
||||
it('merges headers from plugin defaults and user defaults', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const plugin = definePlugin({
|
||||
name: 'hdrs',
|
||||
defaults: { headers: { 'x-plugin': 'p' } },
|
||||
});
|
||||
const $fetch = createFetch({
|
||||
fetch: fetchMock,
|
||||
plugins: [plugin],
|
||||
defaults: { headers: { 'x-user': 'u' } },
|
||||
});
|
||||
|
||||
await $fetch('https://api.example.com');
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
const headers = init.headers as Headers;
|
||||
expect(headers.get('x-plugin')).toBe('p');
|
||||
expect(headers.get('x-user')).toBe('u');
|
||||
});
|
||||
|
||||
it('user headers win on conflict', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const plugin = definePlugin({
|
||||
name: 'hdrs',
|
||||
defaults: { headers: { authorization: 'plugin' } },
|
||||
});
|
||||
const $fetch = createFetch({
|
||||
fetch: fetchMock,
|
||||
plugins: [plugin],
|
||||
defaults: { headers: { authorization: 'user' } },
|
||||
});
|
||||
|
||||
await $fetch('https://api.example.com');
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect((init.headers as Headers).get('authorization')).toBe('user');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook ordering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('plugin hooks', () => {
|
||||
it('runs plugin hooks before per-request hooks', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const calls: string[] = [];
|
||||
const plugin = definePlugin({
|
||||
name: 'a',
|
||||
hooks: {
|
||||
onRequest: () => {
|
||||
calls.push('plugin');
|
||||
},
|
||||
},
|
||||
});
|
||||
const $fetch = createFetch({ fetch: fetchMock, plugins: [plugin] });
|
||||
|
||||
await $fetch('https://api.example.com', {
|
||||
onRequest: () => {
|
||||
calls.push('user');
|
||||
},
|
||||
});
|
||||
|
||||
expect(calls).toEqual(['plugin', 'user']);
|
||||
});
|
||||
|
||||
it('preserves plugin registration order across multiple plugins', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const calls: string[] = [];
|
||||
const a = definePlugin({
|
||||
name: 'a',
|
||||
hooks: { onRequest: () => { calls.push('a'); } },
|
||||
});
|
||||
const b = definePlugin({
|
||||
name: 'b',
|
||||
hooks: { onRequest: () => { calls.push('b'); } },
|
||||
});
|
||||
const $fetch = createFetch({ fetch: fetchMock, plugins: [a, b] });
|
||||
|
||||
await $fetch('https://api.example.com');
|
||||
|
||||
expect(calls).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('supports arrays of hooks inside a single plugin', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const calls: string[] = [];
|
||||
const plugin = definePlugin({
|
||||
name: 'multi',
|
||||
hooks: {
|
||||
onRequest: [
|
||||
() => { calls.push('1'); },
|
||||
() => { calls.push('2'); },
|
||||
],
|
||||
},
|
||||
});
|
||||
const $fetch = createFetch({ fetch: fetchMock, plugins: [plugin] });
|
||||
|
||||
await $fetch('https://api.example.com');
|
||||
|
||||
expect(calls).toEqual(['1', '2']);
|
||||
});
|
||||
|
||||
it('invokes onResponse hook for successful responses', async () => {
|
||||
const fetchMock = makeFetchMock({ id: 1 });
|
||||
const seen: number[] = [];
|
||||
const plugin = definePlugin({
|
||||
name: 'r',
|
||||
hooks: {
|
||||
onResponse: (ctx) => {
|
||||
if (ctx.response.status === 200) seen.push(ctx.response.status);
|
||||
},
|
||||
},
|
||||
});
|
||||
const $fetch = createFetch({ fetch: fetchMock, plugins: [plugin] });
|
||||
|
||||
await $fetch('https://api.example.com');
|
||||
|
||||
expect(seen).toEqual([200]);
|
||||
});
|
||||
|
||||
it('invokes onResponseError hook for 4xx', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response('', { status: 401 }));
|
||||
const calls: string[] = [];
|
||||
const plugin = definePlugin({
|
||||
name: 'err',
|
||||
hooks: { onResponseError: () => { calls.push('err'); } },
|
||||
});
|
||||
const $fetch = createFetch({ fetch: fetchMock, plugins: [plugin] });
|
||||
|
||||
await expect($fetch('https://api.example.com', { retry: false }))
|
||||
.rejects.toBeInstanceOf(FetchError);
|
||||
expect(calls).toEqual(['err']);
|
||||
});
|
||||
|
||||
it('invokes onRequestError hook on network failure', async () => {
|
||||
const fetchMock = vi.fn().mockRejectedValue(new TypeError('offline'));
|
||||
const calls: string[] = [];
|
||||
const plugin = definePlugin({
|
||||
name: 'net',
|
||||
hooks: { onRequestError: () => { calls.push('net'); } },
|
||||
});
|
||||
const $fetch = createFetch({ fetch: fetchMock, plugins: [plugin] });
|
||||
|
||||
await expect($fetch('https://api.example.com', { retry: false }))
|
||||
.rejects.toBeInstanceOf(FetchError);
|
||||
expect(calls).toEqual(['net']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('plugin setup', () => {
|
||||
it('is called exactly once per createFetch', async () => {
|
||||
const fetchMock = vi.fn<Fetch>().mockImplementation(async () =>
|
||||
new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }),
|
||||
);
|
||||
const setup = vi.fn();
|
||||
const plugin = definePlugin({ name: 's', setup });
|
||||
|
||||
const $fetch = createFetch({ fetch: fetchMock, plugins: [plugin] });
|
||||
expect(setup).toHaveBeenCalledTimes(1);
|
||||
|
||||
await $fetch('https://api.example.com');
|
||||
await $fetch('https://api.example.com');
|
||||
expect(setup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('receives the fully merged defaults', () => {
|
||||
const plugin = definePlugin({
|
||||
name: 'inspect',
|
||||
defaults: { baseURL: 'https://plugin.example.com' },
|
||||
setup: ({ defaults }) => {
|
||||
expect(defaults.baseURL).toBe('https://user.example.com');
|
||||
},
|
||||
});
|
||||
createFetch({
|
||||
fetch: makeFetchMock({}),
|
||||
plugins: [plugin],
|
||||
defaults: { baseURL: 'https://user.example.com' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extend / create — plugin inheritance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('extend inherits plugins', () => {
|
||||
it('child instance runs parent plugin hooks', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const calls: string[] = [];
|
||||
const parentPlugin = definePlugin({
|
||||
name: 'parent',
|
||||
hooks: { onRequest: () => { calls.push('parent'); } },
|
||||
});
|
||||
const parent = createFetch({ fetch: fetchMock, plugins: [parentPlugin] });
|
||||
const child = parent.extend({});
|
||||
|
||||
await child('https://api.example.com');
|
||||
|
||||
expect(calls).toEqual(['parent']);
|
||||
});
|
||||
|
||||
it('child plugin runs after parent plugin', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const calls: string[] = [];
|
||||
const parentPlugin = definePlugin({
|
||||
name: 'parent',
|
||||
hooks: { onRequest: () => { calls.push('parent'); } },
|
||||
});
|
||||
const childPlugin = definePlugin({
|
||||
name: 'child',
|
||||
hooks: { onRequest: () => { calls.push('child'); } },
|
||||
});
|
||||
const parent = createFetch({ fetch: fetchMock, plugins: [parentPlugin] });
|
||||
const child = parent.extend({}, { plugins: [childPlugin] });
|
||||
|
||||
await child('https://api.example.com', {
|
||||
onRequest: () => { calls.push('user'); },
|
||||
});
|
||||
|
||||
expect(calls).toEqual(['parent', 'child', 'user']);
|
||||
});
|
||||
|
||||
it('child defaults override parent defaults', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const parent = createFetch({
|
||||
fetch: fetchMock,
|
||||
defaults: { baseURL: 'https://parent.example.com' },
|
||||
});
|
||||
const child = parent.extend({ baseURL: 'https://child.example.com' });
|
||||
|
||||
await child('/x');
|
||||
|
||||
const [url] = fetchMock.mock.calls[0] as [string];
|
||||
expect(url).toBe('https://child.example.com/x');
|
||||
});
|
||||
|
||||
it('parent plugin defaults persist through extend', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const plugin = definePlugin({
|
||||
name: 'base',
|
||||
defaults: { baseURL: 'https://plugin.example.com' },
|
||||
});
|
||||
const parent = createFetch({ fetch: fetchMock, plugins: [plugin] });
|
||||
const child = parent.extend({});
|
||||
|
||||
await child('/x');
|
||||
|
||||
const [url] = fetchMock.mock.calls[0] as [string];
|
||||
expect(url).toBe('https://plugin.example.com/x');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zero-regression: instance without plugins behaves exactly like before
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('no-plugin instances', () => {
|
||||
it('skips the hook fast-path when neither plugin nor user hooks are set', async () => {
|
||||
const fetchMock = makeFetchMock({ ok: true });
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
const data = await $fetch<{ ok: boolean }>('https://api.example.com');
|
||||
|
||||
expect(data).toEqual({ ok: true });
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type-level checks — plugin OptionsExt flows into request options
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('type inference', () => {
|
||||
it('adds plugin OptionsExt fields to request options', () => {
|
||||
const auth = definePlugin<'auth', { token?: string }>({ name: 'auth' });
|
||||
const _api = createFetch({ plugins: [auth] });
|
||||
|
||||
// Valid usage — `token` is known to the type system.
|
||||
type ApiCall = Parameters<typeof _api>[1];
|
||||
expectTypeOf<ApiCall>().toMatchTypeOf<{ token?: string } | undefined>();
|
||||
});
|
||||
|
||||
it('rejects unknown fields when no plugin declares them', () => {
|
||||
const api = createFetch();
|
||||
|
||||
// @ts-expect-error — `token` is not a known option on a plugin-less instance.
|
||||
void (() => api('https://api.example.com', { token: 'x' }));
|
||||
});
|
||||
|
||||
it('exposes context extension as a type-only carrier', () => {
|
||||
const trace = definePlugin<'trace', object, { traceId: string }>({
|
||||
name: 'trace',
|
||||
});
|
||||
|
||||
expectTypeOf(trace.name).toEqualTypeOf<'trace'>();
|
||||
// Sanity: FetchContext at runtime is still the base shape; ContextExt is advisory.
|
||||
expectTypeOf<FetchContext>().toMatchTypeOf<{ request: unknown }>();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { retryPlugin } from './retry';
|
||||
export { timeoutPlugin } from './timeout';
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { FetchContext, ResolvedFetchOptions } from '../types';
|
||||
import { isFunction, isNumber, retry } from '@robonen/stdlib';
|
||||
import { definePlugin } from '../plugin';
|
||||
import { isPayloadMethod } from '../utils';
|
||||
|
||||
/** HTTP status codes that trigger automatic retry by default */
|
||||
const DEFAULT_RETRY_STATUS_CODES: ReadonlySet<number> = /* @__PURE__ */ new Set([
|
||||
408, // Request Timeout
|
||||
409, // Conflict
|
||||
425, // Too Early (Experimental)
|
||||
429, // Too Many Requests
|
||||
500, // Internal Server Error
|
||||
502, // Bad Gateway
|
||||
503, // Service Unavailable
|
||||
504, // Gateway Timeout
|
||||
]);
|
||||
|
||||
const ABORT_ERROR_NAME = 'AbortError';
|
||||
|
||||
/**
|
||||
* Compute the number of retries for a given request. Module-scope so the
|
||||
* function site stays monomorphic (always sees `ResolvedFetchOptions` / string).
|
||||
*/
|
||||
function computeMaxRetries(options: ResolvedFetchOptions, method: string): number {
|
||||
const retryOpt = options.retry;
|
||||
if (retryOpt === false) return 0;
|
||||
if (isNumber(retryOpt)) return retryOpt;
|
||||
return isPayloadMethod(method) ? 0 : 1;
|
||||
}
|
||||
|
||||
/** True when the current response status is in the effective retry-status allowlist. */
|
||||
function shouldRetryStatus(options: ResolvedFetchOptions, status: number): boolean {
|
||||
const list = options.retryStatusCodes;
|
||||
return list !== undefined
|
||||
? list.includes(status)
|
||||
: DEFAULT_RETRY_STATUS_CODES.has(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name retryPlugin
|
||||
* @category Fetch
|
||||
* @description Retries failed attempts based on status code, respecting
|
||||
* `retry` / `retryDelay` / `retryStatusCodes` request options.
|
||||
*
|
||||
* Auto-registered by `createFetch`; disable per-request via `retry: false`.
|
||||
*
|
||||
* @since 0.1.0
|
||||
*/
|
||||
export function retryPlugin() {
|
||||
return definePlugin({
|
||||
name: 'retry',
|
||||
execute: async (context, next) => {
|
||||
const options = context.options;
|
||||
const maxRetries = computeMaxRetries(options, options.method ?? 'GET');
|
||||
|
||||
// Fast path — no retries requested; avoid the stdlib retry wrapper
|
||||
if (maxRetries === 0) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const retryDelay = options.retryDelay;
|
||||
const delay = isFunction(retryDelay)
|
||||
? () => (retryDelay as (ctx: FetchContext) => number)(context)
|
||||
: (retryDelay ?? 0);
|
||||
|
||||
await retry(
|
||||
async ({ stop }) => {
|
||||
try {
|
||||
await next();
|
||||
}
|
||||
catch (error) {
|
||||
// User-initiated abort must never be retried. `AbortSignal.timeout`
|
||||
// aborts with a `TimeoutError`, so a plain `AbortError` is always
|
||||
// caller-driven and should stop the retry loop immediately.
|
||||
const err = context.error;
|
||||
if (err !== undefined && err.name === ABORT_ERROR_NAME) {
|
||||
stop(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
// stdlib retry counts total attempts; fetch `retry` means retries only
|
||||
times: maxRetries + 1,
|
||||
delay,
|
||||
shouldRetry: () => shouldRetryStatus(options, context.response?.status ?? 500),
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { definePlugin } from '../plugin';
|
||||
|
||||
/**
|
||||
* Caller's original `signal`, captured once per request so each retry attempt
|
||||
* recombines a *fresh* timeout signal with it instead of reusing an already
|
||||
* aborted one. Keyed on the FetchContext to keep its hidden class stable.
|
||||
*/
|
||||
const baseSignals = new WeakMap<object, AbortSignal | undefined>();
|
||||
|
||||
/**
|
||||
* @name timeoutPlugin
|
||||
* @category Fetch
|
||||
* @description Composes an `AbortSignal.timeout(ms)` with any caller-supplied signal
|
||||
* when `options.timeout` is set.
|
||||
*
|
||||
* Implemented as an `execute` middleware (inner to `retry`) so every retry attempt
|
||||
* gets a brand-new timeout signal — a single timeout no longer poisons all
|
||||
* subsequent attempts. The timeout therefore applies per attempt, not to the whole
|
||||
* retry sequence.
|
||||
*
|
||||
* Auto-registered by `createFetch`; no-op when `timeout` is unset.
|
||||
*
|
||||
* @since 0.1.0
|
||||
*/
|
||||
export function timeoutPlugin() {
|
||||
return definePlugin({
|
||||
name: 'timeout',
|
||||
execute: async (context, next) => {
|
||||
const options = context.options;
|
||||
const timeout = options.timeout;
|
||||
if (timeout === undefined) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fix the caller's signal once; reuse it across retry attempts.
|
||||
let base: AbortSignal | undefined;
|
||||
if (baseSignals.has(context)) {
|
||||
base = baseSignals.get(context);
|
||||
}
|
||||
else {
|
||||
base = options.signal as AbortSignal | undefined;
|
||||
baseSignals.set(context, base);
|
||||
}
|
||||
|
||||
const timeoutSignal = AbortSignal.timeout(timeout);
|
||||
options.signal = base === undefined
|
||||
? timeoutSignal
|
||||
: AbortSignal.any([timeoutSignal, base]);
|
||||
|
||||
await next();
|
||||
},
|
||||
});
|
||||
}
|
||||
+128
-16
@@ -1,4 +1,4 @@
|
||||
import type { MaybePromise, ReadonlyArrayable } from '@robonen/stdlib';
|
||||
import type { MaybePromise, ReadonlyArrayable, UnionToIntersection } from '@robonen/stdlib';
|
||||
|
||||
// --------------------------
|
||||
// Fetch API
|
||||
@@ -8,51 +8,60 @@ import type { MaybePromise, ReadonlyArrayable } from '@robonen/stdlib';
|
||||
* @name $Fetch
|
||||
* @category Fetch
|
||||
* @description The main fetch interface with method shortcuts, raw access, and factory methods
|
||||
*
|
||||
* @typeParam Plugins - Tuple of plugins attached to this instance; their option
|
||||
* extensions are merged into every request's options type.
|
||||
*/
|
||||
export interface $Fetch {
|
||||
export interface $Fetch<Plugins extends readonly FetchPlugin[] = []> {
|
||||
<T = unknown, R extends ResponseType = 'json'>(
|
||||
request: FetchRequest,
|
||||
options?: FetchOptions<R, T>,
|
||||
options?: FetchOptions<R, T> & MergePluginOptions<Plugins>,
|
||||
): Promise<MappedResponseType<R, T>>;
|
||||
raw<T = unknown, R extends ResponseType = 'json'>(
|
||||
request: FetchRequest,
|
||||
options?: FetchOptions<R, T>,
|
||||
options?: FetchOptions<R, T> & MergePluginOptions<Plugins>,
|
||||
): Promise<FetchResponse<MappedResponseType<R, T>>>;
|
||||
/** Access to the underlying native fetch function */
|
||||
native: Fetch;
|
||||
/** Create a new fetch instance with merged defaults */
|
||||
create(defaults?: FetchOptions, globalOptions?: CreateFetchOptions): $Fetch;
|
||||
/** Alias for create — extend this instance with new defaults */
|
||||
extend(defaults?: FetchOptions, globalOptions?: CreateFetchOptions): $Fetch;
|
||||
/** Create a new fetch instance with merged defaults and (optionally) additional plugins */
|
||||
create<NewPlugins extends readonly FetchPlugin[] = []>(
|
||||
defaults?: FetchOptions & MergePluginOptions<[...Plugins, ...NewPlugins]>,
|
||||
globalOptions?: CreateFetchOptions<NewPlugins>,
|
||||
): $Fetch<[...Plugins, ...NewPlugins]>;
|
||||
/** Alias for create — extend this instance with new defaults and (optionally) additional plugins */
|
||||
extend<NewPlugins extends readonly FetchPlugin[] = []>(
|
||||
defaults?: FetchOptions & MergePluginOptions<[...Plugins, ...NewPlugins]>,
|
||||
globalOptions?: CreateFetchOptions<NewPlugins>,
|
||||
): $Fetch<[...Plugins, ...NewPlugins]>;
|
||||
/** Shorthand for GET requests */
|
||||
get<T = unknown, R extends ResponseType = 'json'>(
|
||||
request: FetchRequest,
|
||||
options?: Omit<FetchOptions<R, T>, 'method'>,
|
||||
options?: Omit<FetchOptions<R, T>, 'method'> & MergePluginOptions<Plugins>,
|
||||
): Promise<MappedResponseType<R, T>>;
|
||||
/** Shorthand for POST requests */
|
||||
post<T = unknown, R extends ResponseType = 'json'>(
|
||||
request: FetchRequest,
|
||||
options?: Omit<FetchOptions<R, T>, 'method'>,
|
||||
options?: Omit<FetchOptions<R, T>, 'method'> & MergePluginOptions<Plugins>,
|
||||
): Promise<MappedResponseType<R, T>>;
|
||||
/** Shorthand for PUT requests */
|
||||
put<T = unknown, R extends ResponseType = 'json'>(
|
||||
request: FetchRequest,
|
||||
options?: Omit<FetchOptions<R, T>, 'method'>,
|
||||
options?: Omit<FetchOptions<R, T>, 'method'> & MergePluginOptions<Plugins>,
|
||||
): Promise<MappedResponseType<R, T>>;
|
||||
/** Shorthand for PATCH requests */
|
||||
patch<T = unknown, R extends ResponseType = 'json'>(
|
||||
request: FetchRequest,
|
||||
options?: Omit<FetchOptions<R, T>, 'method'>,
|
||||
options?: Omit<FetchOptions<R, T>, 'method'> & MergePluginOptions<Plugins>,
|
||||
): Promise<MappedResponseType<R, T>>;
|
||||
/** Shorthand for DELETE requests */
|
||||
delete<T = unknown, R extends ResponseType = 'json'>(
|
||||
request: FetchRequest,
|
||||
options?: Omit<FetchOptions<R, T>, 'method'>,
|
||||
options?: Omit<FetchOptions<R, T>, 'method'> & MergePluginOptions<Plugins>,
|
||||
): Promise<MappedResponseType<R, T>>;
|
||||
/** Shorthand for HEAD requests */
|
||||
head(
|
||||
request: FetchRequest,
|
||||
options?: Omit<FetchOptions, 'method'>,
|
||||
options?: Omit<FetchOptions, 'method'> & MergePluginOptions<Plugins>,
|
||||
): Promise<FetchResponse<unknown>>;
|
||||
}
|
||||
|
||||
@@ -117,14 +126,117 @@ export interface ResolvedFetchOptions<R extends ResponseType = 'json', T = unkno
|
||||
* @name CreateFetchOptions
|
||||
* @category Fetch
|
||||
* @description Global options for createFetch
|
||||
*
|
||||
* @typeParam Plugins - Tuple of plugins to attach to the instance
|
||||
*/
|
||||
export interface CreateFetchOptions {
|
||||
export interface CreateFetchOptions<Plugins extends readonly FetchPlugin[] = []> {
|
||||
/** Default options merged into every request */
|
||||
defaults?: FetchOptions;
|
||||
defaults?: FetchOptions & MergePluginOptions<Plugins>;
|
||||
/** Custom fetch implementation — defaults to globalThis.fetch */
|
||||
fetch?: Fetch;
|
||||
/**
|
||||
* Plugins composed once at createFetch time.
|
||||
* Each plugin may contribute defaults, lifecycle hooks, and typed option fields.
|
||||
*/
|
||||
plugins?: Plugins;
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// Plugins
|
||||
// --------------------------
|
||||
|
||||
/**
|
||||
* @name FetchPlugin
|
||||
* @category Fetch
|
||||
* @description A reusable bundle of defaults and lifecycle hooks that extends a fetch instance.
|
||||
*
|
||||
* Plugins are composed once at `createFetch` time — their defaults and hooks are
|
||||
* flattened into the instance closure, so attaching plugins adds zero per-request
|
||||
* overhead beyond the contributed hooks themselves.
|
||||
*
|
||||
* @typeParam Name - Unique plugin identifier (for debugging / duplicate detection)
|
||||
* @typeParam OptionsExt - Extra fields merged into every request's options type
|
||||
* @typeParam ContextExt - Extra fields that the plugin may attach to FetchContext
|
||||
* at runtime (advisory; not enforced by the core pipeline)
|
||||
*
|
||||
* @example
|
||||
* const authPlugin = definePlugin<'auth', { token?: string }>({
|
||||
* name: 'auth',
|
||||
* hooks: {
|
||||
* onRequest: (ctx) => {
|
||||
* const token = (ctx.options as { token?: string }).token;
|
||||
* if (token) ctx.options.headers.set('authorization', `Bearer ${token}`);
|
||||
* },
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export interface FetchPlugin<
|
||||
Name extends string = string,
|
||||
OptionsExt = unknown,
|
||||
ContextExt = unknown,
|
||||
> {
|
||||
/** Plugin identifier */
|
||||
readonly name: Name;
|
||||
/** Default options contributed by the plugin — merged under user defaults */
|
||||
readonly defaults?: FetchOptions;
|
||||
/** Lifecycle hooks executed before any user per-request hooks */
|
||||
readonly hooks?: FetchHooks;
|
||||
/**
|
||||
* Onion-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).
|
||||
*/
|
||||
readonly execute?: FetchExecuteMiddleware;
|
||||
/** Invoked once per createFetch, after all plugin defaults are merged */
|
||||
readonly setup?: (context: { readonly defaults: FetchOptions }) => void;
|
||||
/**
|
||||
* Phantom marker for type-only option/context extensions — never present at runtime.
|
||||
* Populated via dummy field in `definePlugin` generics.
|
||||
* @internal
|
||||
*/
|
||||
readonly __types?: { options: OptionsExt; context: ContextExt };
|
||||
}
|
||||
|
||||
/**
|
||||
* @name FetchExecuteMiddleware
|
||||
* @category Fetch
|
||||
* @description Onion-style wrapper around a single fetch attempt.
|
||||
*
|
||||
* Invoking `next()` delegates to the next middleware in the chain or, at the
|
||||
* innermost layer, performs the actual `fetch` call and response body parsing.
|
||||
* `context.response` / `context.error` are populated by the time `next()` resolves.
|
||||
*
|
||||
* Middlewares may call `next()` zero, one, or many times (retries).
|
||||
*/
|
||||
export type FetchExecuteMiddleware = (
|
||||
context: FetchContext,
|
||||
next: () => Promise<void>,
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* @name MergePluginOptions
|
||||
* @category Fetch
|
||||
* @description Intersection of all `OptionsExt` carried by a plugin tuple. Empty tuple resolves to `unknown`.
|
||||
*/
|
||||
export type MergePluginOptions<Plugins extends readonly FetchPlugin[]>
|
||||
= [Plugins[number]] extends [never]
|
||||
? unknown
|
||||
: UnionToIntersection<PluginOptionsOf<Plugins[number]>>;
|
||||
|
||||
/**
|
||||
* @name MergePluginContext
|
||||
* @category Fetch
|
||||
* @description Intersection of all `ContextExt` carried by a plugin tuple. Empty tuple resolves to `unknown`.
|
||||
*/
|
||||
export type MergePluginContext<Plugins extends readonly FetchPlugin[]>
|
||||
= [Plugins[number]] extends [never]
|
||||
? unknown
|
||||
: UnionToIntersection<PluginContextOf<Plugins[number]>>;
|
||||
|
||||
type PluginOptionsOf<P> = P extends FetchPlugin<infer _N, infer O, infer _C> ? O : unknown;
|
||||
type PluginContextOf<P> = P extends FetchPlugin<infer _N, infer _O, infer C> ? C : unknown;
|
||||
|
||||
// --------------------------
|
||||
// Hooks and Context
|
||||
// --------------------------
|
||||
|
||||
@@ -131,6 +131,18 @@ describe('buildURL', () => {
|
||||
it('returns the URL unchanged when all params are omitted', () => {
|
||||
expect(buildURL('https://api.example.com', { a: null })).toBe('https://api.example.com');
|
||||
});
|
||||
|
||||
it('inserts the query string before a fragment', () => {
|
||||
expect(buildURL('https://api.example.com/p#section', { a: 1 })).toBe(
|
||||
'https://api.example.com/p?a=1#section',
|
||||
);
|
||||
});
|
||||
|
||||
it('appends to an existing query string before a fragment', () => {
|
||||
expect(buildURL('https://api.example.com/p?foo=bar#section', { baz: 'qux' })).toBe(
|
||||
'https://api.example.com/p?foo=bar&baz=qux#section',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+13
-5
@@ -66,8 +66,9 @@ export function isJSONSerializable(value: unknown): boolean {
|
||||
// Arrays are serialisable
|
||||
if (isArray(value)) return true;
|
||||
|
||||
// TypedArrays / ArrayBuffers carry a .buffer property — not JSON-serialisable
|
||||
if ((value as Record<string, unknown>).buffer !== undefined) return false;
|
||||
// TypedArrays and ArrayBuffers — use native type checks instead of probing
|
||||
// `.buffer`, which would pollute the property IC with every distinct body shape.
|
||||
if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) return false;
|
||||
|
||||
// FormData and URLSearchParams should not be auto-serialised
|
||||
if (value instanceof FormData || value instanceof URLSearchParams) return false;
|
||||
@@ -77,7 +78,7 @@ export function isJSONSerializable(value: unknown): boolean {
|
||||
return (
|
||||
ctor === undefined
|
||||
|| ctor === Object
|
||||
|| typeof (value as Record<string, unknown>).toJSON === 'function'
|
||||
|| typeof (value as { toJSON?: unknown }).toJSON === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,7 +99,9 @@ export function isJSONSerializable(value: unknown): boolean {
|
||||
export function detectResponseType(contentType = ''): ResponseType {
|
||||
if (!contentType) return 'json';
|
||||
|
||||
const type = contentType.split(';')[0] ?? '';
|
||||
// Strip any `; charset=...` suffix without allocating an intermediate array.
|
||||
const semi = contentType.indexOf(';');
|
||||
const type = semi === -1 ? contentType : contentType.slice(0, semi);
|
||||
|
||||
if (JSON_CONTENT_TYPE_RE.test(type)) return 'json';
|
||||
if (type === 'text/event-stream') return 'stream';
|
||||
@@ -140,7 +143,12 @@ export function buildURL(
|
||||
const qs = params.toString();
|
||||
if (!qs) return url;
|
||||
|
||||
return url.includes('?') ? `${url}&${qs}` : `${url}?${qs}`;
|
||||
// Insert the query string *before* any fragment so `#section` stays trailing.
|
||||
const hashIndex = url.indexOf('#');
|
||||
const base = hashIndex === -1 ? url : url.slice(0, hashIndex);
|
||||
const hash = hashIndex === -1 ? '' : url.slice(hashIndex);
|
||||
|
||||
return `${base}${base.includes('?') ? '&' : '?'}${qs}${hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user