1
0
mirror of https://github.com/robonen/tools.git synced 2026-03-20 02:44:45 +00:00

feat: add @robonen/fetch package - lightweight fetch wrapper with V8 optimizations

Co-authored-by: robonen <26167508+robonen@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-13 17:09:49 +00:00
parent 11f823afb4
commit 170093a039
16 changed files with 1919 additions and 0 deletions

6
core/fetch/jsr.json Normal file
View File

@@ -0,0 +1,6 @@
{
"$schema": "https://jsr.io/schema/config-file.v1.json",
"name": "@robonen/fetch",
"version": "0.0.1",
"exports": "./src/index.ts"
}

View File

@@ -0,0 +1,4 @@
import { defineConfig } from 'oxlint';
import { base, compose, imports, stylistic, typescript } from '@robonen/oxlint';
export default defineConfig(compose(base, typescript, imports, stylistic));

53
core/fetch/package.json Normal file
View File

@@ -0,0 +1,53 @@
{
"name": "@robonen/fetch",
"version": "0.0.1",
"license": "Apache-2.0",
"description": "A lightweight, type-safe fetch wrapper with interceptors, retry, and V8-optimized internals",
"keywords": [
"fetch",
"http",
"request",
"tools"
],
"author": "Robonen Andrew <robonenandrew@gmail.com>",
"repository": {
"type": "git",
"url": "git+https://github.com/robonen/tools.git",
"directory": "core/fetch"
},
"packageManager": "pnpm@10.30.3",
"engines": {
"node": ">=24.13.1"
},
"type": "module",
"files": [
"dist"
],
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
},
"scripts": {
"lint:check": "oxlint -c oxlint.config.ts",
"lint:fix": "oxlint -c oxlint.config.ts --fix",
"test": "vitest run",
"dev": "vitest dev",
"build": "tsdown"
},
"devDependencies": {
"@robonen/oxlint": "workspace:*",
"@robonen/tsconfig": "workspace:*",
"@robonen/tsdown": "workspace:*",
"@stylistic/eslint-plugin": "catalog:",
"oxlint": "catalog:",
"tsdown": "catalog:"
}
}

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import { FetchError, createFetchError } from './error';
import type { FetchContext } from './types';
function makeContext(overrides: Partial<FetchContext> = {}): FetchContext {
return {
request: 'https://example.com/api',
options: { headers: new Headers() },
response: undefined,
error: undefined,
...overrides,
} as FetchContext;
}
describe('FetchError', () => {
it('is an instance of Error', () => {
const err = new FetchError('oops');
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(FetchError);
});
it('has name "FetchError"', () => {
expect(new FetchError('x').name).toBe('FetchError');
});
it('preserves the message', () => {
expect(new FetchError('something went wrong').message).toBe('something went wrong');
});
});
describe('createFetchError', () => {
it('includes the request URL in the message', () => {
const err = createFetchError(makeContext());
expect(err.message).toContain('https://example.com/api');
});
it('appends status information when a response is present', () => {
const response = new Response('', { status: 404, statusText: 'Not Found' });
const err = createFetchError(makeContext({ response: response as never }));
expect(err.message).toContain('404');
expect(err.message).toContain('Not Found');
expect(err.status).toBe(404);
expect(err.statusCode).toBe(404);
expect(err.statusText).toBe('Not Found');
expect(err.statusMessage).toBe('Not Found');
});
it('appends the underlying error message when present', () => {
const networkErr = new Error('Failed to fetch');
const err = createFetchError(makeContext({ error: networkErr }));
expect(err.message).toContain('Failed to fetch');
});
it('populates response._data as data', () => {
const response = Object.assign(new Response('', { status: 422 }), { _data: { code: 42 } });
const err = createFetchError(makeContext({ response: response as never }));
expect(err.data).toEqual({ code: 42 });
});
it('works with a URL object as request', () => {
const ctx = makeContext({ request: new URL('https://example.com/test') });
const err = createFetchError(ctx);
expect(err.message).toContain('https://example.com/test');
});
it('works with a Request object as request', () => {
const ctx = makeContext({ request: new Request('https://example.com/req') });
const err = createFetchError(ctx);
expect(err.message).toContain('https://example.com/req');
});
});

70
core/fetch/src/error.ts Normal file
View File

@@ -0,0 +1,70 @@
import type { FetchContext, FetchOptions, FetchRequest, FetchResponse, IFetchError } from './types';
/**
* @name FetchError
* @category Fetch
* @description Error thrown by $fetch on network failures or non-2xx responses
*
* @since 0.0.1
*/
export class FetchError<T = unknown> extends Error implements IFetchError<T> {
request?: FetchRequest;
options?: FetchOptions;
response?: FetchResponse<T>;
data?: T;
status?: number;
statusText?: string;
statusCode?: number;
statusMessage?: string;
constructor(message: string) {
super(message);
this.name = 'FetchError';
}
}
/**
* @name createFetchError
* @category Fetch
* @description Builds a FetchError from a FetchContext, extracting URL, status, and error message
*
* @param {FetchContext} context - The context at the point of failure
* @returns {FetchError} A populated FetchError instance
*
* @since 0.0.1
*/
export function createFetchError<T = unknown>(context: FetchContext<T>): FetchError<T> {
const url
= typeof context.request === 'string'
? context.request
: context.request instanceof URL
? context.request.href
: (context.request as Request).url;
const statusPart = context.response
? `${context.response.status} ${context.response.statusText}`
: '';
const errorPart = context.error?.message ?? '';
// Build message from non-empty parts
let message = url;
if (statusPart) message += ` ${statusPart}`;
if (errorPart) message += `: ${errorPart}`;
const error = new FetchError<T>(message);
error.request = context.request;
error.options = context.options;
if (context.response !== undefined) {
error.response = context.response;
error.data = context.response._data;
error.status = context.response.status;
error.statusText = context.response.statusText;
error.statusCode = context.response.status;
error.statusMessage = context.response.statusText;
}
return error;
}

View File

