refactor(core/fetch): optimize perfomance, improve types
This commit is contained in:
@@ -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));
|
||||||
|
|||||||
@@ -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,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
@@ -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;
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
+146
-139
@@ -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) {
|
|
||||||
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, {
|
throw createFetchError(context);
|
||||||
...context.options,
|
}
|
||||||
retry: maxRetries - 1,
|
|
||||||
});
|
// Response body parsing
|
||||||
|
const method = context.options.method ?? 'GET';
|
||||||
|
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') ?? ''));
|
||||||
|
|
||||||
|
switch (responseType) {
|
||||||
|
case 'json': {
|
||||||
|
const text = await context.response.text();
|
||||||
|
if (text) {
|
||||||
|
context.response._data
|
||||||
|
= context.options.parseResponse !== undefined
|
||||||
|
? context.options.parseResponse(text)
|
||||||
|
: JSON.parse(text);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'stream': {
|
||||||
|
assignResponseData(context.response, context.response.body);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
assignResponseData(context.response, await context.response[responseType]());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
context.response = await fetchImpl(context.request, context.options as RequestInit);
|
return await executeFetch(context) as FetchResponse<MappedResponseType<R, T>>;
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
context.error = err as Error;
|
const error = err instanceof FetchError ? err : createFetchError(context);
|
||||||
|
|
||||||
if (context.options.onRequestError !== undefined) {
|
if (isFunction(Error.captureStackTrace)) {
|
||||||
await callHooks(
|
Error.captureStackTrace(error, $fetchRaw);
|
||||||
context as FetchContext<T, R> & { error: Error },
|
}
|
||||||
context.options.onRequestError,
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry path — delegates to stdlib retry with iterative while-loop
|
||||||
|
try {
|
||||||
|
return await retry(
|
||||||
|
async ({ stop }) => {
|
||||||
|
try {
|
||||||
|
return await executeFetch(context) as FetchResponse<MappedResponseType<R, T>>;
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
// User-initiated abort (not timeout) should not be retried
|
||||||
|
const isAbort
|
||||||
|
= context.error !== undefined
|
||||||
|
&& context.error.name === 'AbortError'
|
||||||
|
&& context.options.timeout === undefined;
|
||||||
|
|
||||||
|
if (isAbort) {
|
||||||
|
stop(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// stdlib retry counts total attempts; fetch `retry` means retries only
|
||||||
|
times: maxRetries + 1,
|
||||||
|
delay: isFunction(context.options.retryDelay)
|
||||||
|
? () => (context.options.retryDelay as (ctx: FetchContext<T, R>) => number)(context)
|
||||||
|
: (context.options.retryDelay ?? 0),
|
||||||
|
shouldRetry: () => {
|
||||||
|
const status = context.response?.status ?? 500;
|
||||||
|
return context.options.retryStatusCodes !== undefined
|
||||||
|
? context.options.retryStatusCodes.includes(status)
|
||||||
|
: DEFAULT_RETRY_STATUS_CODES.has(status);
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
catch (err) {
|
||||||
|
const error = err instanceof FetchError ? err : createFetchError(context);
|
||||||
|
|
||||||
return (await onError(context)) as FetchResponse<T>;
|
// V8 / Node.js — clip internal frames from the error stack trace
|
||||||
|
if (isFunction(Error.captureStackTrace)) {
|
||||||
|
Error.captureStackTrace(error, $fetchRaw);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Response body parsing
|
throw error;
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -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;
|
|
||||||
|
|||||||
@@ -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
@@ -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,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';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
+27
-37
@@ -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)) {
|
||||||
|
await hooks(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const len = hooks.length;
|
const len = hooks.length;
|
||||||
for (let i = 0; i < len; i++) {
|
for (let i = 0; i < len; i++) {
|
||||||
await (hooks as Array<FetchHook<C>>)[i]!(context);
|
await hooks[i]!(context);
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
await (hooks as FetchHook<C>)(context);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
Generated
+8402
-2321
File diff suppressed because it is too large
Load Diff
+13
-7
@@ -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
|
||||||
Reference in New Issue
Block a user