Merge pull request #130 from robonen/copilot/add-fetch-wrapper-optimization
feat(core): add @robonen/fetch — type-safe fetch wrapper with V8-optimised internals
This commit is contained in:
@@ -19,7 +19,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v5
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v5
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://jsr.io/schema/config-file.v1.json",
|
||||||
|
"name": "@robonen/fetch",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"exports": "./src/index.ts"
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { base, compose, imports, stylistic, typescript } from '@robonen/oxlint';
|
||||||
|
import { defineConfig } from 'oxlint';
|
||||||
|
|
||||||
|
export default defineConfig(compose(base, typescript, imports, stylistic));
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@robonen/stdlib": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@robonen/oxlint": "workspace:*",
|
||||||
|
"@robonen/tsconfig": "workspace:*",
|
||||||
|
"@robonen/tsdown": "workspace:*",
|
||||||
|
"@stylistic/eslint-plugin": "catalog:",
|
||||||
|
"oxlint": "catalog:",
|
||||||
|
"tsdown": "catalog:"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { FetchError, createFetchError } from './error';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { FetchContext, FetchErrorOptions, FetchRequest, FetchResponse, IFetchError, ResponseType } from './types';
|
||||||
|
import { omit } from '@robonen/stdlib';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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 | undefined;
|
||||||
|
options: FetchErrorOptions | undefined;
|
||||||
|
response: FetchResponse<T> | undefined;
|
||||||
|
data: T | undefined;
|
||||||
|
status: number | undefined;
|
||||||
|
statusText: string | undefined;
|
||||||
|
statusCode: number | undefined;
|
||||||
|
statusMessage: string | undefined;
|
||||||
|
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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, R extends ResponseType = ResponseType>(context: FetchContext<T, R>): FetchError<T> {
|
||||||
|
const url
|
||||||
|
= typeof context.request === 'string'
|
||||||
|
? context.request
|
||||||
|
: context.request instanceof URL
|
||||||
|
? context.request.href
|
||||||
|
: context.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 = omit(context.options, ['onRequest', 'onRequestError', 'onResponse', 'onResponseError', 'retryDelay', 'parseResponse']);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { Fetch } from './types';
|
||||||
|
import { FetchError } from './error';
|
||||||
|
import { createFetch } from './fetch';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeFetchMock(
|
||||||
|
body: unknown = { ok: true },
|
||||||
|
init: ResponseInit = { status: 200 },
|
||||||
|
contentType = 'application/json',
|
||||||
|
) {
|
||||||
|
return vi.fn<Fetch>().mockResolvedValue(
|
||||||
|
new Response(typeof body === 'string' ? body : JSON.stringify(body), {
|
||||||
|
...init,
|
||||||
|
headers: { 'content-type': contentType, ...init.headers },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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<Fetch>()
|
||||||
|
.mockResolvedValue(
|
||||||
|
new Response('{"value":10}', { headers: { 'content-type': 'application/json' } }),
|
||||||
|
);
|
||||||
|
const $fetch = createFetch({ fetch: fetchMock });
|
||||||
|
|
||||||
|
const data = await $fetch<{ value: number; custom: boolean }>('https://api.example.com/custom', {
|
||||||
|
parseResponse: text => ({ ...(JSON.parse(text) as { value: number }), 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import type { $Fetch, CreateFetchOptions, FetchContext, FetchOptions, FetchRequest, FetchResponse, MappedResponseType, ResponseType } from './types';
|
||||||
|
import { FetchError, createFetchError } from './error';
|
||||||
|
import {
|
||||||
|
NULL_BODY_STATUSES,
|
||||||
|
buildURL,
|
||||||
|
callHooks,
|
||||||
|
detectResponseType,
|
||||||
|
isJSONSerializable,
|
||||||
|
isPayloadMethod,
|
||||||
|
joinURL,
|
||||||
|
resolveFetchOptions,
|
||||||
|
} from './utils';
|
||||||
|
import { isFunction, isNumber, isString, retry } from '@robonen/stdlib';
|
||||||
|
|
||||||
|
function assignResponseData(response: { _data?: unknown }, data: unknown): void {
|
||||||
|
response._data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTTP status codes that trigger automatic retry by default */
|
||||||
|
const DEFAULT_RETRY_STATUS_CODES: ReadonlySet<number> = /* @__PURE__ */ new Set([
|
||||||
|
408, // Request Timeout
|
||||||
|
409, // Conflict
|
||||||
|
425, // Too Early (Experimental)
|
||||||
|
429, // Too Many Requests
|
||||||
|
500, // Internal Server Error
|
||||||
|
502, // Bad Gateway
|
||||||
|
503, // Service Unavailable
|
||||||
|
504, // Gateway Timeout
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// createFetch
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name createFetch
|
||||||
|
* @category Fetch
|
||||||
|
* @description Creates a configured $fetch instance
|
||||||
|
*
|
||||||
|
* @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;
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// executeFetch — performs a single fetch attempt (no retry logic)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function executeFetch<T = unknown, R extends ResponseType = 'json'>(context: FetchContext<T, R>): Promise<FetchResponse<T>> {
|
||||||
|
// 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createFetchError(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response body parsing
|
||||||
|
const method = context.options.method ?? 'GET';
|
||||||
|
const hasBody
|
||||||
|
= context.response.body !== null
|
||||||
|
&& !NULL_BODY_STATUSES.has(context.response.status)
|
||||||
|
&& method !== 'HEAD';
|
||||||
|
|
||||||
|
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]());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createFetchError(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// $fetchRaw — returns the full Response object with a parsed `_data` field
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const $fetchRaw = async function $fetchRaw<
|
||||||
|
T = unknown,
|
||||||
|
R extends ResponseType = 'json',
|
||||||
|
>(
|
||||||
|
_request: FetchRequest,
|
||||||
|
_options?: FetchOptions<R, T>,
|
||||||
|
): Promise<FetchResponse<MappedResponseType<R, T>>> {
|
||||||
|
const context: FetchContext<T, R> = {
|
||||||
|
request: _request,
|
||||||
|
options: resolveFetchOptions(
|
||||||
|
_request,
|
||||||
|
_options,
|
||||||
|
globalOptions.defaults,
|
||||||
|
),
|
||||||
|
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 (isString(context.request)) {
|
||||||
|
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 (!isString(context.options.body)) {
|
||||||
|
context.options.body
|
||||||
|
= contentType === 'application/x-www-form-urlencoded'
|
||||||
|
? new URLSearchParams(
|
||||||
|
context.options.body as Record<string, string>,
|
||||||
|
).toString()
|
||||||
|
: JSON.stringify(context.options.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentType === null) {
|
||||||
|
context.options.headers.set('content-type', 'application/json');
|
||||||
|
}
|
||||||
|
if (!context.options.headers.has('accept')) {
|
||||||
|
context.options.headers.set('accept', 'application/json');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (
|
||||||
|
// Web Streams API body
|
||||||
|
typeof (context.options.body as ReadableStream | null)?.pipeTo === 'function'
|
||||||
|
) {
|
||||||
|
if (!('duplex' in context.options)) {
|
||||||
|
context.options.duplex = 'half';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeout via AbortSignal — compose with any caller-supplied signal
|
||||||
|
if (context.options.timeout !== undefined) {
|
||||||
|
const timeoutSignal = AbortSignal.timeout(context.options.timeout);
|
||||||
|
context.options.signal
|
||||||
|
= context.options.signal !== undefined
|
||||||
|
? AbortSignal.any([timeoutSignal, context.options.signal as AbortSignal])
|
||||||
|
: timeoutSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 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 {
|
||||||
|
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);
|
||||||
|
|
||||||
|
// V8 / Node.js — clip internal frames from the error stack trace
|
||||||
|
if (isFunction(Error.captureStackTrace)) {
|
||||||
|
Error.captureStackTrace(error, $fetchRaw);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// $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<MappedResponseType<R, T>> {
|
||||||
|
const response = await $fetchRaw<T, R>(request, options);
|
||||||
|
return response._data as MappedResponseType<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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { createFetch } from './fetch';
|
||||||
|
|
||||||
|
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,
|
||||||
|
FetchErrorOptions,
|
||||||
|
FetchHook,
|
||||||
|
FetchHooks,
|
||||||
|
FetchOptions,
|
||||||
|
FetchRequest,
|
||||||
|
FetchResponse,
|
||||||
|
IFetchError,
|
||||||
|
MappedResponseType,
|
||||||
|
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();
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import type { MaybePromise, ReadonlyArrayable } from '@robonen/stdlib';
|
||||||
|
|
||||||
|
// --------------------------
|
||||||
|
// 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, 'method'>,
|
||||||
|
): Promise<FetchResponse<unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------
|
||||||
|
// 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?: RequestInit['body'] | 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name FetchHook
|
||||||
|
* @category Fetch
|
||||||
|
* @description A function invoked at a specific point in the fetch lifecycle
|
||||||
|
*/
|
||||||
|
export type FetchHook<C = 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?: ReadonlyArrayable<FetchHook<FetchContext<T, R>>>;
|
||||||
|
/** Called when the request itself throws (e.g. network error, timeout) */
|
||||||
|
onRequestError?: ReadonlyArrayable<FetchHook<FetchContext<T, R> & { error: Error }>>;
|
||||||
|
/** Called after a successful response is received and parsed */
|
||||||
|
onResponse?: ReadonlyArrayable<FetchHook<FetchContext<T, R> & { response: FetchResponse<T> }>>;
|
||||||
|
/** Called when the response status is 4xx or 5xx */
|
||||||
|
onResponseError?: ReadonlyArrayable<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 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
|
||||||
|
* @category Fetch
|
||||||
|
* @description Shape of errors thrown by $fetch
|
||||||
|
*/
|
||||||
|
export interface IFetchError<T = unknown> extends Error {
|
||||||
|
request?: FetchRequest;
|
||||||
|
options?: FetchErrorOptions;
|
||||||
|
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 = string | URL | Request;
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import {
|
||||||
|
buildURL,
|
||||||
|
callHooks,
|
||||||
|
detectResponseType,
|
||||||
|
isJSONSerializable,
|
||||||
|
isPayloadMethod,
|
||||||
|
joinURL,
|
||||||
|
resolveFetchOptions,
|
||||||
|
} from './utils';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import type {
|
||||||
|
FetchHook,
|
||||||
|
FetchOptions,
|
||||||
|
FetchRequest,
|
||||||
|
ResolvedFetchOptions,
|
||||||
|
ResponseType,
|
||||||
|
} from './types';
|
||||||
|
import { isArray, isFunction } from '@robonen/stdlib';
|
||||||
|
|
||||||
|
/** HTTP methods whose requests carry a body */
|
||||||
|
const PAYLOAD_METHODS: ReadonlySet<string> = /* @__PURE__ */ new Set(['PATCH', 'POST', 'PUT', 'DELETE']);
|
||||||
|
|
||||||
|
/** HTTP status codes whose responses never have a body */
|
||||||
|
export const NULL_BODY_STATUSES: ReadonlySet<number> = /* @__PURE__ */ new Set([101, 204, 205, 304]);
|
||||||
|
|
||||||
|
/** Content-types treated as plain text */
|
||||||
|
const TEXT_CONTENT_TYPES: ReadonlySet<string> = /* @__PURE__ */ new Set([
|
||||||
|
'image/svg',
|
||||||
|
'application/xml',
|
||||||
|
'application/xhtml',
|
||||||
|
'application/html',
|
||||||
|
]);
|
||||||
|
|
||||||
|
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
|
||||||
|
*
|
||||||
|
* @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
|
||||||
|
*
|
||||||
|
* @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 (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';
|
||||||
|
|
||||||
|
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
|
||||||
|
*
|
||||||
|
* @since 0.0.1
|
||||||
|
*/
|
||||||
|
export function resolveFetchOptions<R extends ResponseType = 'json', T = unknown>(
|
||||||
|
request: FetchRequest,
|
||||||
|
input: FetchOptions<R, T> | undefined,
|
||||||
|
defaults: FetchOptions | undefined,
|
||||||
|
): ResolvedFetchOptions<R, T>;
|
||||||
|
export function resolveFetchOptions(
|
||||||
|
request: FetchRequest,
|
||||||
|
input: FetchOptions | undefined,
|
||||||
|
defaults: FetchOptions | undefined,
|
||||||
|
): ResolvedFetchOptions {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Header sources accepted by the merge function */
|
||||||
|
type HeadersInput = Headers | Record<string, string | undefined> | Array<[string, string]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge two header sources into a single Headers instance.
|
||||||
|
* Input headers override default headers.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
function mergeHeaders(
|
||||||
|
input: HeadersInput | undefined,
|
||||||
|
defaults: HeadersInput | 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);
|
||||||
|
src.forEach((value, key) => {
|
||||||
|
merged.set(key, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hook dispatch
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name callHooks
|
||||||
|
* @category Fetch
|
||||||
|
* @description Invokes one or more lifecycle hooks with the given context
|
||||||
|
*
|
||||||
|
* @since 0.0.1
|
||||||
|
*/
|
||||||
|
export async function callHooks<C>(
|
||||||
|
context: C,
|
||||||
|
hooks: FetchHook<C> | ReadonlyArray<FetchHook<C>> | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
if (hooks === undefined) return;
|
||||||
|
|
||||||
|
if (isFunction(hooks)) {
|
||||||
|
await hooks(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const len = hooks.length;
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
await hooks[i]!(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "@robonen/tsconfig/tsconfig.json"
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'tsdown';
|
||||||
|
import { sharedConfig } from '@robonen/tsdown';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
...sharedConfig,
|
||||||
|
entry: ['src/index.ts'],
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
|
export * from './retry';
|
||||||
export * from './sleep';
|
export * from './sleep';
|
||||||
export * from './tryIt';
|
export * from './tryIt';
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { retry } from '.';
|
||||||
|
|
||||||
|
describe('retry', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('return the result on first successful attempt', async () => {
|
||||||
|
const successFn = vi.fn().mockResolvedValue('success');
|
||||||
|
|
||||||
|
const result = await retry(successFn);
|
||||||
|
|
||||||
|
expect(result).toBe('success');
|
||||||
|
expect(successFn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(successFn).toHaveBeenCalledWith({ count: 1, stop: expect.any(Function) });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('use default times value of 2', async () => {
|
||||||
|
const failingFn = vi.fn().mockRejectedValue(new Error('Test error'));
|
||||||
|
|
||||||
|
await expect(retry(failingFn)).rejects.toThrow('Test error');
|
||||||
|
|
||||||
|
expect(failingFn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retry the specified number of times on failure', async () => {
|
||||||
|
const failingFn = vi.fn().mockRejectedValue(new Error('Test error'));
|
||||||
|
|
||||||
|
await expect(retry(failingFn, { times: 3 })).rejects.toThrow('Test error');
|
||||||
|
|
||||||
|
expect(failingFn).toHaveBeenCalledTimes(3);
|
||||||
|
expect(failingFn).toHaveBeenNthCalledWith(1, { count: 1, stop: expect.any(Function) });
|
||||||
|
expect(failingFn).toHaveBeenNthCalledWith(2, { count: 2, stop: expect.any(Function) });
|
||||||
|
expect(failingFn).toHaveBeenNthCalledWith(3, { count: 3, stop: expect.any(Function) });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('succeed on the last attempt', async () => {
|
||||||
|
const partiallyFailingFn = vi.fn()
|
||||||
|
.mockRejectedValueOnce(new Error('First failure'))
|
||||||
|
.mockRejectedValueOnce(new Error('Second failure'))
|
||||||
|
.mockResolvedValue('success');
|
||||||
|
|
||||||
|
const result = await retry(partiallyFailingFn, { times: 3 });
|
||||||
|
|
||||||
|
expect(result).toBe('success');
|
||||||
|
expect(partiallyFailingFn).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('use custom shouldRetry function', async () => {
|
||||||
|
const networkError = new Error('Network failed');
|
||||||
|
networkError.name = 'NetworkError';
|
||||||
|
const failingFn = vi.fn().mockRejectedValue(networkError);
|
||||||
|
|
||||||
|
await expect(retry(failingFn, {
|
||||||
|
times: 3,
|
||||||
|
shouldRetry: (error) => error.name !== 'NetworkError'
|
||||||
|
})).rejects.toThrow('Network failed');
|
||||||
|
|
||||||
|
expect(failingFn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retry with custom shouldRetry based on count', async () => {
|
||||||
|
const testError = new Error('Test error');
|
||||||
|
const failingFn = vi.fn().mockRejectedValue(testError);
|
||||||
|
|
||||||
|
await expect(retry(failingFn, {
|
||||||
|
times: 5,
|
||||||
|
shouldRetry: (error, count) => count < 3 // Only retry first 2 attempts
|
||||||
|
})).rejects.toThrow('Test error');
|
||||||
|
|
||||||
|
expect(failingFn).toHaveBeenCalledTimes(3); // Initial + 2 retries
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retry specific error types with custom shouldRetry', async () => {
|
||||||
|
const temporaryError = new Error('Temporary failure');
|
||||||
|
temporaryError.name = 'TemporaryError';
|
||||||
|
const permanentError = new Error('Permanent failure');
|
||||||
|
permanentError.name = 'PermanentError';
|
||||||
|
|
||||||
|
const failingFn = vi.fn()
|
||||||
|
.mockRejectedValueOnce(temporaryError)
|
||||||
|
.mockRejectedValueOnce(temporaryError)
|
||||||
|
.mockRejectedValueOnce(permanentError);
|
||||||
|
|
||||||
|
await expect(retry(failingFn, {
|
||||||
|
times: 5,
|
||||||
|
shouldRetry: (error) => error.name === 'TemporaryError'
|
||||||
|
})).rejects.toThrow('Permanent failure');
|
||||||
|
|
||||||
|
expect(failingFn).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wait for the specified delay between retries', async () => {
|
||||||
|
const failingFn = vi.fn().mockRejectedValue(new Error('Test error'));
|
||||||
|
|
||||||
|
const retryPromise = retry(failingFn, { times: 3, delay: 1000 });
|
||||||
|
const result = expect(retryPromise).rejects.toThrow('Test error');
|
||||||
|
|
||||||
|
// First call should happen immediately
|
||||||
|
expect(failingFn).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Advance time to trigger first retry
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
expect(failingFn).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// Advance time to trigger second retry
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
expect(failingFn).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
await result;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('use dynamic delay function', async () => {
|
||||||
|
const failingFn = vi.fn().mockRejectedValue(new Error('Test error'));
|
||||||
|
const delayFn = vi.fn((count: number) => count * 500);
|
||||||
|
|
||||||
|
const retryPromise = retry(failingFn, { times: 3, delay: delayFn });
|
||||||
|
const result = expect(retryPromise).rejects.toThrow('Test error');
|
||||||
|
|
||||||
|
// First call should happen immediately
|
||||||
|
expect(failingFn).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// First retry should wait for delay(2) = 1000ms
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
expect(failingFn).toHaveBeenCalledTimes(2);
|
||||||
|
expect(delayFn).toHaveBeenCalledWith(2);
|
||||||
|
|
||||||
|
// Second retry should wait for delay(3) = 1500ms
|
||||||
|
await vi.advanceTimersByTimeAsync(1500);
|
||||||
|
expect(failingFn).toHaveBeenCalledTimes(3);
|
||||||
|
expect(delayFn).toHaveBeenCalledWith(3);
|
||||||
|
|
||||||
|
await result;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('not delay after the last attempt', async () => {
|
||||||
|
const failingFn = vi.fn().mockRejectedValue(new Error('Test error'));
|
||||||
|
|
||||||
|
const retryPromise = retry(failingFn, { times: 2, delay: 1000 });
|
||||||
|
const result = expect(retryPromise).rejects.toThrow('Test error');
|
||||||
|
|
||||||
|
// Wait for the first retry delay
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
|
||||||
|
// Should complete without further delays
|
||||||
|
await result;
|
||||||
|
expect(failingFn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handle zero delay', async () => {
|
||||||
|
const failingFn = vi.fn().mockRejectedValue(new Error('Test error'));
|
||||||
|
|
||||||
|
await expect(retry(failingFn, { times: 3, delay: 0 })).rejects.toThrow('Test error');
|
||||||
|
|
||||||
|
expect(failingFn).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pass the count parameter to the function', async () => {
|
||||||
|
const countingFn = vi.fn(async ({ count }: { count: number }) => {
|
||||||
|
if (count < 3) {
|
||||||
|
throw new Error(`Attempt ${count} failed`);
|
||||||
|
}
|
||||||
|
return `Success on attempt ${count}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await retry(countingFn, { times: 3 });
|
||||||
|
|
||||||
|
expect(result).toBe('Success on attempt 3');
|
||||||
|
expect(countingFn).toHaveBeenCalledWith({ count: 1, stop: expect.any(Function) });
|
||||||
|
expect(countingFn).toHaveBeenCalledWith({ count: 2, stop: expect.any(Function) });
|
||||||
|
expect(countingFn).toHaveBeenCalledWith({ count: 3, stop: expect.any(Function) });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throw the last error when all attempts fail', async () => {
|
||||||
|
const firstError = new Error('First error');
|
||||||
|
const lastError = new Error('Last error');
|
||||||
|
const failingFn = vi.fn()
|
||||||
|
.mockRejectedValueOnce(firstError)
|
||||||
|
.mockRejectedValueOnce(lastError);
|
||||||
|
|
||||||
|
await expect(retry(failingFn, { times: 2 })).rejects.toThrow('Last error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handle times value of 1', async () => {
|
||||||
|
const failingFn = vi.fn().mockRejectedValue(new Error('Test error'));
|
||||||
|
|
||||||
|
await expect(retry(failingFn, { times: 1 })).rejects.toThrow('Test error');
|
||||||
|
|
||||||
|
expect(failingFn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handle function that returns non-promise values', async () => {
|
||||||
|
const syncFn = vi.fn(async ({ count }: { count: number }) => {
|
||||||
|
if (count === 1) {
|
||||||
|
throw new Error('First attempt failed');
|
||||||
|
}
|
||||||
|
return 'success';
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await retry(syncFn, { times: 2 });
|
||||||
|
|
||||||
|
expect(result).toBe('success');
|
||||||
|
expect(syncFn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handle complex return types', async () => {
|
||||||
|
const complexFn = vi.fn().mockResolvedValue({
|
||||||
|
data: [1, 2, 3],
|
||||||
|
status: 'ok',
|
||||||
|
metadata: { timestamp: 123456 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await retry(complexFn);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
data: [1, 2, 3],
|
||||||
|
status: 'ok',
|
||||||
|
metadata: { timestamp: 123456 }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stop retrying when stop function is called', async () => {
|
||||||
|
const customError = new Error('Custom stop error');
|
||||||
|
const stopFn = vi.fn(async ({ count, stop }: { count: number, stop: (error: any) => void }) => {
|
||||||
|
if (count === 2) {
|
||||||
|
stop(customError);
|
||||||
|
}
|
||||||
|
throw new Error(`Attempt ${count} failed`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(retry(stopFn, { times: 5 })).rejects.toThrow('Custom stop error');
|
||||||
|
|
||||||
|
expect(stopFn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stop retrying with undefined error when stop is called without argument', async () => {
|
||||||
|
const stopFn = vi.fn(async ({ count, stop }: { count: number, stop: (error?: any) => void }) => {
|
||||||
|
if (count === 2) {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
throw new Error(`Attempt ${count} failed`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(retry(stopFn, { times: 5 })).rejects.toBeUndefined();
|
||||||
|
|
||||||
|
expect(stopFn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,25 @@
|
|||||||
// eslint-disable
|
import { tryIt } from '../tryIt';
|
||||||
|
import { sleep } from '../sleep';
|
||||||
|
import { isFunction } from '../../types';
|
||||||
|
|
||||||
export interface RetryOptions {
|
export interface RetryOptions {
|
||||||
times?: number;
|
times?: number;
|
||||||
delay?: number;
|
delay?: number | ((count: number) => number);
|
||||||
backoff: (options: RetryOptions & { count: number }) => number;
|
shouldRetry?: (error: Error, count: number) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RetryFunction<Return> = (
|
||||||
|
args: {
|
||||||
|
count: number;
|
||||||
|
stop: (error: any) => void;
|
||||||
|
},
|
||||||
|
) => Promise<Return>;
|
||||||
|
|
||||||
|
class RetryEarlyExitError {
|
||||||
|
cause: any;
|
||||||
|
constructor(cause: any) {
|
||||||
|
this.cause = cause;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,14 +43,53 @@ export interface RetryOptions {
|
|||||||
* .then(response => response.json())
|
* .then(response => response.json())
|
||||||
* }, { times: 3, delay: 1000 });
|
* }, { times: 3, delay: 1000 });
|
||||||
*
|
*
|
||||||
|
* @since 0.0.8
|
||||||
*/
|
*/
|
||||||
export async function retry<Return>(
|
export async function retry<Return>(
|
||||||
fn: () => Promise<Return>,
|
fn: RetryFunction<Return>,
|
||||||
options: RetryOptions
|
options: RetryOptions = {},
|
||||||
) {
|
): Promise<Return> {
|
||||||
const {
|
const {
|
||||||
times = 3,
|
times = 2,
|
||||||
|
delay = 0,
|
||||||
|
shouldRetry,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
let count = 0;
|
const wrappedFn = tryIt(fn);
|
||||||
|
const delayFn = isFunction(delay) ? delay : null;
|
||||||
|
const delayMs = delayFn ? 0 : delay as number;
|
||||||
|
|
||||||
|
const stop = (error?: any): never => {
|
||||||
|
throw new RetryEarlyExitError(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
let count = 1;
|
||||||
|
|
||||||
|
while (count <= times) {
|
||||||
|
const { error, data } = await wrappedFn({ count, stop });
|
||||||
|
|
||||||
|
if (!error)
|
||||||
|
return data;
|
||||||
|
|
||||||
|
if (error instanceof RetryEarlyExitError)
|
||||||
|
throw error.cause;
|
||||||
|
|
||||||
|
if (shouldRetry && !shouldRetry(error, count))
|
||||||
|
throw error;
|
||||||
|
|
||||||
|
lastError = error;
|
||||||
|
count++;
|
||||||
|
|
||||||
|
// Don't delay after the last attempt
|
||||||
|
if (count <= times) {
|
||||||
|
const ms = delayFn ? delayFn(count) : delayMs;
|
||||||
|
|
||||||
|
if (ms > 0)
|
||||||
|
await sleep(ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line eslint/no-throw-literal
|
||||||
|
throw lastError!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ describe('tryIt', () => {
|
|||||||
const syncFn = (x: number) => x * 2;
|
const syncFn = (x: number) => x * 2;
|
||||||
const wrappedSyncFn = tryIt(syncFn);
|
const wrappedSyncFn = tryIt(syncFn);
|
||||||
|
|
||||||
const [error, result] = wrappedSyncFn(2);
|
const { error, data } = wrappedSyncFn(2);
|
||||||
|
|
||||||
expect(error).toBeUndefined();
|
expect(error).toBeUndefined();
|
||||||
expect(result).toBe(4);
|
expect(data).toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handle synchronous functions with errors', () => {
|
it('handle synchronous functions with errors', () => {
|
||||||
@@ -18,21 +18,21 @@ describe('tryIt', () => {
|
|||||||
};
|
};
|
||||||
const wrappedSyncFn = tryIt(syncFn);
|
const wrappedSyncFn = tryIt(syncFn);
|
||||||
|
|
||||||
const [error, result] = wrappedSyncFn();
|
const { error, data } = wrappedSyncFn();
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(Error);
|
expect(error).toBeInstanceOf(Error);
|
||||||
expect(error?.message).toBe('Test error');
|
expect(error?.message).toBe('Test error');
|
||||||
expect(result).toBeUndefined();
|
expect(data).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handle asynchronous functions without errors', async () => {
|
it('handle asynchronous functions without errors', async () => {
|
||||||
const asyncFn = async (x: number) => x * 2;
|
const asyncFn = async (x: number) => x * 2;
|
||||||
const wrappedAsyncFn = tryIt(asyncFn);
|
const wrappedAsyncFn = tryIt(asyncFn);
|
||||||
|
|
||||||
const [error, result] = await wrappedAsyncFn(2);
|
const { error, data } = await wrappedAsyncFn(2);
|
||||||
|
|
||||||
expect(error).toBeUndefined();
|
expect(error).toBeUndefined();
|
||||||
expect(result).toBe(4);
|
expect(data).toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handle asynchronous functions with errors', async () => {
|
it('handle asynchronous functions with errors', async () => {
|
||||||
@@ -41,31 +41,31 @@ describe('tryIt', () => {
|
|||||||
};
|
};
|
||||||
const wrappedAsyncFn = tryIt(asyncFn);
|
const wrappedAsyncFn = tryIt(asyncFn);
|
||||||
|
|
||||||
const [error, result] = await wrappedAsyncFn();
|
const { error, data } = await wrappedAsyncFn();
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(Error);
|
expect(error).toBeInstanceOf(Error);
|
||||||
expect(error?.message).toBe('Test error');
|
expect(error?.message).toBe('Test error');
|
||||||
expect(result).toBeUndefined();
|
expect(data).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handle promise-based functions without errors', async () => {
|
it('handle promise-based functions without errors', async () => {
|
||||||
const promiseFn = (x: number) => Promise.resolve(x * 2);
|
const promiseFn = (x: number) => Promise.resolve(x * 2);
|
||||||
const wrappedPromiseFn = tryIt(promiseFn);
|
const wrappedPromiseFn = tryIt(promiseFn);
|
||||||
|
|
||||||
const [error, result] = await wrappedPromiseFn(2);
|
const { error, data } = await wrappedPromiseFn(2);
|
||||||
|
|
||||||
expect(error).toBeUndefined();
|
expect(error).toBeUndefined();
|
||||||
expect(result).toBe(4);
|
expect(data).toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handle promise-based functions with errors', async () => {
|
it('handle promise-based functions with errors', async () => {
|
||||||
const promiseFn = () => Promise.reject(new Error('Test error'));
|
const promiseFn = () => Promise.reject(new Error('Test error'));
|
||||||
const wrappedPromiseFn = tryIt(promiseFn);
|
const wrappedPromiseFn = tryIt(promiseFn);
|
||||||
|
|
||||||
const [error, result] = await wrappedPromiseFn();
|
const { error, data } = await wrappedPromiseFn();
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(Error);
|
expect(error).toBeInstanceOf(Error);
|
||||||
expect(error?.message).toBe('Test error');
|
expect(error?.message).toBe('Test error');
|
||||||
expect(result).toBeUndefined();
|
expect(data).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { isPromise } from '../../types';
|
import { isPromise } from '../../types';
|
||||||
|
|
||||||
export type TryItReturn<Return> = Return extends Promise<any>
|
export type TryItReturn<Return> = Return extends Promise<any>
|
||||||
? Promise<[Error, undefined] | [undefined, Awaited<Return>]>
|
? Promise<{ error: Error; data: undefined } | { error: undefined; data: Awaited<Return> }>
|
||||||
: [Error, undefined] | [undefined, Return];
|
: { error: Error; data: undefined } | { error: undefined; data: Return };
|
||||||
|
|
||||||
|
function onResolve(data: any) { return { error: undefined, data }; }
|
||||||
|
function onReject(error: any) { return { error, data: undefined }; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @name tryIt
|
* @name tryIt
|
||||||
@@ -14,10 +17,10 @@ export type TryItReturn<Return> = Return extends Promise<any>
|
|||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const wrappedFetch = tryIt(fetch);
|
* const wrappedFetch = tryIt(fetch);
|
||||||
* const [error, result] = await wrappedFetch('https://jsonplaceholder.typicode.com/todos/1');
|
* const { error, data } = await wrappedFetch('https://jsonplaceholder.typicode.com/todos/1');
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const [error, result] = await tryIt(fetch)('https://jsonplaceholder.typicode.com/todos/1');
|
* const { error, data } = await tryIt(fetch)('https://jsonplaceholder.typicode.com/todos/1');
|
||||||
*
|
*
|
||||||
* @since 0.0.3
|
* @since 0.0.3
|
||||||
*/
|
*/
|
||||||
@@ -29,14 +32,11 @@ export function tryIt<Args extends any[], Return>(
|
|||||||
const result = fn(...args);
|
const result = fn(...args);
|
||||||
|
|
||||||
if (isPromise(result))
|
if (isPromise(result))
|
||||||
return result
|
return result.then(onResolve, onReject) as TryItReturn<Return>;
|
||||||
.then(value => [undefined, value])
|
|
||||||
.catch(error => [error, undefined]) as TryItReturn<Return>;
|
|
||||||
|
|
||||||
return [undefined, result] as TryItReturn<Return>;
|
return { error: undefined, data: result } as TryItReturn<Return>;
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
return { error, data: undefined } as TryItReturn<Return>;
|
||||||
return [error, undefined] as TryItReturn<Return>;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
+2966
-3025
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
projects: [
|
projects: [
|
||||||
'configs/oxlint/vitest.config.ts',
|
'configs/oxlint/vitest.config.ts',
|
||||||
|
'core/fetch/vitest.config.ts',
|
||||||
'core/stdlib/vitest.config.ts',
|
'core/stdlib/vitest.config.ts',
|
||||||
'core/platform/vitest.config.ts',
|
'core/platform/vitest.config.ts',
|
||||||
'vue/toolkit/vitest.config.ts',
|
'vue/toolkit/vitest.config.ts',
|
||||||
|
|||||||
Reference in New Issue
Block a user