@@ -0,0 +1,526 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FetchError } from './error';
import { createFetch } from './fetch';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeFetchMock(
body: unknown = { ok: true },
init: ResponseInit = { status: 200 },
contentType = 'application/json',
): ReturnType<typeof vi.fn> {
return vi.fn().mockResolvedValue(
new Response(typeof body === 'string' ? body : JSON.stringify(body), {
...init,
headers: { 'content-type': contentType, ...init.headers },
}),
);
}
// ---------------------------------------------------------------------------
// Basic fetch
// ---------------------------------------------------------------------------
describe('createFetch — basic', () => {
it('returns parsed JSON body', async () => {
const fetchMock = makeFetchMock({ id: 1 });
const $fetch = createFetch({ fetch: fetchMock });
const data = await $fetch<{ id: number }>('https://api.example.com/user');
expect(data).toEqual({ id: 1 });
expect(fetchMock).toHaveBeenCalledOnce();
});
it('passes options through to the underlying fetch', async () => {
const fetchMock = makeFetchMock({ done: true });
const $fetch = createFetch({ fetch: fetchMock });
await $fetch('https://api.example.com/task', {
method: 'POST',
headers: { 'x-token': 'abc' },
});
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect((init.headers as Headers).get('x-token')).toBe('abc');
expect(init.method).toBe('POST');
});
it('uppercases the HTTP method', async () => {
const fetchMock = makeFetchMock({});
const $fetch = createFetch({ fetch: fetchMock });
await $fetch('https://api.example.com', { method: 'post' });
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.method).toBe('POST');
});
});
// ---------------------------------------------------------------------------
// raw
// ---------------------------------------------------------------------------
describe('$fetch.raw', () => {
it('returns a Response with _data', async () => {
const fetchMock = makeFetchMock({ value: 42 });
const $fetch = createFetch({ fetch: fetchMock });
const response = await $fetch.raw<{ value: number }>('https://api.example.com');
expect(response).toBeInstanceOf(Response);
expect(response._data).toEqual({ value: 42 });
expect(response.status).toBe(200);
});
});
// ---------------------------------------------------------------------------
// Method shortcuts
// ---------------------------------------------------------------------------
describe('method shortcuts', () => {
it('$fetch.get sends a GET request', async () => {
const fetchMock = makeFetchMock({});
const $fetch = createFetch({ fetch: fetchMock });
await $fetch.get('https://api.example.com/items');
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.method).toBe('GET');
});
it('$fetch.post sends a POST request', async () => {
const fetchMock = makeFetchMock({});
const $fetch = createFetch({ fetch: fetchMock });
await $fetch.post('https://api.example.com/items', { body: { name: 'x' } });
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.method).toBe('POST');
});
it('$fetch.put sends a PUT request', async () => {
const fetchMock = makeFetchMock({});
const $fetch = createFetch({ fetch: fetchMock });
await $fetch.put('https://api.example.com/items/1', { body: { name: 'y' } });
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.method).toBe('PUT');
});
it('$fetch.patch sends a PATCH request', async () => {
const fetchMock = makeFetchMock({});
const $fetch = createFetch({ fetch: fetchMock });
await $fetch.patch('https://api.example.com/items/1', { body: { name: 'z' } });
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.method).toBe('PATCH');
});
it('$fetch.delete sends a DELETE request', async () => {
const fetchMock = makeFetchMock({});
const $fetch = createFetch({ fetch: fetchMock });
await $fetch.delete('https://api.example.com/items/1');
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.method).toBe('DELETE');
});
});
// ---------------------------------------------------------------------------
// baseURL
// ---------------------------------------------------------------------------
describe('baseURL', () => {
it('prepends baseURL to a relative path', async () => {
const fetchMock = makeFetchMock({});
const $fetch = createFetch({ fetch: fetchMock });
await $fetch('/users', { baseURL: 'https://api.example.com/v1' });
const [url] = fetchMock.mock.calls[0] as [string];
expect(url).toBe('https://api.example.com/v1/users');
});
it('inherits baseURL from create() defaults', async () => {
const fetchMock = makeFetchMock({});
const api = createFetch({ fetch: fetchMock }).create({ baseURL: 'https://api.example.com' });
await api('/health');
const [url] = fetchMock.mock.calls[0] as [string];
expect(url).toBe('https://api.example.com/health');
});
});
// ---------------------------------------------------------------------------
// Query params
// ---------------------------------------------------------------------------
describe('query params', () => {
it('appends query to the request URL', async () => {
const fetchMock = makeFetchMock([]);
const $fetch = createFetch({ fetch: fetchMock });
await $fetch('https://api.example.com/items', { query: { page: 2, limit: 10 } });
const [url] = fetchMock.mock.calls[0] as [string];
expect(url).toContain('page=2');
expect(url).toContain('limit=10');
});
it('merges default query with per-request query', async () => {
const fetchMock = makeFetchMock([]);
const $fetch = createFetch({ fetch: fetchMock }).create({
baseURL: 'https://api.example.com',
query: { version: 2 },
});
await $fetch('/items', { query: { page: 1 } });
const [url] = fetchMock.mock.calls[0] as [string];
expect(url).toContain('version=2');
expect(url).toContain('page=1');
});
});
// ---------------------------------------------------------------------------
// JSON body serialisation
// ---------------------------------------------------------------------------
describe('JSON body serialisation', () => {
it('serialises plain objects and sets content-type to application/json', async () => {
const fetchMock = makeFetchMock({ ok: true });
const $fetch = createFetch({ fetch: fetchMock });
await $fetch('https://api.example.com/users', {
method: 'POST',
body: { name: 'Alice' },
});
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.body).toBe('{"name":"Alice"}');
expect((init.headers as Headers).get('content-type')).toBe('application/json');
});
it('respects a pre-set content-type header', async () => {
const fetchMock = makeFetchMock({ ok: true });
const $fetch = createFetch({ fetch: fetchMock });
await $fetch('https://api.example.com/form', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: { key: 'value' },
});
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.body).toBe('key=value');
});
});
// ---------------------------------------------------------------------------
// Error handling
// ---------------------------------------------------------------------------
describe('error handling', () => {
it('throws FetchError on 4xx response', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response('{"error":"not found"}', {
status: 404,
statusText: 'Not Found',
headers: { 'content-type': 'application/json' },
}),
);
const $fetch = createFetch({ fetch: fetchMock });
await expect($fetch('https://api.example.com/missing')).rejects.toBeInstanceOf(FetchError);
});
it('throws FetchError on 5xx response', async () => {
// Use mockImplementation so each retry attempt gets a fresh Response (body not yet read)
const fetchMock = vi
.fn()
.mockImplementation(async () => new Response('Internal Server Error', { status: 500, statusText: 'Internal Server Error' }));
const $fetch = createFetch({ fetch: fetchMock });
await expect($fetch('https://api.example.com/crash')).rejects.toThrow(FetchError);
});
it('does not throw when ignoreResponseError is true', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response('{"error":"bad request"}', {
status: 400,
headers: { 'content-type': 'application/json' },
}),
);
const $fetch = createFetch({ fetch: fetchMock });
await expect(
$fetch('https://api.example.com/bad', { ignoreResponseError: true }),
).resolves.toEqual({ error: 'bad request' });
});
it('throws FetchError on network error', async () => {
const fetchMock = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
const $fetch = createFetch({ fetch: fetchMock });
await expect($fetch('https://api.example.com/offline')).rejects.toBeInstanceOf(FetchError);
});
});
// ---------------------------------------------------------------------------
// Retry
// ---------------------------------------------------------------------------
describe('retry', () => {
it('retries once on 500 by default for GET', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response('error', { status: 500 }))
.mockResolvedValueOnce(
new Response('{"ok":true}', {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
const $fetch = createFetch({ fetch: fetchMock });
const data = await $fetch('https://api.example.com/flaky');
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(data).toEqual({ ok: true });
});
it('does not retry POST by default', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response('error', { status: 500 }));
const $fetch = createFetch({ fetch: fetchMock });
await expect(
$fetch('https://api.example.com/task', { method: 'POST' }),
).rejects.toBeInstanceOf(FetchError);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('respects retry: false', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response('error', { status: 503 }));
const $fetch = createFetch({ fetch: fetchMock });
await expect(
$fetch('https://api.example.com/flaky', { retry: false }),
).rejects.toBeInstanceOf(FetchError);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('respects custom retryStatusCodes', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response('', { status: 418 }))
.mockResolvedValueOnce(
new Response('{"ok":true}', {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
const $fetch = createFetch({ fetch: fetchMock });
const data = await $fetch('https://api.example.com/teapot', {
retryStatusCodes: [418],
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(data).toEqual({ ok: true });
});
});
// ---------------------------------------------------------------------------
// Lifecycle hooks
// ---------------------------------------------------------------------------
describe('lifecycle hooks', () => {
it('calls onRequest before sending', async () => {
const fetchMock = makeFetchMock({});
const $fetch = createFetch({ fetch: fetchMock });
const calls: string[] = [];
await $fetch('https://api.example.com', {
onRequest: () => {
calls.push('request');
},
});
expect(calls).toContain('request');
expect(calls.indexOf('request')).toBeLessThan(1);
});
it('calls onResponse after a successful response', async () => {
const fetchMock = makeFetchMock({ data: 1 });
const $fetch = createFetch({ fetch: fetchMock });
const calls: string[] = [];
await $fetch('https://api.example.com', {
onResponse: () => {
calls.push('response');
},
});
expect(calls).toContain('response');
});
it('calls onResponseError for 4xx responses', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response('', { status: 401 }));
const $fetch = createFetch({ fetch: fetchMock });
const calls: string[] = [];
await expect(
$fetch('https://api.example.com/protected', {
retry: false,
onResponseError: () => {
calls.push('responseError');
},
}),
).rejects.toBeInstanceOf(FetchError);
expect(calls).toContain('responseError');
});
it('calls onRequestError on network failure', async () => {
const fetchMock = vi.fn().mockRejectedValue(new TypeError('Network error'));
const $fetch = createFetch({ fetch: fetchMock });
const calls: string[] = [];
await expect(
$fetch('https://api.example.com/offline', {
retry: false,
onRequestError: () => {
calls.push('requestError');
},
}),
).rejects.toBeInstanceOf(FetchError);
expect(calls).toContain('requestError');
});
it('supports multiple hooks as an array', async () => {
const fetchMock = makeFetchMock({});
const $fetch = createFetch({ fetch: fetchMock });
const calls: number[] = [];
await $fetch('https://api.example.com', {
onRequest: [
() => {
calls.push(1);
},
() => {
calls.push(2);
},
],
});
expect(calls).toEqual([1, 2]);
});
});
// ---------------------------------------------------------------------------
// create / extend
// ---------------------------------------------------------------------------
describe('create and extend', () => {
it('creates a new instance with merged defaults', async () => {
const fetchMock = makeFetchMock({});
const $fetch = createFetch({ fetch: fetchMock });
const api = $fetch.create({ baseURL: 'https://api.example.com' });
await api('/ping');
const [url] = fetchMock.mock.calls[0] as [string];
expect(url).toBe('https://api.example.com/ping');
});
it('extend is an alias for create', async () => {
const fetchMock = makeFetchMock({});
const $fetch = createFetch({ fetch: fetchMock });
const api = $fetch.extend({ baseURL: 'https://api.example.com' });
await api('/ping');
const [url] = fetchMock.mock.calls[0] as [string];
expect(url).toBe('https://api.example.com/ping');
});
});
// ---------------------------------------------------------------------------
// Response type variants
// ---------------------------------------------------------------------------
describe('response types', () => {
it('returns text when responseType is "text"', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(new Response('hello world', { headers: { 'content-type': 'text/plain' } }));
const $fetch = createFetch({ fetch: fetchMock });
const text = await $fetch<string, 'text'>('https://api.example.com/text', {
responseType: 'text',
});
expect(text).toBe('hello world');
});
it('returns a Blob when responseType is "blob"', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(new Response('binary', { headers: { 'content-type': 'image/png' } }));
const $fetch = createFetch({ fetch: fetchMock });
const blob = await $fetch<Blob, 'blob'>('https://api.example.com/img', {
responseType: 'blob',
});
expect(blob).toBeInstanceOf(Blob);
});
it('uses a custom parseResponse function', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(
new Response('{"value":10}', { headers: { 'content-type': 'application/json' } }),
);
const $fetch = createFetch({ fetch: fetchMock });
const data = await $fetch<{ value: number }>('https://api.example.com/custom', {
parseResponse: text => ({ ...JSON.parse(text) as object, custom: true }),
});
expect(data).toEqual({ value: 10, custom: true });
});
});
// ---------------------------------------------------------------------------
// Timeout
// ---------------------------------------------------------------------------
describe('timeout', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('aborts a request that exceeds the timeout', async () => {
// fetchMock that never resolves until the signal fires
const fetchMock = vi.fn().mockImplementation((_url: string, init: RequestInit) => {
return new Promise((_resolve, reject) => {
(init.signal as AbortSignal).addEventListener('abort', () => {
reject(new DOMException('The operation was aborted.', 'AbortError'));
});
});
});
const $fetch = createFetch({ fetch: fetchMock });
const promise = $fetch('https://api.example.com/slow', { timeout: 100, retry: false });
vi.advanceTimersByTime(200);
await expect(promise).rejects.toBeInstanceOf(FetchError);
});
});

