From a7e668ced835d8873bfe566c6c4cae3dbc7664f7 Mon Sep 17 00:00:00 2001 From: robonen Date: Sun, 7 Jun 2026 16:29:18 +0700 Subject: [PATCH] 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. --- core/fetch/README.md | 376 +++++++++++++++++++++++++++ core/fetch/eslint.config.ts | 3 + core/fetch/oxlint.config.ts | 4 - core/fetch/package.json | 9 +- core/fetch/src/fetch.test.ts | 92 +++++++ core/fetch/src/fetch.ts | 418 +++++++++++++++--------------- core/fetch/src/index.ts | 7 + core/fetch/src/plugin.test.ts | 380 +++++++++++++++++++++++++++ core/fetch/src/plugin.ts | 357 +++++++++++++++++++++++++ core/fetch/src/plugins/index.ts | 2 + core/fetch/src/plugins/retry.ts | 92 +++++++ core/fetch/src/plugins/timeout.ts | 54 ++++ core/fetch/src/types.ts | 144 ++++++++-- core/fetch/src/utils.test.ts | 12 + core/fetch/src/utils.ts | 18 +- core/fetch/tsconfig.json | 6 +- core/fetch/tsconfig.node.json | 8 + core/fetch/tsconfig.src.json | 9 + core/fetch/tsdown.config.ts | 1 + 19 files changed, 1759 insertions(+), 233 deletions(-) create mode 100644 core/fetch/README.md create mode 100644 core/fetch/eslint.config.ts delete mode 100644 core/fetch/oxlint.config.ts create mode 100644 core/fetch/src/plugin.test.ts create mode 100644 core/fetch/src/plugin.ts create mode 100644 core/fetch/src/plugins/index.ts create mode 100644 core/fetch/src/plugins/retry.ts create mode 100644 core/fetch/src/plugins/timeout.ts create mode 100644 core/fetch/tsconfig.node.json create mode 100644 core/fetch/tsconfig.src.json diff --git a/core/fetch/README.md b/core/fetch/README.md new file mode 100644 index 0000000..3d61378 --- /dev/null +++ b/core/fetch/README.md @@ -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('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('https://api.example.com/todos/1'); + +// POST a plain object β€” serialized to JSON, content-type set for you +const created = await $fetch('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('/users/1'); // Promise +const ids = await $fetch('/users/ids'); // Promise +``` + +### Method shortcuts + +```ts +await $fetch.get('/users/1'); +await $fetch.post('/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('/readme', { responseType: 'text' }); +const blob = await $fetch('/avatar.png', { responseType: 'blob' }); +const buf = await $fetch('/file', { responseType: 'arrayBuffer' }); +const sse = await $fetch('/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('/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) { + let current: Promise | 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` | 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 diff --git a/core/fetch/eslint.config.ts b/core/fetch/eslint.config.ts new file mode 100644 index 0000000..e703f35 --- /dev/null +++ b/core/fetch/eslint.config.ts @@ -0,0 +1,3 @@ +import { base, compose, imports, stylistic, typescript } from '@robonen/eslint'; + +export default compose(base, typescript, imports, stylistic); diff --git a/core/fetch/oxlint.config.ts b/core/fetch/oxlint.config.ts deleted file mode 100644 index f5f700f..0000000 --- a/core/fetch/oxlint.config.ts +++ /dev/null @@ -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)); diff --git a/core/fetch/package.json b/core/fetch/package.json index 61cca83..e9d09c6 100644 --- a/core/fetch/package.json +++ b/core/fetch/package.json @@ -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:" } } diff --git a/core/fetch/src/fetch.test.ts b/core/fetch/src/fetch.test.ts index 89d453b..9c0cf44 100644 --- a/core/fetch/src/fetch.test.ts +++ b/core/fetch/src/fetch.test.ts @@ -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); + }); }); diff --git a/core/fetch/src/fetch.ts b/core/fetch/src/fetch.ts index 9034b00..bd46d92 100644 --- a/core/fetch/src/fetch.ts +++ b/core/fetch/src/fetch.ts @@ -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, + options: ResolvedFetchOptions, + method: string, +): Promise { + 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 = /* @__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, 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).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(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 = /* @__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( + globalOptions: CreateFetchOptions = {}, +): $Fetch { 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(context: FetchContext): Promise> { - // Actual fetch call + async function runAttempt(context: FetchContext): Promise { + 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 & { error: Error }>> | undefined, + options.onRequestError, context as FetchContext & { 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, options as unknown as ResolvedFetchOptions, 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 & { response: FetchResponse }>> | undefined, + options.onResponse, context as FetchContext & { response: FetchResponse }, - 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 & { response: FetchResponse }>> | undefined, + options.onResponseError, context as FetchContext & { response: FetchResponse }, - context.options.onResponseError, ); } throw createFetchError(context); } - - return context.response; } // ------------------------------------------------------------------------- @@ -138,149 +224,69 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch { _request: FetchRequest, _options?: FetchOptions, ): Promise>> { + // Fixed key order β†’ single hidden class for FetchContext across all requests const context: FetchContext = { 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>> | 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, - ).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, 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>; - } - 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>; - } - 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) => 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>; } 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> { const response = await $fetchRaw(request, options); return response._data as MappedResponseType; - } as $Fetch; + } as $Fetch; $fetch.raw = $fetchRaw; - $fetch.native = (...args: Parameters) => fetchImpl(...args); + $fetch.native = fetchImpl as Fetch; - $fetch.create = (defaults: FetchOptions = {}, customGlobalOptions: CreateFetchOptions = {}) => - createFetch({ - ...globalOptions, - ...customGlobalOptions, + $fetch.create = (( + defaults: FetchOptions = {}, + customGlobalOptions: CreateFetchOptions = {}, + ) => + 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['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; + $fetch.get = ((req, opt) => $fetch(req, withMethod(opt, 'GET') as ShortcutOptions)) as $Fetch['get']; + $fetch.post = ((req, opt) => $fetch(req, withMethod(opt, 'POST') as ShortcutOptions)) as $Fetch['post']; + $fetch.put = ((req, opt) => $fetch(req, withMethod(opt, 'PUT') as ShortcutOptions)) as $Fetch['put']; + $fetch.patch = ((req, opt) => $fetch(req, withMethod(opt, 'PATCH') as ShortcutOptions)) as $Fetch['patch']; + $fetch.delete = ((req, opt) => $fetch(req, withMethod(opt, 'DELETE') as ShortcutOptions)) as $Fetch['delete']; + $fetch.head = ((req, opt) => $fetchRaw(req, withMethod(opt, 'HEAD') as ShortcutOptions)) as $Fetch['head']; return $fetch; } diff --git a/core/fetch/src/index.ts b/core/fetch/src/index.ts index 72a739a..f988fe5 100644 --- a/core/fetch/src/index.ts +++ b/core/fetch/src/index.ts @@ -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, diff --git a/core/fetch/src/plugin.test.ts b/core/fetch/src/plugin.test.ts new file mode 100644 index 0000000..182f620 --- /dev/null +++ b/core/fetch/src/plugin.test.ts @@ -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().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().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[1]; + expectTypeOf().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().toMatchTypeOf<{ request: unknown }>(); + }); +}); diff --git a/core/fetch/src/plugin.ts b/core/fetch/src/plugin.ts new file mode 100644 index 0000000..ea4bc19 --- /dev/null +++ b/core/fetch/src/plugin.ts @@ -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 Bearer token injection with typed per-request override + * const auth = definePlugin<'auth', { token?: string }>({ + * name: 'auth', + * hooks: { + * onRequest: (ctx) => { + * const token = (ctx.options as { token?: string }).token; + * if (token !== undefined) ctx.options.headers.set('authorization', `Bearer ${token}`); + * }, + * }, + * }); + * + * const api = createFetch({ plugins: [auth] }); + * await api('/me', { token: 'xyz' }); + * + * @example Auto-refresh on 401 using a shared factory closure + * function createAuthPlugin(getAccessToken: () => Promise) { + * let current: Promise | 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 Idempotency-Key for unsafe methods + * const idempotency = definePlugin<'idempotency', { idempotencyKey?: string }>({ + * name: 'idempotency', + * hooks: { + * onRequest: (ctx) => { + * const method = (ctx.options.method ?? 'GET').toUpperCase(); + * if (method === 'GET' || method === 'HEAD') return; + * const key = (ctx.options as { idempotencyKey?: string }).idempotencyKey ?? crypto.randomUUID(); + * ctx.options.headers.set('idempotency-key', key); + * }, + * }, + * }); + * + * @example Response envelope unwrapping β€” { data, meta } β†’ data + * interface Envelope { readonly data: T; readonly meta?: Record } + * + * const unwrap = definePlugin({ + * name: 'unwrap', + * hooks: { + * onResponse: (ctx) => { + * const body = ctx.response._data as Envelope | undefined; + * if (body !== undefined && typeof body === 'object' && 'data' in body) { + * ctx.response._data = body.data; + * } + * }, + * }, + * }); + * + * @example Timing + structured logger using WeakMap-keyed state + * function createLoggerPlugin(sink: (record: { url: string; status: number; ms: number }) => void) { + * const started = new WeakMap(); + * + * 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 Request ID / correlation header + * const requestId = definePlugin<'requestId', { requestId?: string }>({ + * name: 'requestId', + * hooks: { + * onRequest: (ctx) => { + * const id = (ctx.options as { requestId?: string }).requestId ?? crypto.randomUUID(); + * ctx.options.headers.set('x-request-id', id); + * }, + * }, + * }); + * + * @example Composing multiple plugins β€” order matters + * // Hooks execute in registration order, then any user per-request hook runs last. + * // Here: requestId β†’ auth β†’ logger β†’ user-provided onRequest. + * const api = createFetch({ + * plugins: [requestId, createAuthPlugin(fetchToken), createLoggerPlugin(console.log), unwrap], + * defaults: { baseURL: 'https://api.example.com' }, + * }); + * + * // Per-domain instance inherits every parent plugin and may add its own. + * const billing = api.extend({ baseURL: 'https://billing.example.com' }, { + * plugins: [idempotency], + * }); + * await billing('/invoices', { method: 'POST', body: { amount: 100 } }); + * + * @since 0.1.0 + */ +export function definePlugin< + const Name extends string, + OptionsExt = unknown, + ContextExt = unknown, +>( + plugin: FetchPlugin, +): FetchPlugin { + 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 | 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 | Array<[string, string]>); + headers.forEach((value, key) => { + target.set(key, value); + }); +} + +function pushHook( + target: Array>, + source: FetchHook | ReadonlyArray> | 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 => { + 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( + instance: ReadonlyArray> | undefined, + user: FetchHook | ReadonlyArray> | undefined, + context: C, +): Promise { + 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); + } +} diff --git a/core/fetch/src/plugins/index.ts b/core/fetch/src/plugins/index.ts new file mode 100644 index 0000000..b50a773 --- /dev/null +++ b/core/fetch/src/plugins/index.ts @@ -0,0 +1,2 @@ +export { retryPlugin } from './retry'; +export { timeoutPlugin } from './timeout'; diff --git a/core/fetch/src/plugins/retry.ts b/core/fetch/src/plugins/retry.ts new file mode 100644 index 0000000..196e521 --- /dev/null +++ b/core/fetch/src/plugins/retry.ts @@ -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 = /* @__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), + }, + ); + }, + }); +} diff --git a/core/fetch/src/plugins/timeout.ts b/core/fetch/src/plugins/timeout.ts new file mode 100644 index 0000000..2806ba2 --- /dev/null +++ b/core/fetch/src/plugins/timeout.ts @@ -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(); + +/** + * @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(); + }, + }); +} diff --git a/core/fetch/src/types.ts b/core/fetch/src/types.ts index 017b900..9219240 100644 --- a/core/fetch/src/types.ts +++ b/core/fetch/src/types.ts @@ -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 { ( request: FetchRequest, - options?: FetchOptions, + options?: FetchOptions & MergePluginOptions, ): Promise>; raw( request: FetchRequest, - options?: FetchOptions, + options?: FetchOptions & MergePluginOptions, ): Promise>>; /** 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( + defaults?: FetchOptions & MergePluginOptions<[...Plugins, ...NewPlugins]>, + globalOptions?: CreateFetchOptions, + ): $Fetch<[...Plugins, ...NewPlugins]>; + /** Alias for create β€” extend this instance with new defaults and (optionally) additional plugins */ + extend( + defaults?: FetchOptions & MergePluginOptions<[...Plugins, ...NewPlugins]>, + globalOptions?: CreateFetchOptions, + ): $Fetch<[...Plugins, ...NewPlugins]>; /** Shorthand for GET requests */ get( request: FetchRequest, - options?: Omit, 'method'>, + options?: Omit, 'method'> & MergePluginOptions, ): Promise>; /** Shorthand for POST requests */ post( request: FetchRequest, - options?: Omit, 'method'>, + options?: Omit, 'method'> & MergePluginOptions, ): Promise>; /** Shorthand for PUT requests */ put( request: FetchRequest, - options?: Omit, 'method'>, + options?: Omit, 'method'> & MergePluginOptions, ): Promise>; /** Shorthand for PATCH requests */ patch( request: FetchRequest, - options?: Omit, 'method'>, + options?: Omit, 'method'> & MergePluginOptions, ): Promise>; /** Shorthand for DELETE requests */ delete( request: FetchRequest, - options?: Omit, 'method'>, + options?: Omit, 'method'> & MergePluginOptions, ): Promise>; /** Shorthand for HEAD requests */ head( request: FetchRequest, - options?: Omit, + options?: Omit & MergePluginOptions, ): Promise>; } @@ -117,14 +126,117 @@ export interface ResolvedFetchOptions { /** Default options merged into every request */ - defaults?: FetchOptions; + defaults?: FetchOptions & MergePluginOptions; /** 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, +) => Promise; + +/** + * @name MergePluginOptions + * @category Fetch + * @description Intersection of all `OptionsExt` carried by a plugin tuple. Empty tuple resolves to `unknown`. + */ +export type MergePluginOptions + = [Plugins[number]] extends [never] + ? unknown + : UnionToIntersection>; + +/** + * @name MergePluginContext + * @category Fetch + * @description Intersection of all `ContextExt` carried by a plugin tuple. Empty tuple resolves to `unknown`. + */ +export type MergePluginContext + = [Plugins[number]] extends [never] + ? unknown + : UnionToIntersection>; + +type PluginOptionsOf

= P extends FetchPlugin ? O : unknown; +type PluginContextOf

= P extends FetchPlugin ? C : unknown; + // -------------------------- // Hooks and Context // -------------------------- diff --git a/core/fetch/src/utils.test.ts b/core/fetch/src/utils.test.ts index 63e79d3..1d03b70 100644 --- a/core/fetch/src/utils.test.ts +++ b/core/fetch/src/utils.test.ts @@ -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', + ); + }); }); // --------------------------------------------------------------------------- diff --git a/core/fetch/src/utils.ts b/core/fetch/src/utils.ts index 8f0a6c5..f9ea266 100644 --- a/core/fetch/src/utils.ts +++ b/core/fetch/src/utils.ts @@ -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).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).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}`; } /** diff --git a/core/fetch/tsconfig.json b/core/fetch/tsconfig.json index ab255ac..2781e66 100644 --- a/core/fetch/tsconfig.json +++ b/core/fetch/tsconfig.json @@ -1,3 +1,7 @@ { - "extends": "@robonen/tsconfig/tsconfig.json" + "files": [], + "references": [ + { "path": "./tsconfig.src.json" }, + { "path": "./tsconfig.node.json" } + ] } diff --git a/core/fetch/tsconfig.node.json b/core/fetch/tsconfig.node.json new file mode 100644 index 0000000..edc474f --- /dev/null +++ b/core/fetch/tsconfig.node.json @@ -0,0 +1,8 @@ +{ + "extends": "@robonen/tsconfig/tsconfig.node.json", + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo" + }, + "include": ["*.config.ts"] +} diff --git a/core/fetch/tsconfig.src.json b/core/fetch/tsconfig.src.json new file mode 100644 index 0000000..04b066e --- /dev/null +++ b/core/fetch/tsconfig.src.json @@ -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"] +} diff --git a/core/fetch/tsdown.config.ts b/core/fetch/tsdown.config.ts index ae9657f..6e391e5 100644 --- a/core/fetch/tsdown.config.ts +++ b/core/fetch/tsdown.config.ts @@ -3,5 +3,6 @@ import { sharedConfig } from '@robonen/tsdown'; export default defineConfig({ ...sharedConfig, + tsconfig: './tsconfig.src.json', entry: ['src/index.ts'], });