refactor(core/fetch): optimize perfomance, improve types

This commit is contained in:
2026-03-26 16:10:19 +07:00
parent 4876e04ceb
commit 1db475c982
13 changed files with 8653 additions and 2539 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
import { defineConfig } from 'oxlint';
import { base, compose, imports, stylistic, typescript } from '@robonen/oxlint'; import { base, compose, imports, stylistic, typescript } from '@robonen/oxlint';
import { defineConfig } from 'oxlint';
export default defineConfig(compose(base, typescript, imports, stylistic)); export default defineConfig(compose(base, typescript, imports, stylistic));
+3
View File
@@ -42,6 +42,9 @@
"dev": "vitest dev", "dev": "vitest dev",
"build": "tsdown" "build": "tsdown"
}, },
"dependencies": {
"@robonen/stdlib": "workspace:*"
},
"devDependencies": { "devDependencies": {
"@robonen/oxlint": "workspace:*", "@robonen/oxlint": "workspace:*",
"@robonen/tsconfig": "workspace:*", "@robonen/tsconfig": "workspace:*",
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { FetchError, createFetchError } from './error'; import { FetchError, createFetchError } from './error';
import { describe, expect, it } from 'vitest';
import type { FetchContext } from './types'; import type { FetchContext } from './types';
function makeContext(overrides: Partial<FetchContext> = {}): FetchContext { function makeContext(overrides: Partial<FetchContext> = {}): FetchContext {
+22 -12
View File
@@ -1,4 +1,5 @@
import type { FetchContext, FetchOptions, FetchRequest, FetchResponse, IFetchError } from './types'; import type { FetchContext, FetchErrorOptions, FetchRequest, FetchResponse, IFetchError, ResponseType } from './types';
import { omit } from '@robonen/stdlib';
/** /**
* @name FetchError * @name FetchError
@@ -8,18 +9,27 @@ import type { FetchContext, FetchOptions, FetchRequest, FetchResponse, IFetchErr
* @since 0.0.1 * @since 0.0.1
*/ */
export class FetchError<T = unknown> extends Error implements IFetchError<T> { export class FetchError<T = unknown> extends Error implements IFetchError<T> {
request?: FetchRequest; request: FetchRequest | undefined;
options?: FetchOptions; options: FetchErrorOptions | undefined;
response?: FetchResponse<T>; response: FetchResponse<T> | undefined;
data?: T; data: T | undefined;
status?: number; status: number | undefined;
statusText?: string; statusText: string | undefined;
statusCode?: number; statusCode: number | undefined;
statusMessage?: string; statusMessage: string | undefined;
constructor(message: string) { constructor(message: string) {
super(message); super(message);
this.name = 'FetchError'; this.name = 'FetchError';
this.request = undefined;
this.options = undefined;
this.response = undefined;
this.data = undefined;
this.status = undefined;
this.statusText = undefined;
this.statusCode = undefined;
this.statusMessage = undefined;
} }
} }
@@ -33,13 +43,13 @@ export class FetchError<T = unknown> extends Error implements IFetchError<T> {
* *
* @since 0.0.1 * @since 0.0.1
*/ */
export function createFetchError<T = unknown>(context: FetchContext<T>): FetchError<T> { export function createFetchError<T = unknown, R extends ResponseType = ResponseType>(context: FetchContext<T, R>): FetchError<T> {
const url const url
= typeof context.request === 'string' = typeof context.request === 'string'
? context.request ? context.request
: context.request instanceof URL : context.request instanceof URL
? context.request.href ? context.request.href
: (context.request as Request).url; : context.request.url;
const statusPart = context.response const statusPart = context.response
? `${context.response.status} ${context.response.statusText}` ? `${context.response.status} ${context.response.statusText}`
@@ -55,7 +65,7 @@ export function createFetchError<T = unknown>(context: FetchContext<T>): FetchEr
const error = new FetchError<T>(message); const error = new FetchError<T>(message);
error.request = context.request; error.request = context.request;
error.options = context.options; error.options = omit(context.options, ['onRequest', 'onRequestError', 'onResponse', 'onResponseError', 'retryDelay', 'parseResponse']);
if (context.response !== undefined) { if (context.response !== undefined) {
error.response = context.response; error.response = context.response;
+6 -5
View File
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Fetch } from './types';
import { FetchError } from './error'; import { FetchError } from './error';
import { createFetch } from './fetch'; import { createFetch } from './fetch';
@@ -10,8 +11,8 @@ function makeFetchMock(
body: unknown = { ok: true }, body: unknown = { ok: true },
init: ResponseInit = { status: 200 }, init: ResponseInit = { status: 200 },
contentType = 'application/json', contentType = 'application/json',
): ReturnType<typeof vi.fn> { ) {
return vi.fn().mockResolvedValue( return vi.fn<Fetch>().mockResolvedValue(
new Response(typeof body === 'string' ? body : JSON.stringify(body), { new Response(typeof body === 'string' ? body : JSON.stringify(body), {
...init, ...init,
headers: { 'content-type': contentType, ...init.headers }, headers: { 'content-type': contentType, ...init.headers },
@@ -478,14 +479,14 @@ describe('response types', () => {
it('uses a custom parseResponse function', async () => { it('uses a custom parseResponse function', async () => {
const fetchMock = vi const fetchMock = vi
.fn() .fn<Fetch>()
.mockResolvedValue( .mockResolvedValue(
new Response('{"value":10}', { headers: { 'content-type': 'application/json' } }), new Response('{"value":10}', { headers: { 'content-type': 'application/json' } }),
); );
const $fetch = createFetch({ fetch: fetchMock }); const $fetch = createFetch({ fetch: fetchMock });
const data = await $fetch<{ value: number }>('https://api.example.com/custom', { const data = await $fetch<{ value: number; custom: boolean }>('https://api.example.com/custom', {
parseResponse: text => ({ ...JSON.parse(text) as object, custom: true }), parseResponse: text => ({ ...(JSON.parse(text) as { value: number }), custom: true }),
}); });
expect(data).toEqual({ value: 10, custom: true }); expect(data).toEqual({ value: 10, custom: true });
+145 -138
View File
@@ -1,5 +1,5 @@
import type { ResponseMap, $Fetch, CreateFetchOptions, FetchContext, FetchOptions, FetchRequest, FetchResponse, ResponseType } from './types'; import type { $Fetch, CreateFetchOptions, FetchContext, FetchOptions, FetchRequest, FetchResponse, MappedResponseType, ResponseType } from './types';
import { createFetchError } from './error'; import { FetchError, createFetchError } from './error';
import { import {
NULL_BODY_STATUSES, NULL_BODY_STATUSES,
buildURL, buildURL,
@@ -10,11 +10,11 @@ import {
joinURL, joinURL,
resolveFetchOptions, resolveFetchOptions,
} from './utils'; } from './utils';
import { isFunction, isNumber, isString, retry } from '@robonen/stdlib';
// --------------------------------------------------------------------------- function assignResponseData(response: { _data?: unknown }, data: unknown): void {
// V8: module-level Set — initialised once, never mutated, allows V8 to response._data = data;
// embed the set reference as a constant in compiled code. }
// ---------------------------------------------------------------------------
/** HTTP status codes that trigger automatic retry by default */ /** HTTP status codes that trigger automatic retry by default */
const DEFAULT_RETRY_STATUS_CODES: ReadonlySet<number> = /* @__PURE__ */ new Set([ const DEFAULT_RETRY_STATUS_CODES: ReadonlySet<number> = /* @__PURE__ */ new Set([
@@ -37,15 +37,6 @@ const DEFAULT_RETRY_STATUS_CODES: ReadonlySet<number> = /* @__PURE__ */ new Set(
* @category Fetch * @category Fetch
* @description Creates a configured $fetch instance * @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 * @param {CreateFetchOptions} [globalOptions={}] - Global defaults and custom fetch implementation
* @returns {$Fetch} Configured fetch instance * @returns {$Fetch} Configured fetch instance
* *
@@ -55,82 +46,104 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch {
const fetchImpl = globalOptions.fetch ?? globalThis.fetch; const fetchImpl = globalOptions.fetch ?? globalThis.fetch;
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Error handler — shared between network errors and 4xx/5xx responses // executeFetch — performs a single fetch attempt (no retry logic)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
async function onError(context: FetchContext): Promise<FetchResponse<unknown>> { async function executeFetch<T = unknown, R extends ResponseType = 'json'>(context: FetchContext<T, R>): Promise<FetchResponse<T>> {
// Explicit user-triggered abort should not be retried automatically // Actual fetch call
const isAbort try {
= context.error !== undefined context.response = await fetchImpl(context.request, context.options as RequestInit);
&& context.error.name === 'AbortError' }
&& context.options.timeout === undefined; catch (err) {
context.error = err as Error;
if (!isAbort && context.options.retry !== false) { if (context.options.onRequestError !== undefined) {
// Default retry count: 0 for payload methods, 1 for idempotent methods await callHooks(
const maxRetries context as FetchContext<T, R> & { error: Error },
= typeof context.options.retry === 'number' context.options.onRequestError,
? context.options.retry );
: isPayloadMethod(context.options.method ?? 'GET') }
? 0
: 1;
if (maxRetries > 0) { throw createFetchError(context);
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) { // Response body parsing
const retryDelay const method = context.options.method ?? 'GET';
= typeof context.options.retryDelay === 'function' const hasBody
? context.options.retryDelay(context) = context.response.body !== null
: (context.options.retryDelay ?? 0); && !NULL_BODY_STATUSES.has(context.response.status)
&& method !== 'HEAD';
if (retryDelay > 0) { if (hasBody) {
await new Promise<void>((resolve) => { const responseType
setTimeout(resolve, retryDelay); = context.options.parseResponse !== undefined
}); ? 'json'
: (context.options.responseType
?? detectResponseType(context.response.headers.get('content-type') ?? ''));
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;
return $fetchRaw(context.request, { }
...context.options, case 'stream': {
retry: maxRetries - 1, assignResponseData(context.response, context.response.body);
}); break;
}
default: {
assignResponseData(context.response, await context.response[responseType]());
} }
} }
} }
const error = createFetchError(context); if (context.options.onResponse !== undefined) {
await callHooks(
// V8 / Node.js — clip internal frames from the error stack trace context as FetchContext<T, R> & { response: FetchResponse<T> },
if (typeof Error.captureStackTrace === 'function') { context.options.onResponse,
Error.captureStackTrace(error, $fetchRaw); );
} }
throw error; 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,
);
}
throw createFetchError(context);
}
return context.response;
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// $fetchRaw — returns the full Response object with a parsed `_data` field // $fetchRaw — returns the full Response object with a parsed `_data` field
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
const $fetchRaw: $Fetch['raw'] = async function $fetchRaw< const $fetchRaw = async function $fetchRaw<
T = unknown, T = unknown,
R extends ResponseType = 'json', R extends ResponseType = 'json',
>( >(
_request: FetchRequest, _request: FetchRequest,
_options: FetchOptions<R, T> = {} as FetchOptions<R, T>, _options?: FetchOptions<R, T>,
): Promise<FetchResponse<T>> { ): Promise<FetchResponse<MappedResponseType<R, 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> = { const context: FetchContext<T, R> = {
request: _request, request: _request,
options: resolveFetchOptions( options: resolveFetchOptions(
_request, _request,
_options, _options,
globalOptions.defaults as FetchOptions<R, T>, globalOptions.defaults,
), ),
response: undefined, response: undefined,
error: undefined, error: undefined,
@@ -146,7 +159,7 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch {
} }
// URL transformations — only when request is a plain string // URL transformations — only when request is a plain string
if (typeof context.request === 'string') { if (isString(context.request)) {
if (context.options.baseURL !== undefined) { if (context.options.baseURL !== undefined) {
context.request = joinURL(context.options.baseURL, context.request); context.request = joinURL(context.options.baseURL, context.request);
} }
@@ -163,7 +176,7 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch {
if (isJSONSerializable(context.options.body)) { if (isJSONSerializable(context.options.body)) {
const contentType = context.options.headers.get('content-type'); const contentType = context.options.headers.get('content-type');
if (typeof context.options.body !== 'string') { if (!isString(context.options.body)) {
context.options.body context.options.body
= contentType === 'application/x-www-form-urlencoded' = contentType === 'application/x-www-form-urlencoded'
? new URLSearchParams( ? new URLSearchParams(
@@ -198,81 +211,80 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch {
: timeoutSignal; : timeoutSignal;
} }
// Actual fetch call // -----------------------------------------------------------------------
// Retry configuration — computed once, not per attempt
// -----------------------------------------------------------------------
const retryDisabled = context.options.retry === false;
const maxRetries = retryDisabled
? 0
: isNumber(context.options.retry)
? context.options.retry
: isPayloadMethod(method)
? 0
: 1;
if (maxRetries === 0) {
try {
return await executeFetch(context) as FetchResponse<MappedResponseType<R, T>>;
}
catch (err) {
const error = err instanceof FetchError ? err : createFetchError(context);
if (isFunction(Error.captureStackTrace)) {
Error.captureStackTrace(error, $fetchRaw);
}
throw error;
}
}
// Retry path — delegates to stdlib retry with iterative while-loop
try { try {
context.response = await fetchImpl(context.request, context.options as RequestInit); return await retry(
} async ({ stop }) => {
catch (err) { try {
context.error = err as Error; return await executeFetch(context) as FetchResponse<MappedResponseType<R, T>>;
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; catch (error) {
} // User-initiated abort (not timeout) should not be retried
case 'stream': { const isAbort
context.response._data = context.response.body as unknown as T; = context.error !== undefined
break; && context.error.name === 'AbortError'
} && context.options.timeout === undefined;
default: {
context.response._data = (await context.response[responseType]()) as T;
}
}
}
if (context.options.onResponse !== undefined) { if (isAbort) {
await callHooks( stop(error);
context as FetchContext<T, R> & { response: FetchResponse<T> }, }
context.options.onResponse,
throw error;
}
},
{
// stdlib retry counts total attempts; fetch `retry` means retries only
times: maxRetries + 1,
delay: isFunction(context.options.retryDelay)
? () => (context.options.retryDelay as (ctx: FetchContext<T, R>) => number)(context)
: (context.options.retryDelay ?? 0),
shouldRetry: () => {
const status = context.response?.status ?? 500;
return context.options.retryStatusCodes !== undefined
? context.options.retryStatusCodes.includes(status)
: DEFAULT_RETRY_STATUS_CODES.has(status);
},
},
); );
} }
catch (err) {
const error = err instanceof FetchError ? err : createFetchError(context);
if ( // V8 / Node.js — clip internal frames from the error stack trace
!context.options.ignoreResponseError if (isFunction(Error.captureStackTrace)) {
&& context.response.status >= 400 Error.captureStackTrace(error, $fetchRaw);
&& 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>; throw error;
} }
return context.response;
}; };
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -282,9 +294,9 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch {
const $fetch = async function $fetch<T = unknown, R extends ResponseType = 'json'>( const $fetch = async function $fetch<T = unknown, R extends ResponseType = 'json'>(
request: FetchRequest, request: FetchRequest,
options?: FetchOptions<R, T>, options?: FetchOptions<R, T>,
): Promise<InferResponseType<R, T>> { ): Promise<MappedResponseType<R, T>> {
const response = await $fetchRaw<T, R>(request, options); const response = await $fetchRaw<T, R>(request, options);
return response._data as InferResponseType<R, T>; return response._data as MappedResponseType<R, T>;
} as $Fetch; } as $Fetch;
$fetch.raw = $fetchRaw; $fetch.raw = $fetchRaw;
@@ -317,8 +329,3 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch {
return $fetch; 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;
+3 -2
View File
@@ -1,3 +1,5 @@
import { createFetch } from './fetch';
export { createFetch } from './fetch'; export { createFetch } from './fetch';
export { FetchError, createFetchError } from './error'; export { FetchError, createFetchError } from './error';
export { export {
@@ -14,6 +16,7 @@ export type {
CreateFetchOptions, CreateFetchOptions,
Fetch, Fetch,
FetchContext, FetchContext,
FetchErrorOptions,
FetchHook, FetchHook,
FetchHooks, FetchHooks,
FetchOptions, FetchOptions,
@@ -21,8 +24,6 @@ export type {
FetchResponse, FetchResponse,
IFetchError, IFetchError,
MappedResponseType, MappedResponseType,
MaybeArray,
MaybePromise,
ResponseMap, ResponseMap,
ResponseType, ResponseType,
ResolvedFetchOptions, ResolvedFetchOptions,
+23 -13
View File
@@ -1,3 +1,5 @@
import type { MaybePromise, ReadonlyArrayable } from '@robonen/stdlib';
// -------------------------- // --------------------------
// Fetch API // Fetch API
// -------------------------- // --------------------------
@@ -50,8 +52,8 @@ export interface $Fetch {
/** Shorthand for HEAD requests */ /** Shorthand for HEAD requests */
head( head(
request: FetchRequest, request: FetchRequest,
options?: Omit<FetchOptions<'text', never>, 'method'>, options?: Omit<FetchOptions, 'method'>,
): Promise<FetchResponse<never>>; ): Promise<FetchResponse<unknown>>;
} }
// -------------------------- // --------------------------
@@ -69,7 +71,7 @@ export interface FetchOptions<R extends ResponseType = 'json', T = unknown>
/** Base URL prepended to all relative request URLs */ /** Base URL prepended to all relative request URLs */
baseURL?: string; baseURL?: string;
/** Request body — plain objects are automatically JSON-serialized */ /** Request body — plain objects are automatically JSON-serialized */
body?: BodyInit | Record<string, unknown> | unknown[] | null; body?: RequestInit['body'] | Record<string, unknown> | unknown[] | null;
/** Suppress throwing on 4xx/5xx responses */ /** Suppress throwing on 4xx/5xx responses */
ignoreResponseError?: boolean; ignoreResponseError?: boolean;
/** URL query parameters serialized and appended to the request URL */ /** URL query parameters serialized and appended to the request URL */
@@ -139,15 +141,12 @@ export interface FetchContext<T = unknown, R extends ResponseType = 'json'> {
error?: Error; error?: Error;
} }
export type MaybePromise<T> = T | Promise<T>;
export type MaybeArray<T> = T | readonly T[];
/** /**
* @name FetchHook * @name FetchHook
* @category Fetch * @category Fetch
* @description A function invoked at a specific point in the fetch lifecycle * @description A function invoked at a specific point in the fetch lifecycle
*/ */
export type FetchHook<C extends FetchContext = FetchContext> = (context: C) => MaybePromise<void>; export type FetchHook<C = FetchContext> = (context: C) => MaybePromise<void>;
/** /**
* @name FetchHooks * @name FetchHooks
@@ -156,13 +155,13 @@ export type FetchHook<C extends FetchContext = FetchContext> = (context: C) => M
*/ */
export interface FetchHooks<T = unknown, R extends ResponseType = 'json'> { export interface FetchHooks<T = unknown, R extends ResponseType = 'json'> {
/** Called before the request is sent */ /** Called before the request is sent */
onRequest?: MaybeArray<FetchHook<FetchContext<T, R>>>; onRequest?: ReadonlyArrayable<FetchHook<FetchContext<T, R>>>;
/** Called when the request itself throws (e.g. network error, timeout) */ /** Called when the request itself throws (e.g. network error, timeout) */
onRequestError?: MaybeArray<FetchHook<FetchContext<T, R> & { error: Error }>>; onRequestError?: ReadonlyArrayable<FetchHook<FetchContext<T, R> & { error: Error }>>;
/** Called after a successful response is received and parsed */ /** Called after a successful response is received and parsed */
onResponse?: MaybeArray<FetchHook<FetchContext<T, R> & { response: FetchResponse<T> }>>; onResponse?: ReadonlyArrayable<FetchHook<FetchContext<T, R> & { response: FetchResponse<T> }>>;
/** Called when the response status is 4xx or 5xx */ /** Called when the response status is 4xx or 5xx */
onResponseError?: MaybeArray<FetchHook<FetchContext<T, R> & { response: FetchResponse<T> }>>; onResponseError?: ReadonlyArrayable<FetchHook<FetchContext<T, R> & { response: FetchResponse<T> }>>;
} }
// -------------------------- // --------------------------
@@ -210,6 +209,17 @@ export interface FetchResponse<T> extends Response {
// Error // Error
// -------------------------- // --------------------------
/**
* @name FetchErrorOptions
* @category Fetch
* @description Subset of FetchOptions stored on FetchError — strips lifecycle hooks,
* parseResponse, and retryDelay callback that are invariant in T/R
*/
export type FetchErrorOptions = Omit<
FetchOptions<ResponseType>,
keyof FetchHooks | 'parseResponse' | 'retryDelay'
>;
/** /**
* @name IFetchError * @name IFetchError
* @category Fetch * @category Fetch
@@ -217,7 +227,7 @@ export interface FetchResponse<T> extends Response {
*/ */
export interface IFetchError<T = unknown> extends Error { export interface IFetchError<T = unknown> extends Error {
request?: FetchRequest; request?: FetchRequest;
options?: FetchOptions; options?: FetchErrorOptions;
response?: FetchResponse<T>; response?: FetchResponse<T>;
data?: T; data?: T;
status?: number; status?: number;
@@ -234,4 +244,4 @@ export interface IFetchError<T = unknown> extends Error {
export type Fetch = typeof globalThis.fetch; export type Fetch = typeof globalThis.fetch;
/** A fetch request — URL string, URL object, or Request object */ /** A fetch request — URL string, URL object, or Request object */
export type FetchRequest = RequestInfo; export type FetchRequest = string | URL | Request;
+1 -1
View File
@@ -1,4 +1,3 @@
import { describe, expect, it } from 'vitest';
import { import {
buildURL, buildURL,
callHooks, callHooks,
@@ -8,6 +7,7 @@ import {
joinURL, joinURL,
resolveFetchOptions, resolveFetchOptions,
} from './utils'; } from './utils';
import { describe, expect, it } from 'vitest';
import type { FetchContext } from './types'; import type { FetchContext } from './types';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+28 -38
View File
@@ -1,16 +1,11 @@
import type { import type {
FetchContext,
FetchHook, FetchHook,
FetchOptions, FetchOptions,
FetchRequest, FetchRequest,
ResolvedFetchOptions, ResolvedFetchOptions,
ResponseType, ResponseType,
} from './types'; } from './types';
import { isArray, isFunction } from '@robonen/stdlib';
// ---------------------------------------------------------------------------
// 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 */ /** HTTP methods whose requests carry a body */
const PAYLOAD_METHODS: ReadonlySet<string> = /* @__PURE__ */ new Set(['PATCH', 'POST', 'PUT', 'DELETE']); const PAYLOAD_METHODS: ReadonlySet<string> = /* @__PURE__ */ new Set(['PATCH', 'POST', 'PUT', 'DELETE']);
@@ -26,7 +21,6 @@ const TEXT_CONTENT_TYPES: ReadonlySet<string> = /* @__PURE__ */ new Set([
'application/html', 'application/html',
]); ]);
/** V8: pre-compiled at module load — avoids per-call RegExp construction */
const JSON_CONTENT_TYPE_RE = /^application\/(?:[\w!#$%&*.^`~-]*\+)?json(;.+)?$/i; const JSON_CONTENT_TYPE_RE = /^application\/(?:[\w!#$%&*.^`~-]*\+)?json(;.+)?$/i;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -38,8 +32,6 @@ const JSON_CONTENT_TYPE_RE = /^application\/(?:[\w!#$%&*.^`~-]*\+)?json(;.+)?$/i
* @category Fetch * @category Fetch
* @description Returns true for HTTP methods that carry a request body * @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 * @param {string} method - Uppercase HTTP method string
* @returns {boolean} * @returns {boolean}
* *
@@ -54,16 +46,14 @@ export function isPayloadMethod(method: string): boolean {
* @category Fetch * @category Fetch
* @description Returns true when a value can be serialised with JSON.stringify * @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 * @param {unknown} value - Any value
* @returns {boolean} * @returns {boolean}
* *
* @since 0.0.1 * @since 0.0.1
*/ */
export function isJSONSerializable(value: unknown): boolean { export function isJSONSerializable(value: unknown): boolean {
if (value === undefined) return false; if (value === undefined)
return false;
const type = typeof value; const type = typeof value;
@@ -74,7 +64,7 @@ export function isJSONSerializable(value: unknown): boolean {
if (type !== 'object') return false; if (type !== 'object') return false;
// Arrays are serialisable // Arrays are serialisable
if (Array.isArray(value)) return true; if (isArray(value)) return true;
// TypedArrays / ArrayBuffers carry a .buffer property — not JSON-serialisable // TypedArrays / ArrayBuffers carry a .buffer property — not JSON-serialisable
if ((value as Record<string, unknown>).buffer !== undefined) return false; if ((value as Record<string, unknown>).buffer !== undefined) return false;
@@ -108,7 +98,6 @@ export function isJSONSerializable(value: unknown): boolean {
export function detectResponseType(contentType = ''): ResponseType { export function detectResponseType(contentType = ''): ResponseType {
if (!contentType) return 'json'; if (!contentType) return 'json';
// V8: split once and reuse — avoids calling split multiple times
const type = contentType.split(';')[0] ?? ''; const type = contentType.split(';')[0] ?? '';
if (JSON_CONTENT_TYPE_RE.test(type)) return 'json'; if (JSON_CONTENT_TYPE_RE.test(type)) return 'json';
@@ -185,16 +174,18 @@ export function joinURL(base: string, path: string): string {
* @category Fetch * @category Fetch
* @description Merges per-request options with global defaults * @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 * @since 0.0.1
*/ */
export function resolveFetchOptions<R extends ResponseType = 'json', T = unknown>( export function resolveFetchOptions<R extends ResponseType = 'json', T = unknown>(
request: FetchRequest, request: FetchRequest,
input: FetchOptions<R, T> | undefined, input: FetchOptions<R, T> | undefined,
defaults: FetchOptions<R, T> | undefined, defaults: FetchOptions | undefined,
): ResolvedFetchOptions<R, T> { ): ResolvedFetchOptions<R, T>;
export function resolveFetchOptions(
request: FetchRequest,
input: FetchOptions | undefined,
defaults: FetchOptions | undefined,
): ResolvedFetchOptions {
const headers = mergeHeaders( const headers = mergeHeaders(
input?.headers ?? (request as Request)?.headers, input?.headers ?? (request as Request)?.headers,
defaults?.headers, defaults?.headers,
@@ -224,15 +215,17 @@ export function resolveFetchOptions<R extends ResponseType = 'json', T = unknown
}; };
} }
/** Header sources accepted by the merge function */
type HeadersInput = Headers | Record<string, string | undefined> | Array<[string, string]>;
/** /**
* Merge two HeadersInit sources into a single Headers instance. * Merge two header sources into a single Headers instance.
* Input headers override default headers. * Input headers override default headers.
* *
* V8: avoids constructing an intermediate Headers when defaults are absent.
*/ */
function mergeHeaders( function mergeHeaders(
input: HeadersInit | undefined, input: HeadersInput | undefined,
defaults: HeadersInit | undefined, defaults: HeadersInput | undefined,
): Headers { ): Headers {
if (defaults === undefined) { if (defaults === undefined) {
return new Headers(input); return new Headers(input);
@@ -242,9 +235,9 @@ function mergeHeaders(
if (input !== undefined) { if (input !== undefined) {
const src = input instanceof Headers ? input : new Headers(input); const src = input instanceof Headers ? input : new Headers(input);
for (const [key, value] of src) { src.forEach((value, key) => {
merged.set(key, value); merged.set(key, value);
} });
} }
return merged; return merged;
@@ -259,24 +252,21 @@ function mergeHeaders(
* @category Fetch * @category Fetch
* @description Invokes one or more lifecycle hooks with the given context * @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 * @since 0.0.1
*/ */
export async function callHooks<C extends FetchContext = FetchContext>( export async function callHooks<C>(
context: C, context: C,
hooks: FetchHook<C> | readonly FetchHook<C>[] | undefined, hooks: FetchHook<C> | ReadonlyArray<FetchHook<C>> | undefined,
): Promise<void> { ): Promise<void> {
if (hooks === undefined) return; if (hooks === undefined) return;
if (Array.isArray(hooks)) { if (isFunction(hooks)) {
const len = hooks.length; await hooks(context);
for (let i = 0; i < len; i++) { return;
await (hooks as Array<FetchHook<C>>)[i]!(context);
}
} }
else {
await (hooks as FetchHook<C>)(context); const len = hooks.length;
for (let i = 0; i < len; i++) {
await hooks[i]!(context);
} }
} }
+5
View File
@@ -2,3 +2,8 @@
* A type that can be either a single value or an array of values * A type that can be either a single value or an array of values
*/ */
export type Arrayable<T> = T | T[]; export type Arrayable<T> = T | T[];
/**
* A type that can be either a single value or a readonly array of values
*/
export type ReadonlyArrayable<T> = T | readonly T[];
+8402 -2321
View File
File diff suppressed because it is too large Load Diff
+13 -7
View File
@@ -2,16 +2,22 @@ packages:
- configs/* - configs/*
- core/* - core/*
- infra/* - infra/*
- web/* - vue/*
- docs - docs
catalog: catalog:
'@stylistic/eslint-plugin': ^5.10.0
'@vitest/coverage-v8': ^4.0.18 '@vitest/coverage-v8': ^4.0.18
'@vue/test-utils': ^2.4.6
jsdom: ^29.0.1
oxlint: ^1.2.0
tsdown: ^0.12.5
vitest: ^4.0.18
'@vitest/ui': ^4.0.18 '@vitest/ui': ^4.0.18
vue: ^3.5.28 '@vue/shared': ^3.5.29
'@vue/test-utils': ^2.4.6
jsdom: ^28.1.0
nuxt: ^4.3.1 nuxt: ^4.3.1
oxlint: ^1.51.0
tsdown: ^0.21.0
vitest: ^4.0.18
vue: ^3.5.29
ignoredBuiltDependencies:
- '@parcel/watcher'
- esbuild