325
core/fetch/src/fetch.ts Normal file
View File

@@ -0,0 +1,325 @@
import type { ResponseMap, $Fetch, CreateFetchOptions, FetchContext, FetchOptions, FetchRequest, FetchResponse, ResponseType } from './types';
import { createFetchError } from './error';
import {
NULL_BODY_STATUSES,
buildURL,
callHooks,
detectResponseType,
isJSONSerializable,
isPayloadMethod,
joinURL,
resolveFetchOptions,
} from './utils';
// ---------------------------------------------------------------------------
// V8: module-level Set — initialised once, never mutated, allows V8 to
// embed the set reference as a constant in compiled code.
// ---------------------------------------------------------------------------
/** HTTP status codes that trigger automatic retry by default */
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
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
]);
// ---------------------------------------------------------------------------
// createFetch
// ---------------------------------------------------------------------------
/**
* @name createFetch
* @category Fetch
* @description Creates a configured $fetch instance
*
* V8 optimisation notes:
* - All inner objects are created with a fixed property set so V8 can reuse
* their hidden class across invocations (no dynamic property additions).
* - `Error.captureStackTrace` is called only when available (V8 / Node.js)
* to produce clean stack traces without internal frames.
* - Retry and timeout paths avoid allocating closures on the hot path.
* - `NULL_BODY_STATUSES` / `DEFAULT_RETRY_STATUS_CODES` are frozen module-
* level Sets, so their `.has()` calls are always monomorphic.
*
* @param {CreateFetchOptions} [globalOptions={}] - Global defaults and custom fetch implementation
* @returns {$Fetch} Configured fetch instance
*
* @since 0.0.1
*/
export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch {
const fetchImpl = globalOptions.fetch ?? globalThis.fetch;
// -------------------------------------------------------------------------
// Error handler — shared between network errors and 4xx/5xx responses
// -------------------------------------------------------------------------
async function onError(context: FetchContext): Promise<FetchResponse<unknown>> {
// Explicit user-triggered abort should not be retried automatically
const isAbort
= context.error !== undefined
&& context.error.name === 'AbortError'
&& context.options.timeout === undefined;
if (!isAbort && context.options.retry !== false) {
// Default retry count: 0 for payload methods, 1 for idempotent methods
const maxRetries
= typeof context.options.retry === 'number'
? context.options.retry
: isPayloadMethod(context.options.method ?? 'GET')
? 0
: 1;
if (maxRetries > 0) {
const responseStatus = context.response?.status ?? 500;
const retryStatusCodes = context.options.retryStatusCodes;
const shouldRetry
= retryStatusCodes !== undefined
? retryStatusCodes.includes(responseStatus)
: DEFAULT_RETRY_STATUS_CODES.has(responseStatus);
if (shouldRetry) {
const retryDelay
= typeof context.options.retryDelay === 'function'
? context.options.retryDelay(context)
: (context.options.retryDelay ?? 0);
if (retryDelay > 0) {
await new Promise<void>((resolve) => {
setTimeout(resolve, retryDelay);
});
}
return $fetchRaw(context.request, {
...context.options,
retry: maxRetries - 1,
});
}
}
}
const error = createFetchError(context);
// V8 / Node.js — clip internal frames from the error stack trace
if (typeof Error.captureStackTrace === 'function') {
Error.captureStackTrace(error, $fetchRaw);
}
throw error;
}
// -------------------------------------------------------------------------
// $fetchRaw — returns the full Response object with a parsed `_data` field
// -------------------------------------------------------------------------
const $fetchRaw: $Fetch['raw'] = async function $fetchRaw<
T = unknown,
R extends ResponseType = 'json',
>(
_request: FetchRequest,
_options: FetchOptions<R, T> = {} as FetchOptions<R, T>,
): Promise<FetchResponse<T>> {
// V8: object literal with a fixed shape — V8 allocates a single hidden
// class for all context objects created by this function.
const context: FetchContext<T, R> = {
request: _request,
options: resolveFetchOptions(
_request,
_options,
globalOptions.defaults as FetchOptions<R, T>,
),
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();
}
if (context.options.onRequest !== undefined) {
await callHooks(context, context.options.onRequest);
}
// URL transformations — only when request is a plain string
if (typeof context.request === 'string') {
if (context.options.baseURL !== undefined) {
context.request = joinURL(context.options.baseURL, context.request);
}
const query = context.options.query ?? context.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 (typeof context.options.body !== 'string') {
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;
}
// Actual fetch call
try {
context.response = await fetchImpl(context.request, context.options as RequestInit);
}
catch (err) {
context.error = err as Error;
if (context.options.onRequestError !== undefined) {
await callHooks(
context as FetchContext<T, R> & { error: Error },
context.options.onRequestError,
);
}
return (await onError(context)) as FetchResponse<T>;
}
// Response body parsing
const hasBody
= context.response.body !== null
&& !NULL_BODY_STATUSES.has(context.response.status)
&& method !== 'HEAD';
if (hasBody) {
const responseType
= context.options.parseResponse !== undefined
? 'json'
: (context.options.responseType
?? detectResponseType(context.response.headers.get('content-type') ?? ''));
// V8: switch over a string constant — compiled to a jump table
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) as T);
}
break;
}
case 'stream': {
context.response._data = context.response.body as unknown as T;
break;
}
default: {
context.response._data = (await context.response[responseType]()) as T;
}
}
}
if (context.options.onResponse !== undefined) {
await callHooks(
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(
context as FetchContext<T, R> & { response: FetchResponse<T> },
context.options.onResponseError,
);
}
return (await onError(context)) as FetchResponse<T>;
}
return context.response;
};
// -------------------------------------------------------------------------
// $fetch — convenience wrapper that returns only the parsed data
// -------------------------------------------------------------------------
const $fetch = async function $fetch<T = unknown, R extends ResponseType = 'json'>(
request: FetchRequest,
options?: FetchOptions<R, T>,
): Promise<InferResponseType<R, T>> {
const response = await $fetchRaw<T, R>(request, options);
return response._data as InferResponseType<R, T>;
} as $Fetch;
$fetch.raw = $fetchRaw;
$fetch.native = (...args: Parameters<typeof fetchImpl>) => fetchImpl(...args);
$fetch.create = (defaults: FetchOptions = {}, customGlobalOptions: CreateFetchOptions = {}) =>
createFetch({
...globalOptions,
...customGlobalOptions,
defaults: {
...globalOptions.defaults,
...customGlobalOptions.defaults,
...defaults,
},
});
$fetch.extend = $fetch.create;
// -------------------------------------------------------------------------
// Method shortcuts
// -------------------------------------------------------------------------
$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' });
return $fetch;
}
/** Resolves the inferred return value type from a ResponseType key */
type InferResponseType<R extends ResponseType, T> = R extends keyof ResponseMap
? ResponseMap[R]
: T;

