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
+2
View File
@@ -0,0 +1,2 @@
export { retryPlugin } from './retry';
export { timeoutPlugin } from './timeout';
+92
View File
@@ -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),
},
);
},
});
}
+54
View File
@@ -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();
},
});
}