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
+376
View File
@@ -0,0 +1,376 @@
# @robonen/fetch
A lightweight, type-safe `fetch` wrapper with interceptors, retry, timeout, and a composable plugin system — V8-optimized internals, zero runtime dependencies beyond the standard library.
```ts
import { $fetch } from '@robonen/fetch';
const user = await $fetch<User>('https://api.example.com/users/1');
// ^? User — body is parsed and typed for you
```
## Install
```bash
pnpm install @robonen/fetch
```
## Why
`globalThis.fetch` is great primitive plumbing, but every app re-implements the same layer on top of it: JSON parsing, throwing on `4xx`/`5xx`, base URLs, query strings, retries, timeouts, auth headers. `@robonen/fetch` is that layer — small, typed, and built so attaching features costs nothing on the hot path.
- **🎯 Type-safe** — response data, options, and plugin-contributed fields are all inferred.
- **📦 Smart bodies** — plain objects are JSON-serialized; `FormData`/`Blob`/streams pass through untouched.
- **🧠 Auto-parsing** — response decoded from `Content-Type`, or forced via `responseType`.
- **💥 Errors that throw** — non-2xx responses reject with a rich `FetchError`.
- **🔁 Retry & ⏱️ timeout** — built in, per-request configurable, sensible defaults.
- **🪝 Lifecycle hooks** — `onRequest` / `onResponse` / `onRequestError` / `onResponseError`.
- **🧩 Plugins** — composed once, typed, with onion-style `execute` middleware.
- **🌱 `create` / `extend`** — derive pre-configured instances that inherit defaults and plugins.
---
## Quick start
```ts
import { $fetch } from '@robonen/fetch';
// GET + automatic JSON parse
const todo = await $fetch<Todo>('https://api.example.com/todos/1');
// POST a plain object — serialized to JSON, content-type set for you
const created = await $fetch<Todo>('https://api.example.com/todos', {
method: 'POST',
body: { title: 'Ship it', done: false },
});
// Method shortcuts
await $fetch.get('https://api.example.com/todos');
await $fetch.delete('https://api.example.com/todos/1');
```
---
## Features
### Type-safe responses
The first type parameter types the parsed body; the second drives the parsing mode.
```ts
interface User { id: number; name: string }
const user = await $fetch<User>('/users/1'); // Promise<User>
const ids = await $fetch<number[]>('/users/ids'); // Promise<number[]>
```
### Method shortcuts
```ts
await $fetch.get<User>('/users/1');
await $fetch.post<User>('/users', { body: { name: 'Alice' } });
await $fetch.put('/users/1', { body: patch });
await $fetch.patch('/users/1', { body: partial });
await $fetch.delete('/users/1');
await $fetch.head('/users/1'); // returns the raw Response
```
### Base URL & query params
```ts
const api = $fetch.create({ baseURL: 'https://api.example.com/v1' });
await api('/users'); // → https://api.example.com/v1/users
await api('/search', { query: { q: 'vue', page: 2 } });
// → ...?q=vue&page=2
```
`null` / `undefined` query values are dropped, existing query strings are preserved, and the query is inserted **before** any `#fragment`.
### Automatic body serialization
```ts
// Plain object → JSON (content-type: application/json + accept: application/json)
await $fetch.post('/users', { body: { name: 'Alice' } });
// content-type: application/x-www-form-urlencoded → form-encoded automatically
await $fetch.post('/login', {
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: { user: 'a', pass: 'b' }, // → "user=a&pass=b"
});
// FormData / Blob / ReadableStream / raw string → passed through untouched
await $fetch.post('/upload', { body: formData });
await $fetch.post('/ndjson', { body: '{"a":1}\n{"b":2}' }); // you own content-type
```
### Response type detection
The body is decoded from the response `Content-Type`, or you can force it:
```ts
const text = await $fetch<string, 'text'>('/readme', { responseType: 'text' });
const blob = await $fetch<Blob, 'blob'>('/avatar.png', { responseType: 'blob' });
const buf = await $fetch<ArrayBuffer, 'arrayBuffer'>('/file', { responseType: 'arrayBuffer' });
const sse = await $fetch<ReadableStream, 'stream'>('/events', { responseType: 'stream' });
// Custom parser (e.g. superjson, devalue)
const data = await $fetch('/data', { parseResponse: txt => superjson.parse(txt) });
```
### Errors that throw
Any `4xx`/`5xx` rejects with a `FetchError` carrying the request, parsed body, and status.
```ts
import { FetchError } from '@robonen/fetch';
try {
await $fetch('/users/999');
}
catch (err) {
if (err instanceof FetchError) {
err.status; // 404
err.statusText; // "Not Found"
err.data; // parsed error body
err.request; // the URL/Request
err.options; // resolved request options (hooks stripped)
}
}
// Opt out — get the parsed body even on error responses
const body = await $fetch('/maybe-404', { ignoreResponseError: true });
```
### Retry
Retries are built in. Defaults: **1 retry for non-payload methods** (GET/HEAD…), **0 for payload methods** (POST/PUT/PATCH/DELETE), on status `408, 409, 425, 429, 500, 502, 503, 504`.
```ts
await $fetch('/flaky', { retry: 3 }); // up to 3 retries
await $fetch('/flaky', { retry: 3, retryDelay: 200 }); // 200ms between attempts
await $fetch('/flaky', { retry: 3, retryDelay: ctx => ctx.response?.status === 429 ? 1000 : 200 });
await $fetch('/flaky', { retryStatusCodes: [429, 503] }); // custom allowlist
await $fetch('/flaky', { retry: false }); // disable
```
A **user-initiated abort never retries**; a timeout does.
### Timeout
Backed by `AbortSignal.timeout`, composed with any signal you pass. The timeout applies **per attempt**, so a slow attempt that times out still gets retried with a fresh signal.
```ts
await $fetch('/slow', { timeout: 5000 });
// Combine with your own AbortController — both are honoured
const controller = new AbortController();
await $fetch('/slow', { timeout: 5000, signal: controller.signal });
```
### Lifecycle hooks
```ts
await $fetch('/users', {
onRequest: (ctx) => {
ctx.options.headers.set('authorization', `Bearer ${token}`);
},
onResponse: (ctx) => {
console.log(ctx.response.status, ctx.response._data);
},
onResponseError: (ctx) => {
report(ctx.response.status, ctx.request);
},
onRequestError: (ctx) => {
console.error('network failure', ctx.error);
},
});
```
Hooks accept a single function or an array, and run after any plugin hooks for the same phase.
---
## Instances: `create` / `extend`
Derive a configured instance. Defaults and plugins are merged; child wins on conflicts.
```ts
const api = $fetch.create({
baseURL: 'https://api.example.com',
headers: { 'x-app': 'web' },
retry: 2,
});
// `extend` is an alias for `create` — layer on more defaults / plugins
const billing = api.extend({ baseURL: 'https://billing.example.com' });
await api('/users'); // x-app + retry:2 inherited
await billing('/invoices'); // inherits headers/retry, overrides baseURL
```
Need the raw `Response`, or the underlying native fetch?
```ts
const res = await $fetch.raw<User>('/users/1');
res._data; // parsed body
res.headers; // full Response API
await $fetch.native('https://example.com'); // untouched globalThis.fetch
```
---
## Plugins
A plugin is a reusable bundle of **defaults**, **typed options**, **lifecycle hooks**, and optional **`execute` middleware**. Plugins are composed once at `createFetch` time — attaching them adds zero per-request overhead beyond the hooks themselves.
```ts
import { createFetch, definePlugin } from '@robonen/fetch';
```
### Auth header injection — with a typed per-request option
```ts
const auth = 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}`);
},
},
});
const api = createFetch({ plugins: [auth] });
await api('/me', { token: 'xyz' }); // `token` is type-checked thanks to the plugin
```
### Token auto-refresh on 401
```ts
function createAuthPlugin(getAccessToken: () => Promise<string>) {
let current: Promise<string> | undefined;
const refresh = () => (current ??= getAccessToken().finally(() => { current = undefined; }));
return definePlugin<'auth', { skipAuth?: boolean }>({
name: 'auth',
defaults: { retry: 1, retryStatusCodes: [401, 408, 429, 500, 502, 503, 504] },
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;
current = undefined; // invalidate; the retry picks up a fresh token
ctx.options.headers.set('authorization', `Bearer ${await refresh()}`);
},
},
});
}
```
### Response envelope unwrapping — `{ data, meta }` → `data`
```ts
const unwrap = definePlugin({
name: 'unwrap',
hooks: {
onResponse: (ctx) => {
const body = ctx.response._data as { data?: unknown } | undefined;
if (body && typeof body === 'object' && 'data' in body) {
ctx.response._data = body.data;
}
},
},
});
```
### `execute` middleware — onion-style wrapping
`execute` wraps the whole fetch attempt (the built-in `retry` is itself an `execute` middleware). Middlewares may call `next()` zero, one, or many times.
```ts
const logger = definePlugin({
name: 'logger',
execute: async (ctx, next) => {
const start = performance.now();
try {
await next(); // run the attempt (+ inner middlewares)
console.log(`${ctx.request}${ctx.response?.status} in ${(performance.now() - start) | 0}ms`);
}
catch (err) {
console.error(`${ctx.request} failed in ${(performance.now() - start) | 0}ms`);
throw err;
}
},
});
```
### Composing — order matters
Hooks run in registration order; the user's per-request hook runs last.
```ts
const api = createFetch({
baseURL: 'https://api.example.com',
plugins: [createAuthPlugin(fetchToken), logger, unwrap],
});
// Children inherit every parent plugin and may add their own
const idempotency = definePlugin<'idempotency', { idempotencyKey?: string }>({
name: 'idempotency',
hooks: {
onRequest: (ctx) => {
const m = (ctx.options.method ?? 'GET').toUpperCase();
if (m === 'GET' || m === 'HEAD') return;
const key = (ctx.options as { idempotencyKey?: string }).idempotencyKey ?? crypto.randomUUID();
ctx.options.headers.set('idempotency-key', key);
},
},
});
const billing = api.extend({ baseURL: 'https://billing.example.com' }, { plugins: [idempotency] });
await billing('/invoices', { method: 'POST', body: { amount: 100 } });
```
---
## API reference
### `$fetch(request, options?)` / `createFetch(globalOptions?)`
| Option | Type | Description |
| ------------------- | ------------------------------------- | ----------- |
| `baseURL` | `string` | Prepended to relative request URLs. |
| `query` | `Record<string, …>` | Serialized and appended to the URL. |
| `body` | `RequestInit['body'] \| object \| array` | Objects are JSON-serialized. |
| `responseType` | `'json' \| 'text' \| 'blob' \| 'arrayBuffer' \| 'stream'` | Forces the parse mode. |
| `parseResponse` | `(text: string) => T` | Custom body parser. |
| `ignoreResponseError` | `boolean` | Don't throw on `4xx`/`5xx`. |
| `retry` | `number \| false` | Retry attempts on failure. |
| `retryDelay` | `number \| (ctx) => number` | Delay between retries. |
| `retryStatusCodes` | `readonly number[]` | Status codes that trigger a retry. |
| `timeout` | `number` | Per-attempt timeout in ms. |
| `onRequest` / `onResponse` / `onRequestError` / `onResponseError` | hook(s) | Lifecycle callbacks. |
…plus every native `RequestInit` field (`headers`, `method`, `signal`, `credentials`, …).
### Instance members
| Member | Description |
| ----------------- | ----------- |
| `$fetch(req, o?)` | Returns the parsed body. |
| `$fetch.raw(req, o?)` | Returns the full `Response` with a parsed `_data`. |
| `$fetch.get/post/put/patch/delete/head` | Method shortcuts. |
| `$fetch.create(defaults?, globalOptions?)` | New instance with merged config. |
| `$fetch.extend(...)` | Alias for `create`. |
| `$fetch.native` | The underlying `globalThis.fetch`. |
---
## License
Apache-2.0 © Robonen Andrew
+3
View File
@@ -0,0 +1,3 @@
import { base, compose, imports, stylistic, typescript } from '@robonen/eslint';
export default compose(base, typescript, imports, stylistic);
-4
View File
@@ -1,4 +0,0 @@
import { base, compose, imports, stylistic, typescript } from '@robonen/oxlint';
import { defineConfig } from 'oxlint';
export default defineConfig(compose(base, typescript, imports, stylistic));
+4 -5
View File
@@ -36,8 +36,8 @@
}
},
"scripts": {
"lint:check": "oxlint -c oxlint.config.ts",
"lint:fix": "oxlint -c oxlint.config.ts --fix",
"lint:check": "eslint .",
"lint:fix": "eslint . --fix",
"test": "vitest run",
"dev": "vitest dev",
"build": "tsdown"
@@ -46,11 +46,10 @@
"@robonen/stdlib": "workspace:*"
},
"devDependencies": {
"@robonen/oxlint": "workspace:*",
"@robonen/eslint": "workspace:*",
"@robonen/tsconfig": "workspace:*",
"@robonen/tsdown": "workspace:*",
"@stylistic/eslint-plugin": "catalog:",
"oxlint": "catalog:",
"eslint": "catalog:",
"tsdown": "catalog:"
}
}
+92
View File
@@ -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);
});
});
+214 -200
View File
@@ -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>>;
if (composedExecute === undefined) {
await runAttempt(context);
}
catch (err) {
const error = err instanceof FetchError ? err : createFetchError(context);
if (isFunction(Error.captureStackTrace)) {
Error.captureStackTrace(error, $fetchRaw);
else {
await composedExecute(context as unknown as FetchContext, () => runAttempt(context));
}
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);
},
},
);
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;
}
+7
View File
@@ -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,
+380
View File
@@ -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 }>();
});
});
+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);
}
}
+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();
},
});
}
+128 -16
View File
@@ -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
// --------------------------
+12
View File
@@ -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
View File
@@ -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}`;
}
/**
+5 -1
View File
@@ -1,3 +1,7 @@
{
"extends": "@robonen/tsconfig/tsconfig.json"
"files": [],
"references": [
{ "path": "./tsconfig.src.json" },
{ "path": "./tsconfig.node.json" }
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@robonen/tsconfig/tsconfig.node.json",
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
},
"include": ["*.config.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@robonen/tsconfig/tsconfig.base.json",
"compilerOptions": {
"composite": true,
"types": ["node"],
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.src.tsbuildinfo"
},
"include": ["src/**/*.ts"]
}
+1
View File
@@ -3,5 +3,6 @@ import { sharedConfig } from '@robonen/tsdown';
export default defineConfig({
...sharedConfig,
tsconfig: './tsconfig.src.json',
entry: ['src/index.ts'],
});