46
core/fetch/src/index.ts Normal file
View File

@@ -0,0 +1,46 @@
export { createFetch } from './fetch';
export { FetchError, createFetchError } from './error';
export {
isPayloadMethod,
isJSONSerializable,
detectResponseType,
buildURL,
joinURL,
callHooks,
resolveFetchOptions,
} from './utils';
export type {
$Fetch,
CreateFetchOptions,
Fetch,
FetchContext,
FetchHook,
FetchHooks,
FetchOptions,
FetchRequest,
FetchResponse,
IFetchError,
MappedResponseType,
MaybeArray,
MaybePromise,
ResponseMap,
ResponseType,
ResolvedFetchOptions,
} from './types';
/**
* @name $fetch
* @category Fetch
* @description Default $fetch instance backed by globalThis.fetch
*
* @example
* const data = await $fetch<User>('https://api.example.com/users/1');
*
* @example
* const user = await $fetch.post<User>('https://api.example.com/users', {
* body: { name: 'Alice' },
* });
*
* @since 0.0.1
*/
export const $fetch = createFetch();

237
core/fetch/src/types.ts Normal file
View File

@@ -0,0 +1,237 @@
// --------------------------
// Fetch API
// --------------------------
/**
* @name $Fetch
* @category Fetch
* @description The main fetch interface with method shortcuts, raw access, and factory methods
*/
export interface $Fetch {
<T = unknown, R extends ResponseType = 'json'>(
request: FetchRequest,
options?: FetchOptions<R, T>,
): Promise<MappedResponseType<R, T>>;
raw<T = unknown, R extends ResponseType = 'json'>(
request: FetchRequest,
options?: FetchOptions<R, T>,
): 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;
/** Shorthand for GET requests */
get<T = unknown, R extends ResponseType = 'json'>(
request: FetchRequest,
options?: Omit<FetchOptions<R, T>, 'method'>,
): Promise<MappedResponseType<R, T>>;
/** Shorthand for POST requests */
post<T = unknown, R extends ResponseType = 'json'>(
request: FetchRequest,
options?: Omit<FetchOptions<R, T>, 'method'>,
): Promise<MappedResponseType<R, T>>;
/** Shorthand for PUT requests */
put<T = unknown, R extends ResponseType = 'json'>(
request: FetchRequest,
options?: Omit<FetchOptions<R, T>, 'method'>,
): Promise<MappedResponseType<R, T>>;
/** Shorthand for PATCH requests */
patch<T = unknown, R extends ResponseType = 'json'>(
request: FetchRequest,
options?: Omit<FetchOptions<R, T>, 'method'>,
): Promise<MappedResponseType<R, T>>;
/** Shorthand for DELETE requests */
delete<T = unknown, R extends ResponseType = 'json'>(
request: FetchRequest,
options?: Omit<FetchOptions<R, T>, 'method'>,
): Promise<MappedResponseType<R, T>>;
/** Shorthand for HEAD requests */
head(
request: FetchRequest,
options?: Omit<FetchOptions<'text', never>, 'method'>,
): Promise<FetchResponse<never>>;
}
// --------------------------
// Options
// --------------------------
/**
* @name FetchOptions
* @category Fetch
* @description Options for a fetch request, extending native RequestInit with additional features
*/
export interface FetchOptions<R extends ResponseType = 'json', T = unknown>
extends Omit<RequestInit, 'body'>,
FetchHooks<T, R> {
/** Base URL prepended to all relative request URLs */
baseURL?: string;
/** Request body — plain objects are automatically JSON-serialized */
body?: BodyInit | Record<string, unknown> | unknown[] | null;
/** Suppress throwing on 4xx/5xx responses */
ignoreResponseError?: boolean;
/** URL query parameters serialized and appended to the request URL */
query?: Record<string, string | number | boolean | null | undefined>;
/**
* @deprecated use `query` instead
*/
params?: Record<string, string | number | boolean | null | undefined>;
/** Custom response parser — overrides built-in JSON.parse */
parseResponse?: (responseText: string) => T;
/** Expected response format — drives body parsing */
responseType?: R;
/**
* Enable duplex streaming.
* Automatically set to "half" when a ReadableStream is used as body.
* @see https://fetch.spec.whatwg.org/#enumdef-requestduplex
*/
duplex?: 'half';
/** Request timeout in milliseconds. Uses AbortSignal.timeout internally. */
timeout?: number;
/** Number of retry attempts on failure, or false to disable. Defaults to 1 for non-payload methods. */
retry?: number | false;
/** Delay in milliseconds between retries, or a function receiving the context */
retryDelay?: number | ((context: FetchContext<T, R>) => number);
/**
* HTTP status codes that trigger a retry.
* Defaults to [408, 409, 425, 429, 500, 502, 503, 504].
*/
retryStatusCodes?: readonly number[];
}
/**
* @name ResolvedFetchOptions
* @category Fetch
* @description FetchOptions after merging defaults — headers are always a Headers instance
*/
export interface ResolvedFetchOptions<R extends ResponseType = 'json', T = unknown>
extends FetchOptions<R, T> {
headers: Headers;
}
/**
* @name CreateFetchOptions
* @category Fetch
* @description Global options for createFetch
*/
export interface CreateFetchOptions {
/** Default options merged into every request */
defaults?: FetchOptions;
/** Custom fetch implementation — defaults to globalThis.fetch */
fetch?: Fetch;
}
// --------------------------
// Hooks and Context
// --------------------------
/**
* @name FetchContext
* @category Fetch
* @description Mutable context object passed to all hooks and the core fetch pipeline
*/
export interface FetchContext<T = unknown, R extends ResponseType = 'json'> {
request: FetchRequest;
options: ResolvedFetchOptions<R, T>;
response?: FetchResponse<T>;
error?: Error;
}
export type MaybePromise<T> = T | Promise<T>;
export type MaybeArray<T> = T | readonly T[];
/**
* @name FetchHook
* @category Fetch
* @description A function invoked at a specific point in the fetch lifecycle
*/
export type FetchHook<C extends FetchContext = FetchContext> = (context: C) => MaybePromise<void>;
/**
* @name FetchHooks
* @category Fetch
* @description Lifecycle hooks for the fetch pipeline
*/
export interface FetchHooks<T = unknown, R extends ResponseType = 'json'> {
/** Called before the request is sent */
onRequest?: MaybeArray<FetchHook<FetchContext<T, R>>>;
/** Called when the request itself throws (e.g. network error, timeout) */
onRequestError?: MaybeArray<FetchHook<FetchContext<T, R> & { error: Error }>>;
/** Called after a successful response is received and parsed */
onResponse?: MaybeArray<FetchHook<FetchContext<T, R> & { response: FetchResponse<T> }>>;
/** Called when the response status is 4xx or 5xx */
onResponseError?: MaybeArray<FetchHook<FetchContext<T, R> & { response: FetchResponse<T> }>>;
}
// --------------------------
// Response Types
// --------------------------
/**
* @name ResponseMap
* @category Fetch
* @description Maps response type keys to their parsed value types
*/
export interface ResponseMap {
blob: Blob;
text: string;
arrayBuffer: ArrayBuffer;
stream: ReadableStream<Uint8Array>;
}
/**
* @name ResponseType
* @category Fetch
* @description Supported response body parsing modes
*/
export type ResponseType = keyof ResponseMap | 'json';
/**
* @name MappedResponseType
* @category Fetch
* @description Resolves the response value type from a ResponseType key
*/
export type MappedResponseType<R extends ResponseType, T = unknown> = R extends keyof ResponseMap
? ResponseMap[R]
: T;
/**
* @name FetchResponse
* @category Fetch
* @description Extended Response with a parsed `_data` field
*/
export interface FetchResponse<T> extends Response {
_data?: T;
}
// --------------------------
// Error
// --------------------------
/**
* @name IFetchError
* @category Fetch
* @description Shape of errors thrown by $fetch
*/
export interface IFetchError<T = unknown> extends Error {
request?: FetchRequest;
options?: FetchOptions;
response?: FetchResponse<T>;
data?: T;
status?: number;
statusText?: string;
statusCode?: number;
statusMessage?: string;
}
// --------------------------
// Primitives
// --------------------------
/** The native fetch function signature */
export type Fetch = typeof globalThis.fetch;
/** A fetch request — URL string, URL object, or Request object */
export type FetchRequest = RequestInfo;

View File

@@ -0,0 +1,257 @@
import { describe, expect, it } from 'vitest';
import {
buildURL,
callHooks,
detectResponseType,
isJSONSerializable,
isPayloadMethod,
joinURL,
resolveFetchOptions,
} from './utils';
import type { FetchContext } from './types';
// ---------------------------------------------------------------------------
// isPayloadMethod
// ---------------------------------------------------------------------------
describe('isPayloadMethod', () => {
it('returns true for payload methods', () => {
expect(isPayloadMethod('POST')).toBe(true);
expect(isPayloadMethod('PUT')).toBe(true);
expect(isPayloadMethod('PATCH')).toBe(true);
expect(isPayloadMethod('DELETE')).toBe(true);
});
it('returns false for non-payload methods', () => {
expect(isPayloadMethod('GET')).toBe(false);
expect(isPayloadMethod('HEAD')).toBe(false);
expect(isPayloadMethod('OPTIONS')).toBe(false);
});
});
// ---------------------------------------------------------------------------
// isJSONSerializable
// ---------------------------------------------------------------------------
describe('isJSONSerializable', () => {
it('returns false for undefined', () => {
expect(isJSONSerializable(undefined)).toBe(false);
});
it('returns true for primitives', () => {
expect(isJSONSerializable('hello')).toBe(true);
expect(isJSONSerializable(42)).toBe(true);
expect(isJSONSerializable(true)).toBe(true);
expect(isJSONSerializable(null)).toBe(true);
});
it('returns false for functions, symbols, bigints', () => {
expect(isJSONSerializable(() => {})).toBe(false);
expect(isJSONSerializable(Symbol('x'))).toBe(false);
expect(isJSONSerializable(42n)).toBe(false);
});
it('returns true for plain arrays', () => {
expect(isJSONSerializable([1, 2, 3])).toBe(true);
});
it('returns false for ArrayBuffer-like values', () => {
expect(isJSONSerializable(new Uint8Array([1, 2]))).toBe(false);
});
it('returns false for FormData and URLSearchParams', () => {
expect(isJSONSerializable(new FormData())).toBe(false);
expect(isJSONSerializable(new URLSearchParams())).toBe(false);
});
it('returns true for plain objects', () => {
expect(isJSONSerializable({ a: 1 })).toBe(true);
});
it('returns true for objects with toJSON', () => {
expect(isJSONSerializable({ toJSON: () => ({}) })).toBe(true);
});
});
// ---------------------------------------------------------------------------
// detectResponseType
// ---------------------------------------------------------------------------
describe('detectResponseType', () => {
it('defaults to json when content-type is empty', () => {
expect(detectResponseType('')).toBe('json');
expect(detectResponseType()).toBe('json');
});
it('detects json content types', () => {
expect(detectResponseType('application/json')).toBe('json');
expect(detectResponseType('application/json; charset=utf-8')).toBe('json');
expect(detectResponseType('application/vnd.api+json')).toBe('json');
});
it('detects event-stream as stream', () => {
expect(detectResponseType('text/event-stream')).toBe('stream');
});
it('detects text content types', () => {
expect(detectResponseType('text/plain')).toBe('text');
expect(detectResponseType('text/html')).toBe('text');
expect(detectResponseType('application/xml')).toBe('text');
});
it('falls back to blob for binary types', () => {
expect(detectResponseType('image/png')).toBe('blob');
expect(detectResponseType('application/octet-stream')).toBe('blob');
});
});
// ---------------------------------------------------------------------------
// buildURL
// ---------------------------------------------------------------------------
describe('buildURL', () => {
it('appends query params to a clean URL', () => {
expect(buildURL('https://api.example.com', { page: 1, limit: 20 })).toBe(
'https://api.example.com?page=1&limit=20',
);
});
it('appends to an existing query string with &', () => {
expect(buildURL('https://api.example.com?foo=bar', { baz: 'qux' })).toBe(
'https://api.example.com?foo=bar&baz=qux',
);
});
it('omits null and undefined values', () => {
expect(buildURL('https://api.example.com', { a: null, b: undefined, c: 'keep' })).toBe(
'https://api.example.com?c=keep',
);
});
it('returns the URL unchanged when all params are omitted', () => {
expect(buildURL('https://api.example.com', { a: null })).toBe('https://api.example.com');
});
});
// ---------------------------------------------------------------------------
// joinURL
// ---------------------------------------------------------------------------
describe('joinURL', () => {
it('joins base and path correctly', () => {
expect(joinURL('https://api.example.com/v1', '/users')).toBe(
'https://api.example.com/v1/users',
);
});
it('does not double slashes', () => {
expect(joinURL('https://api.example.com/v1/', '/users')).toBe(
'https://api.example.com/v1/users',
);
});
it('adds a slash when neither side has one', () => {
expect(joinURL('https://api.example.com/v1', 'users')).toBe(
'https://api.example.com/v1/users',
);
});
it('returns base when path is empty', () => {
expect(joinURL('https://api.example.com', '')).toBe('https://api.example.com');
});
it('returns base when path is "/"', () => {
expect(joinURL('https://api.example.com', '/')).toBe('https://api.example.com');
});
});
// ---------------------------------------------------------------------------
// callHooks
// ---------------------------------------------------------------------------
describe('callHooks', () => {
function makeCtx(): FetchContext {
return {
request: 'https://example.com',
options: { headers: new Headers() },
response: undefined,
error: undefined,
} as FetchContext;
}
it('does nothing when hooks is undefined', async () => {
await expect(callHooks(makeCtx(), undefined)).resolves.toBeUndefined();
});
it('calls a single hook', async () => {
const calls: number[] = [];
await callHooks(makeCtx(), () => {
calls.push(1);
});
expect(calls).toEqual([1]);
});
it('calls an array of hooks in order', async () => {
const calls: number[] = [];
await callHooks(makeCtx(), [
() => { calls.push(1); },
() => { calls.push(2); },
() => { calls.push(3); },
]);
expect(calls).toEqual([1, 2, 3]);
});
it('awaits async hooks', async () => {
const calls: number[] = [];
await callHooks(makeCtx(), [
async () => {
await Promise.resolve();
calls.push(1);
},
() => {
calls.push(2);
},
]);
expect(calls).toEqual([1, 2]);
});
});
// ---------------------------------------------------------------------------
// resolveFetchOptions
// ---------------------------------------------------------------------------
describe('resolveFetchOptions', () => {
it('returns an object with a Headers instance', () => {
const resolved = resolveFetchOptions('https://example.com', undefined, undefined);
expect(resolved.headers).toBeInstanceOf(Headers);
});
it('merges input and default headers (input wins)', () => {
const resolved = resolveFetchOptions(
'https://example.com',
{ headers: { 'x-custom': 'input' } },
{ headers: { 'x-custom': 'default', 'x-default-only': 'yes' } },
);
expect(resolved.headers.get('x-custom')).toBe('input');
expect(resolved.headers.get('x-default-only')).toBe('yes');
});
it('merges query params from defaults and input', () => {
const resolved = resolveFetchOptions(
'https://example.com',
{ query: { a: '1' } },
{ query: { b: '2' } },
);
expect(resolved.query).toEqual({ a: '1', b: '2' });
});
it('merges params alias into query', () => {
const resolved = resolveFetchOptions(
'https://example.com',
{ params: { p: '10' } },
undefined,
);
expect(resolved.query).toEqual({ p: '10' });
expect(resolved.params).toEqual({ p: '10' });
});
});

285
core/fetch/src/utils.ts Normal file
View File

@@ -0,0 +1,285 @@
import type {
FetchContext,
FetchHook,
FetchOptions,
FetchRequest,
ResolvedFetchOptions,
ResponseType,
} from './types';
// ---------------------------------------------------------------------------
// V8 optimisation: module-level frozen Sets avoid per-call allocations and
// allow V8 to treat them as compile-time constants in hidden-class analysis.
// ---------------------------------------------------------------------------
/** HTTP methods whose requests carry a body */
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const PAYLOAD_METHODS: ReadonlySet<string> = /* @__PURE__ */ new Set(['PATCH', 'POST', 'PUT', 'DELETE']);
/** HTTP status codes whose responses never have a body */
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
export const NULL_BODY_STATUSES: ReadonlySet<number> = /* @__PURE__ */ new Set([101, 204, 205, 304]);
/** Content-types treated as plain text */
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const TEXT_CONTENT_TYPES: ReadonlySet<string> = /* @__PURE__ */ new Set([
'image/svg',
'application/xml',
'application/xhtml',
'application/html',
]);
/** V8: pre-compiled at module load — avoids per-call RegExp construction */
const JSON_CONTENT_TYPE_RE = /^application\/(?:[\w!#$%&*.^`~-]*\+)?json(;.+)?$/i;
// ---------------------------------------------------------------------------
// Predicate helpers
// ---------------------------------------------------------------------------
/**
* @name isPayloadMethod
* @category Fetch
* @description Returns true for HTTP methods that carry a request body
*
* V8: function is monomorphic — always called with an uppercase string.
*
* @param {string} method - Uppercase HTTP method string
* @returns {boolean}
*
* @since 0.0.1
*/
export function isPayloadMethod(method: string): boolean {
return PAYLOAD_METHODS.has(method);
}
/**
* @name isJSONSerializable
* @category Fetch
* @description Returns true when a value can be serialised with JSON.stringify
*
* V8: typeof checks are ordered from most-common to least-common to maximise
* the probability of an early return and keep the IC monomorphic.
*
* @param {unknown} value - Any value
* @returns {boolean}
*
* @since 0.0.1
*/
export function isJSONSerializable(value: unknown): boolean {
if (value === undefined) return false;
const type = typeof value;
// Fast path — primitives are always serialisable
if (type === 'string' || type === 'number' || type === 'boolean' || value === null) return true;
// Non-object types (bigint, function, symbol) are not serialisable
if (type !== 'object') return false;
// Arrays are serialisable
if (Array.isArray(value)) return true;
// TypedArrays / ArrayBuffers carry a .buffer property — not JSON-serialisable
if ((value as Record<string, unknown>).buffer !== undefined) return false;
// FormData and URLSearchParams should not be auto-serialised
if (value instanceof FormData || value instanceof URLSearchParams) return false;
// Plain objects or objects with a custom toJSON
const ctor = (value as object).constructor;
return (
ctor === undefined
|| ctor === Object
|| typeof (value as Record<string, unknown>).toJSON === 'function'
);
}
// ---------------------------------------------------------------------------
// Response type detection
// ---------------------------------------------------------------------------
/**
* @name detectResponseType
* @category Fetch
* @description Infers the response body parsing strategy from a Content-Type header value
*
* @param {string} [contentType] - Value of the Content-Type response header
* @returns {ResponseType}
*
* @since 0.0.1
*/
export function detectResponseType(contentType = ''): ResponseType {
if (!contentType) return 'json';
// V8: split once and reuse — avoids calling split multiple times
const type = contentType.split(';')[0] ?? '';
if (JSON_CONTENT_TYPE_RE.test(type)) return 'json';
if (type === 'text/event-stream') return 'stream';
if (TEXT_CONTENT_TYPES.has(type) || type.startsWith('text/')) return 'text';
return 'blob';
}
// ---------------------------------------------------------------------------
// URL helpers
// ---------------------------------------------------------------------------
/**
* @name buildURL
* @category Fetch
* @description Appends serialised query parameters to a URL string
*
* Null and undefined values are omitted. Existing query strings are preserved.
*
* @param {string} url - Base URL (may already contain a query string)
* @param {Record<string, string | number | boolean | null | undefined>} query - Parameters to append
* @returns {string} URL with query string
*
* @since 0.0.1
*/
export function buildURL(
url: string,
query: Record<string, string | number | boolean | null | undefined>,
): string {
const params = new URLSearchParams();
for (const key of Object.keys(query)) {
const value = query[key];
if (value !== null && value !== undefined) {
params.append(key, String(value));
}
}
const qs = params.toString();
if (!qs) return url;
return url.includes('?') ? `${url}&${qs}` : `${url}?${qs}`;
}
/**
* @name joinURL
* @category Fetch
* @description Joins a base URL with a relative path, normalising the slash boundary
*
* @param {string} base - Base URL (e.g. "https://api.example.com/v1")
* @param {string} path - Relative path (e.g. "/users")
* @returns {string} Joined URL
*
* @since 0.0.1
*/
export function joinURL(base: string, path: string): string {
if (!path || path === '/') return base;
const baseEnds = base.endsWith('/');
const pathStarts = path.startsWith('/');
if (baseEnds && pathStarts) return `${base}${path.slice(1)}`;
if (!baseEnds && !pathStarts) return `${base}/${path}`;
return `${base}${path}`;
}
// ---------------------------------------------------------------------------
// Options resolution
// ---------------------------------------------------------------------------
/**
* @name resolveFetchOptions
* @category Fetch
* @description Merges per-request options with global defaults
*
* V8: the returned object always has the same property set (fixed shape),
* which lets V8 reuse its hidden class across all calls.
*
* @since 0.0.1
*/
export function resolveFetchOptions<R extends ResponseType = 'json', T = unknown>(
request: FetchRequest,
input: FetchOptions<R, T> | undefined,
defaults: FetchOptions<R, T> | undefined,
): ResolvedFetchOptions<R, T> {
const headers = mergeHeaders(
input?.headers ?? (request as Request)?.headers,
defaults?.headers,
);
let query: Record<string, string | number | boolean | null | undefined> | undefined;
if (
defaults?.query !== undefined
|| defaults?.params !== undefined
|| input?.params !== undefined
|| input?.query !== undefined
) {
query = {
...defaults?.params,
...defaults?.query,
...input?.params,
...input?.query,
};
}
return {
...defaults,
...input,
query,
params: query,
headers,
};
}
/**
* Merge two HeadersInit sources into a single Headers instance.
* Input headers override default headers.
*
* V8: avoids constructing an intermediate Headers when defaults are absent.
*/
function mergeHeaders(
input: HeadersInit | undefined,
defaults: HeadersInit | undefined,
): Headers {
if (defaults === undefined) {
return new Headers(input);
}
const merged = new Headers(defaults);
if (input !== undefined) {
const src = input instanceof Headers ? input : new Headers(input);
for (const [key, value] of src) {
merged.set(key, value);
}
}
return merged;
}
// ---------------------------------------------------------------------------
// Hook dispatch
// ---------------------------------------------------------------------------
/**
* @name callHooks
* @category Fetch
* @description Invokes one or more lifecycle hooks with the given context
*
* V8: the single-hook path avoids Array creation; the Array path uses a
* for-loop with a cached length to stay monomorphic inside the loop body.
*
* @since 0.0.1
*/
export async function callHooks<C extends FetchContext = FetchContext>(
context: C,
hooks: FetchHook<C> | readonly FetchHook<C>[] | undefined,
): Promise<void> {
if (hooks === undefined) return;
if (Array.isArray(hooks)) {
const len = hooks.length;
for (let i = 0; i < len; i++) {
await (hooks as Array<FetchHook<C>>)[i]!(context);
}
}
else {
await (hooks as FetchHook<C>)(context);
}
}

3
core/fetch/tsconfig.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "@robonen/tsconfig/tsconfig.json"
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'tsdown';
import { sharedConfig } from '@robonen/tsdown';
export default defineConfig({
...sharedConfig,
entry: ['src/index.ts'],
});

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
},
});

21
pnpm-lock.yaml generated
View File

@@ -122,6 +122,27 @@ importers:
specifier: 'catalog:'
version: 0.21.0(typescript@5.8.3)(vue-tsc@3.2.5(typescript@5.8.3))
core/fetch:
devDependencies:
'@robonen/oxlint':
specifier: workspace:*
version: link:../../configs/oxlint
'@robonen/tsconfig':
specifier: workspace:*
version: link:../../configs/tsconfig
'@robonen/tsdown':
specifier: workspace:*
version: link:../../configs/tsdown
'@stylistic/eslint-plugin':
specifier: 'catalog:'
version: 5.10.0(eslint@10.0.3(jiti@2.6.1))
oxlint:
specifier: 'catalog:'
version: 1.51.0
tsdown:
specifier: 'catalog:'
version: 0.21.0(typescript@5.8.3)(vue-tsc@3.2.5(typescript@5.8.3))
core/platform:
devDependencies:
'@robonen/oxlint':

View File

@@ -4,6 +4,7 @@ export default defineConfig({
test: {
projects: [
'configs/oxlint/vitest.config.ts',
'core/fetch/vitest.config.ts',
'core/stdlib/vitest.config.ts',
'core/platform/vitest.config.ts',
'vue/toolkit/vitest.config.ts',