diff --git a/core/fetch/jsr.json b/core/fetch/jsr.json new file mode 100644 index 0000000..ae321cf --- /dev/null +++ b/core/fetch/jsr.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://jsr.io/schema/config-file.v1.json", + "name": "@robonen/fetch", + "version": "0.0.1", + "exports": "./src/index.ts" +} diff --git a/core/fetch/oxlint.config.ts b/core/fetch/oxlint.config.ts new file mode 100644 index 0000000..65e6f39 --- /dev/null +++ b/core/fetch/oxlint.config.ts @@ -0,0 +1,4 @@ +import { defineConfig } from 'oxlint'; +import { base, compose, imports, stylistic, typescript } from '@robonen/oxlint'; + +export default defineConfig(compose(base, typescript, imports, stylistic)); diff --git a/core/fetch/package.json b/core/fetch/package.json new file mode 100644 index 0000000..66bd893 --- /dev/null +++ b/core/fetch/package.json @@ -0,0 +1,53 @@ +{ + "name": "@robonen/fetch", + "version": "0.0.1", + "license": "Apache-2.0", + "description": "A lightweight, type-safe fetch wrapper with interceptors, retry, and V8-optimized internals", + "keywords": [ + "fetch", + "http", + "request", + "tools" + ], + "author": "Robonen Andrew ", + "repository": { + "type": "git", + "url": "git+https://github.com/robonen/tools.git", + "directory": "core/fetch" + }, + "packageManager": "pnpm@10.30.3", + "engines": { + "node": ">=24.13.1" + }, + "type": "module", + "files": [ + "dist" + ], + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "scripts": { + "lint:check": "oxlint -c oxlint.config.ts", + "lint:fix": "oxlint -c oxlint.config.ts --fix", + "test": "vitest run", + "dev": "vitest dev", + "build": "tsdown" + }, + "devDependencies": { + "@robonen/oxlint": "workspace:*", + "@robonen/tsconfig": "workspace:*", + "@robonen/tsdown": "workspace:*", + "@stylistic/eslint-plugin": "catalog:", + "oxlint": "catalog:", + "tsdown": "catalog:" + } +} diff --git a/core/fetch/src/error.test.ts b/core/fetch/src/error.test.ts new file mode 100644 index 0000000..b76b364 --- /dev/null +++ b/core/fetch/src/error.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { FetchError, createFetchError } from './error'; +import type { FetchContext } from './types'; + +function makeContext(overrides: Partial = {}): FetchContext { + 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'); + }); +}); diff --git a/core/fetch/src/error.ts b/core/fetch/src/error.ts new file mode 100644 index 0000000..b443203 --- /dev/null +++ b/core/fetch/src/error.ts @@ -0,0 +1,70 @@ +import type { FetchContext, FetchOptions, FetchRequest, FetchResponse, IFetchError } from './types'; + +/** + * @name FetchError + * @category Fetch + * @description Error thrown by $fetch on network failures or non-2xx responses + * + * @since 0.0.1 + */ +export class FetchError extends Error implements IFetchError { + request?: FetchRequest; + options?: FetchOptions; + response?: FetchResponse; + data?: T; + status?: number; + statusText?: string; + statusCode?: number; + statusMessage?: string; + + constructor(message: string) { + super(message); + this.name = 'FetchError'; + } +} + +/** + * @name createFetchError + * @category Fetch + * @description Builds a FetchError from a FetchContext, extracting URL, status, and error message + * + * @param {FetchContext} context - The context at the point of failure + * @returns {FetchError} A populated FetchError instance + * + * @since 0.0.1 + */ +export function createFetchError(context: FetchContext): FetchError { + const url + = typeof context.request === 'string' + ? context.request + : context.request instanceof URL + ? context.request.href + : (context.request as Request).url; + + const statusPart = context.response + ? `${context.response.status} ${context.response.statusText}` + : ''; + + const errorPart = context.error?.message ?? ''; + + // Build message from non-empty parts + let message = url; + if (statusPart) message += ` ${statusPart}`; + if (errorPart) message += `: ${errorPart}`; + + const error = new FetchError(message); + + error.request = context.request; + error.options = context.options; + + if (context.response !== undefined) { + error.response = context.response; + error.data = context.response._data; + error.status = context.response.status; + error.statusText = context.response.statusText; + error.statusCode = context.response.status; + error.statusMessage = context.response.statusText; + } + + return error; +} diff --git a/core/fetch/src/fetch.test.ts b/core/fetch/src/fetch.test.ts new file mode 100644 index 0000000..35eb67a --- /dev/null +++ b/core/fetch/src/fetch.test.ts @@ -0,0 +1,526 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FetchError } from './error'; +import { createFetch } from './fetch'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeFetchMock( + body: unknown = { ok: true }, + init: ResponseInit = { status: 200 }, + contentType = 'application/json', +): ReturnType { + return vi.fn().mockResolvedValue( + new Response(typeof body === 'string' ? body : JSON.stringify(body), { + ...init, + headers: { 'content-type': contentType, ...init.headers }, + }), + ); +} + +// --------------------------------------------------------------------------- +// Basic fetch +// --------------------------------------------------------------------------- + +describe('createFetch — basic', () => { + it('returns parsed JSON body', async () => { + const fetchMock = makeFetchMock({ id: 1 }); + const $fetch = createFetch({ fetch: fetchMock }); + + const data = await $fetch<{ id: number }>('https://api.example.com/user'); + + expect(data).toEqual({ id: 1 }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('passes options through to the underlying fetch', async () => { + const fetchMock = makeFetchMock({ done: true }); + const $fetch = createFetch({ fetch: fetchMock }); + + await $fetch('https://api.example.com/task', { + method: 'POST', + headers: { 'x-token': 'abc' }, + }); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect((init.headers as Headers).get('x-token')).toBe('abc'); + expect(init.method).toBe('POST'); + }); + + it('uppercases the HTTP method', async () => { + const fetchMock = makeFetchMock({}); + const $fetch = createFetch({ fetch: fetchMock }); + + await $fetch('https://api.example.com', { method: 'post' }); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.method).toBe('POST'); + }); +}); + +// --------------------------------------------------------------------------- +// raw +// --------------------------------------------------------------------------- + +describe('$fetch.raw', () => { + it('returns a Response with _data', async () => { + const fetchMock = makeFetchMock({ value: 42 }); + const $fetch = createFetch({ fetch: fetchMock }); + + const response = await $fetch.raw<{ value: number }>('https://api.example.com'); + + expect(response).toBeInstanceOf(Response); + expect(response._data).toEqual({ value: 42 }); + expect(response.status).toBe(200); + }); +}); + +// --------------------------------------------------------------------------- +// Method shortcuts +// --------------------------------------------------------------------------- + +describe('method shortcuts', () => { + it('$fetch.get sends a GET request', async () => { + const fetchMock = makeFetchMock({}); + const $fetch = createFetch({ fetch: fetchMock }); + await $fetch.get('https://api.example.com/items'); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.method).toBe('GET'); + }); + + it('$fetch.post sends a POST request', async () => { + const fetchMock = makeFetchMock({}); + const $fetch = createFetch({ fetch: fetchMock }); + await $fetch.post('https://api.example.com/items', { body: { name: 'x' } }); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.method).toBe('POST'); + }); + + it('$fetch.put sends a PUT request', async () => { + const fetchMock = makeFetchMock({}); + const $fetch = createFetch({ fetch: fetchMock }); + await $fetch.put('https://api.example.com/items/1', { body: { name: 'y' } }); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.method).toBe('PUT'); + }); + + it('$fetch.patch sends a PATCH request', async () => { + const fetchMock = makeFetchMock({}); + const $fetch = createFetch({ fetch: fetchMock }); + await $fetch.patch('https://api.example.com/items/1', { body: { name: 'z' } }); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.method).toBe('PATCH'); + }); + + it('$fetch.delete sends a DELETE request', async () => { + const fetchMock = makeFetchMock({}); + const $fetch = createFetch({ fetch: fetchMock }); + await $fetch.delete('https://api.example.com/items/1'); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.method).toBe('DELETE'); + }); +}); + +// --------------------------------------------------------------------------- +// baseURL +// --------------------------------------------------------------------------- + +describe('baseURL', () => { + it('prepends baseURL to a relative path', async () => { + const fetchMock = makeFetchMock({}); + const $fetch = createFetch({ fetch: fetchMock }); + + await $fetch('/users', { baseURL: 'https://api.example.com/v1' }); + + const [url] = fetchMock.mock.calls[0] as [string]; + expect(url).toBe('https://api.example.com/v1/users'); + }); + + it('inherits baseURL from create() defaults', async () => { + const fetchMock = makeFetchMock({}); + const api = createFetch({ fetch: fetchMock }).create({ baseURL: 'https://api.example.com' }); + + await api('/health'); + + const [url] = fetchMock.mock.calls[0] as [string]; + expect(url).toBe('https://api.example.com/health'); + }); +}); + +// --------------------------------------------------------------------------- +// Query params +// --------------------------------------------------------------------------- + +describe('query params', () => { + it('appends query to the request URL', async () => { + const fetchMock = makeFetchMock([]); + const $fetch = createFetch({ fetch: fetchMock }); + + await $fetch('https://api.example.com/items', { query: { page: 2, limit: 10 } }); + + const [url] = fetchMock.mock.calls[0] as [string]; + expect(url).toContain('page=2'); + expect(url).toContain('limit=10'); + }); + + it('merges default query with per-request query', async () => { + const fetchMock = makeFetchMock([]); + const $fetch = createFetch({ fetch: fetchMock }).create({ + baseURL: 'https://api.example.com', + query: { version: 2 }, + }); + + await $fetch('/items', { query: { page: 1 } }); + + const [url] = fetchMock.mock.calls[0] as [string]; + expect(url).toContain('version=2'); + expect(url).toContain('page=1'); + }); +}); + +// --------------------------------------------------------------------------- +// JSON body serialisation +// --------------------------------------------------------------------------- + +describe('JSON body serialisation', () => { + it('serialises plain objects and sets content-type to application/json', async () => { + const fetchMock = makeFetchMock({ ok: true }); + const $fetch = createFetch({ fetch: fetchMock }); + + await $fetch('https://api.example.com/users', { + method: 'POST', + body: { name: 'Alice' }, + }); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.body).toBe('{"name":"Alice"}'); + expect((init.headers as Headers).get('content-type')).toBe('application/json'); + }); + + it('respects a pre-set content-type header', async () => { + const fetchMock = makeFetchMock({ ok: true }); + const $fetch = createFetch({ fetch: fetchMock }); + + await $fetch('https://api.example.com/form', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: { key: 'value' }, + }); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.body).toBe('key=value'); + }); +}); + +// --------------------------------------------------------------------------- +// Error handling +// --------------------------------------------------------------------------- + +describe('error handling', () => { + it('throws FetchError on 4xx response', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('{"error":"not found"}', { + status: 404, + statusText: 'Not Found', + headers: { 'content-type': 'application/json' }, + }), + ); + const $fetch = createFetch({ fetch: fetchMock }); + + await expect($fetch('https://api.example.com/missing')).rejects.toBeInstanceOf(FetchError); + }); + + it('throws FetchError on 5xx response', async () => { + // Use mockImplementation so each retry attempt gets a fresh Response (body not yet read) + const fetchMock = vi + .fn() + .mockImplementation(async () => new Response('Internal Server Error', { status: 500, statusText: 'Internal Server Error' })); + const $fetch = createFetch({ fetch: fetchMock }); + + await expect($fetch('https://api.example.com/crash')).rejects.toThrow(FetchError); + }); + + it('does not throw when ignoreResponseError is true', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('{"error":"bad request"}', { + status: 400, + headers: { 'content-type': 'application/json' }, + }), + ); + const $fetch = createFetch({ fetch: fetchMock }); + + await expect( + $fetch('https://api.example.com/bad', { ignoreResponseError: true }), + ).resolves.toEqual({ error: 'bad request' }); + }); + + it('throws FetchError on network error', async () => { + const fetchMock = vi.fn().mockRejectedValue(new TypeError('Failed to fetch')); + const $fetch = createFetch({ fetch: fetchMock }); + + await expect($fetch('https://api.example.com/offline')).rejects.toBeInstanceOf(FetchError); + }); +}); + +// --------------------------------------------------------------------------- +// Retry +// --------------------------------------------------------------------------- + +describe('retry', () => { + it('retries once on 500 by default for GET', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response('error', { status: 500 })) + .mockResolvedValueOnce( + new Response('{"ok":true}', { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + const $fetch = createFetch({ fetch: fetchMock }); + + const data = await $fetch('https://api.example.com/flaky'); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(data).toEqual({ ok: true }); + }); + + it('does not retry POST by default', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response('error', { status: 500 })); + const $fetch = createFetch({ fetch: fetchMock }); + + await expect( + $fetch('https://api.example.com/task', { method: 'POST' }), + ).rejects.toBeInstanceOf(FetchError); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('respects retry: false', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response('error', { status: 503 })); + const $fetch = createFetch({ fetch: fetchMock }); + + await expect( + $fetch('https://api.example.com/flaky', { retry: false }), + ).rejects.toBeInstanceOf(FetchError); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('respects custom retryStatusCodes', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response('', { status: 418 })) + .mockResolvedValueOnce( + new Response('{"ok":true}', { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + const $fetch = createFetch({ fetch: fetchMock }); + + const data = await $fetch('https://api.example.com/teapot', { + retryStatusCodes: [418], + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(data).toEqual({ ok: true }); + }); +}); + +// --------------------------------------------------------------------------- +// Lifecycle hooks +// --------------------------------------------------------------------------- + +describe('lifecycle hooks', () => { + it('calls onRequest before sending', async () => { + const fetchMock = makeFetchMock({}); + const $fetch = createFetch({ fetch: fetchMock }); + const calls: string[] = []; + + await $fetch('https://api.example.com', { + onRequest: () => { + calls.push('request'); + }, + }); + + expect(calls).toContain('request'); + expect(calls.indexOf('request')).toBeLessThan(1); + }); + + it('calls onResponse after a successful response', async () => { + const fetchMock = makeFetchMock({ data: 1 }); + const $fetch = createFetch({ fetch: fetchMock }); + const calls: string[] = []; + + await $fetch('https://api.example.com', { + onResponse: () => { + calls.push('response'); + }, + }); + + expect(calls).toContain('response'); + }); + + it('calls onResponseError for 4xx responses', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response('', { status: 401 })); + const $fetch = createFetch({ fetch: fetchMock }); + const calls: string[] = []; + + await expect( + $fetch('https://api.example.com/protected', { + retry: false, + onResponseError: () => { + calls.push('responseError'); + }, + }), + ).rejects.toBeInstanceOf(FetchError); + + expect(calls).toContain('responseError'); + }); + + it('calls onRequestError on network failure', async () => { + const fetchMock = vi.fn().mockRejectedValue(new TypeError('Network error')); + const $fetch = createFetch({ fetch: fetchMock }); + const calls: string[] = []; + + await expect( + $fetch('https://api.example.com/offline', { + retry: false, + onRequestError: () => { + calls.push('requestError'); + }, + }), + ).rejects.toBeInstanceOf(FetchError); + + expect(calls).toContain('requestError'); + }); + + it('supports multiple hooks as an array', async () => { + const fetchMock = makeFetchMock({}); + const $fetch = createFetch({ fetch: fetchMock }); + const calls: number[] = []; + + await $fetch('https://api.example.com', { + onRequest: [ + () => { + calls.push(1); + }, + () => { + calls.push(2); + }, + ], + }); + + expect(calls).toEqual([1, 2]); + }); +}); + +// --------------------------------------------------------------------------- +// create / extend +// --------------------------------------------------------------------------- + +describe('create and extend', () => { + it('creates a new instance with merged defaults', async () => { + const fetchMock = makeFetchMock({}); + const $fetch = createFetch({ fetch: fetchMock }); + const api = $fetch.create({ baseURL: 'https://api.example.com' }); + + await api('/ping'); + + const [url] = fetchMock.mock.calls[0] as [string]; + expect(url).toBe('https://api.example.com/ping'); + }); + + it('extend is an alias for create', async () => { + const fetchMock = makeFetchMock({}); + const $fetch = createFetch({ fetch: fetchMock }); + const api = $fetch.extend({ baseURL: 'https://api.example.com' }); + + await api('/ping'); + + const [url] = fetchMock.mock.calls[0] as [string]; + expect(url).toBe('https://api.example.com/ping'); + }); +}); + +// --------------------------------------------------------------------------- +// Response type variants +// --------------------------------------------------------------------------- + +describe('response types', () => { + it('returns text when responseType is "text"', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response('hello world', { headers: { 'content-type': 'text/plain' } })); + const $fetch = createFetch({ fetch: fetchMock }); + + const text = await $fetch('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('https://api.example.com/img', { + responseType: 'blob', + }); + + expect(blob).toBeInstanceOf(Blob); + }); + + it('uses a custom parseResponse function', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response('{"value":10}', { headers: { 'content-type': 'application/json' } }), + ); + const $fetch = createFetch({ fetch: fetchMock }); + + const data = await $fetch<{ value: number }>('https://api.example.com/custom', { + parseResponse: text => ({ ...JSON.parse(text) as object, custom: true }), + }); + + expect(data).toEqual({ value: 10, custom: true }); + }); +}); + +// --------------------------------------------------------------------------- +// Timeout +// --------------------------------------------------------------------------- + +describe('timeout', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('aborts a request that exceeds the timeout', async () => { + // fetchMock that never resolves until the signal fires + const fetchMock = vi.fn().mockImplementation((_url: string, init: RequestInit) => { + return new Promise((_resolve, reject) => { + (init.signal as AbortSignal).addEventListener('abort', () => { + reject(new DOMException('The operation was aborted.', 'AbortError')); + }); + }); + }); + + const $fetch = createFetch({ fetch: fetchMock }); + + const promise = $fetch('https://api.example.com/slow', { timeout: 100, retry: false }); + + vi.advanceTimersByTime(200); + + await expect(promise).rejects.toBeInstanceOf(FetchError); + }); +}); diff --git a/core/fetch/src/fetch.ts b/core/fetch/src/fetch.ts new file mode 100644 index 0000000..8af4a3b --- /dev/null +++ b/core/fetch/src/fetch.ts @@ -0,0 +1,325 @@ +import type { ResponseMap, $Fetch, CreateFetchOptions, FetchContext, FetchOptions, FetchRequest, FetchResponse, ResponseType } from './types'; +import { createFetchError } from './error'; +import { + NULL_BODY_STATUSES, + buildURL, + callHooks, + detectResponseType, + isJSONSerializable, + isPayloadMethod, + joinURL, + resolveFetchOptions, +} from './utils'; + +// --------------------------------------------------------------------------- +// V8: module-level Set — initialised once, never mutated, allows V8 to +// embed the set reference as a constant in compiled code. +// --------------------------------------------------------------------------- + +/** HTTP status codes that trigger automatic retry by default */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment +const DEFAULT_RETRY_STATUS_CODES: ReadonlySet = /* @__PURE__ */ new Set([ + 408, // Request Timeout + 409, // Conflict + 425, // Too Early (Experimental) + 429, // Too Many Requests + 500, // Internal Server Error + 502, // Bad Gateway + 503, // Service Unavailable + 504, // Gateway Timeout +]); + +// --------------------------------------------------------------------------- +// createFetch +// --------------------------------------------------------------------------- + +/** + * @name createFetch + * @category Fetch + * @description Creates a configured $fetch instance + * + * V8 optimisation notes: + * - All inner objects are created with a fixed property set so V8 can reuse + * their hidden class across invocations (no dynamic property additions). + * - `Error.captureStackTrace` is called only when available (V8 / Node.js) + * to produce clean stack traces without internal frames. + * - Retry and timeout paths avoid allocating closures on the hot path. + * - `NULL_BODY_STATUSES` / `DEFAULT_RETRY_STATUS_CODES` are frozen module- + * level Sets, so their `.has()` calls are always monomorphic. + * + * @param {CreateFetchOptions} [globalOptions={}] - Global defaults and custom fetch implementation + * @returns {$Fetch} Configured fetch instance + * + * @since 0.0.1 + */ +export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch { + const fetchImpl = globalOptions.fetch ?? globalThis.fetch; + + // ------------------------------------------------------------------------- + // Error handler — shared between network errors and 4xx/5xx responses + // ------------------------------------------------------------------------- + + async function onError(context: FetchContext): Promise> { + // Explicit user-triggered abort should not be retried automatically + const isAbort + = context.error !== undefined + && context.error.name === 'AbortError' + && context.options.timeout === undefined; + + if (!isAbort && context.options.retry !== false) { + // Default retry count: 0 for payload methods, 1 for idempotent methods + const maxRetries + = typeof context.options.retry === 'number' + ? context.options.retry + : isPayloadMethod(context.options.method ?? 'GET') + ? 0 + : 1; + + if (maxRetries > 0) { + const responseStatus = context.response?.status ?? 500; + const retryStatusCodes = context.options.retryStatusCodes; + const shouldRetry + = retryStatusCodes !== undefined + ? retryStatusCodes.includes(responseStatus) + : DEFAULT_RETRY_STATUS_CODES.has(responseStatus); + + if (shouldRetry) { + const retryDelay + = typeof context.options.retryDelay === 'function' + ? context.options.retryDelay(context) + : (context.options.retryDelay ?? 0); + + if (retryDelay > 0) { + await new Promise((resolve) => { + setTimeout(resolve, retryDelay); + }); + } + + return $fetchRaw(context.request, { + ...context.options, + retry: maxRetries - 1, + }); + } + } + } + + const error = createFetchError(context); + + // V8 / Node.js — clip internal frames from the error stack trace + if (typeof Error.captureStackTrace === 'function') { + Error.captureStackTrace(error, $fetchRaw); + } + + throw error; + } + + // ------------------------------------------------------------------------- + // $fetchRaw — returns the full Response object with a parsed `_data` field + // ------------------------------------------------------------------------- + + const $fetchRaw: $Fetch['raw'] = async function $fetchRaw< + T = unknown, + R extends ResponseType = 'json', + >( + _request: FetchRequest, + _options: FetchOptions = {} as FetchOptions, + ): Promise> { + // V8: object literal with a fixed shape — V8 allocates a single hidden + // class for all context objects created by this function. + const context: FetchContext = { + request: _request, + options: resolveFetchOptions( + _request, + _options, + globalOptions.defaults as FetchOptions, + ), + response: undefined, + error: undefined, + }; + + // Normalise method to uppercase before any hook or header logic + if (context.options.method !== undefined) { + context.options.method = context.options.method.toUpperCase(); + } + + if (context.options.onRequest !== undefined) { + await callHooks(context, context.options.onRequest); + } + + // URL transformations — only when request is a plain string + if (typeof context.request === 'string') { + if (context.options.baseURL !== undefined) { + context.request = joinURL(context.options.baseURL, context.request); + } + + const query = context.options.query ?? context.options.params; + if (query !== undefined) { + context.request = buildURL(context.request, query); + } + } + + // Body serialisation + const method = context.options.method ?? 'GET'; + if (context.options.body !== undefined && context.options.body !== null && isPayloadMethod(method)) { + if (isJSONSerializable(context.options.body)) { + const contentType = context.options.headers.get('content-type'); + + if (typeof context.options.body !== 'string') { + context.options.body + = contentType === 'application/x-www-form-urlencoded' + ? new URLSearchParams( + context.options.body as Record, + ).toString() + : JSON.stringify(context.options.body); + } + + if (contentType === null) { + context.options.headers.set('content-type', 'application/json'); + } + if (!context.options.headers.has('accept')) { + context.options.headers.set('accept', 'application/json'); + } + } + else if ( + // Web Streams API body + typeof (context.options.body as ReadableStream | null)?.pipeTo === 'function' + ) { + if (!('duplex' in context.options)) { + context.options.duplex = 'half'; + } + } + } + + // Timeout via AbortSignal — compose with any caller-supplied signal + if (context.options.timeout !== undefined) { + const timeoutSignal = AbortSignal.timeout(context.options.timeout); + context.options.signal + = context.options.signal !== undefined + ? AbortSignal.any([timeoutSignal, context.options.signal as AbortSignal]) + : timeoutSignal; + } + + // Actual fetch call + try { + context.response = await fetchImpl(context.request, context.options as RequestInit); + } + catch (err) { + context.error = err as Error; + + if (context.options.onRequestError !== undefined) { + await callHooks( + context as FetchContext & { error: Error }, + context.options.onRequestError, + ); + } + + return (await onError(context)) as FetchResponse; + } + + // Response body parsing + const hasBody + = context.response.body !== null + && !NULL_BODY_STATUSES.has(context.response.status) + && method !== 'HEAD'; + + if (hasBody) { + const responseType + = context.options.parseResponse !== undefined + ? 'json' + : (context.options.responseType + ?? detectResponseType(context.response.headers.get('content-type') ?? '')); + + // V8: switch over a string constant — compiled to a jump table + switch (responseType) { + case 'json': { + const text = await context.response.text(); + if (text) { + context.response._data + = context.options.parseResponse !== undefined + ? context.options.parseResponse(text) + : (JSON.parse(text) as T); + } + break; + } + case 'stream': { + context.response._data = context.response.body as unknown as T; + break; + } + default: { + context.response._data = (await context.response[responseType]()) as T; + } + } + } + + if (context.options.onResponse !== undefined) { + await callHooks( + context as FetchContext & { response: FetchResponse }, + 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 & { response: FetchResponse }, + context.options.onResponseError, + ); + } + + return (await onError(context)) as FetchResponse; + } + + return context.response; + }; + + // ------------------------------------------------------------------------- + // $fetch — convenience wrapper that returns only the parsed data + // ------------------------------------------------------------------------- + + const $fetch = async function $fetch( + request: FetchRequest, + options?: FetchOptions, + ): Promise> { + const response = await $fetchRaw(request, options); + return response._data as InferResponseType; + } as $Fetch; + + $fetch.raw = $fetchRaw; + + $fetch.native = (...args: Parameters) => fetchImpl(...args); + + $fetch.create = (defaults: FetchOptions = {}, customGlobalOptions: CreateFetchOptions = {}) => + createFetch({ + ...globalOptions, + ...customGlobalOptions, + defaults: { + ...globalOptions.defaults, + ...customGlobalOptions.defaults, + ...defaults, + }, + }); + + $fetch.extend = $fetch.create; + + // ------------------------------------------------------------------------- + // Method shortcuts + // ------------------------------------------------------------------------- + + $fetch.get = (request, options) => $fetch(request, { ...options, method: 'GET' }); + $fetch.post = (request, options) => $fetch(request, { ...options, method: 'POST' }); + $fetch.put = (request, options) => $fetch(request, { ...options, method: 'PUT' }); + $fetch.patch = (request, options) => $fetch(request, { ...options, method: 'PATCH' }); + $fetch.delete = (request, options) => $fetch(request, { ...options, method: 'DELETE' }); + $fetch.head = (request, options) => $fetchRaw(request, { ...options, method: 'HEAD' }); + + return $fetch; +} + +/** Resolves the inferred return value type from a ResponseType key */ +type InferResponseType = R extends keyof ResponseMap + ? ResponseMap[R] + : T; diff --git a/core/fetch/src/index.ts b/core/fetch/src/index.ts new file mode 100644 index 0000000..971b87e --- /dev/null +++ b/core/fetch/src/index.ts @@ -0,0 +1,46 @@ +export { createFetch } from './fetch'; +export { FetchError, createFetchError } from './error'; +export { + isPayloadMethod, + isJSONSerializable, + detectResponseType, + buildURL, + joinURL, + callHooks, + resolveFetchOptions, +} from './utils'; +export type { + $Fetch, + CreateFetchOptions, + Fetch, + FetchContext, + FetchHook, + FetchHooks, + FetchOptions, + FetchRequest, + FetchResponse, + IFetchError, + MappedResponseType, + MaybeArray, + MaybePromise, + ResponseMap, + ResponseType, + ResolvedFetchOptions, +} from './types'; + +/** + * @name $fetch + * @category Fetch + * @description Default $fetch instance backed by globalThis.fetch + * + * @example + * const data = await $fetch('https://api.example.com/users/1'); + * + * @example + * const user = await $fetch.post('https://api.example.com/users', { + * body: { name: 'Alice' }, + * }); + * + * @since 0.0.1 + */ +export const $fetch = createFetch(); diff --git a/core/fetch/src/types.ts b/core/fetch/src/types.ts new file mode 100644 index 0000000..7caeabd --- /dev/null +++ b/core/fetch/src/types.ts @@ -0,0 +1,237 @@ +// -------------------------- +// Fetch API +// -------------------------- + +/** + * @name $Fetch + * @category Fetch + * @description The main fetch interface with method shortcuts, raw access, and factory methods + */ +export interface $Fetch { + ( + request: FetchRequest, + options?: FetchOptions, + ): Promise>; + raw( + request: FetchRequest, + options?: FetchOptions, + ): Promise>>; + /** Access to the underlying native fetch function */ + native: Fetch; + /** Create a new fetch instance with merged defaults */ + create(defaults?: FetchOptions, globalOptions?: CreateFetchOptions): $Fetch; + /** Alias for create — extend this instance with new defaults */ + extend(defaults?: FetchOptions, globalOptions?: CreateFetchOptions): $Fetch; + /** Shorthand for GET requests */ + get( + request: FetchRequest, + options?: Omit, 'method'>, + ): Promise>; + /** Shorthand for POST requests */ + post( + request: FetchRequest, + options?: Omit, 'method'>, + ): Promise>; + /** Shorthand for PUT requests */ + put( + request: FetchRequest, + options?: Omit, 'method'>, + ): Promise>; + /** Shorthand for PATCH requests */ + patch( + request: FetchRequest, + options?: Omit, 'method'>, + ): Promise>; + /** Shorthand for DELETE requests */ + delete( + request: FetchRequest, + options?: Omit, 'method'>, + ): Promise>; + /** Shorthand for HEAD requests */ + head( + request: FetchRequest, + options?: Omit, 'method'>, + ): Promise>; +} + +// -------------------------- +// Options +// -------------------------- + +/** + * @name FetchOptions + * @category Fetch + * @description Options for a fetch request, extending native RequestInit with additional features + */ +export interface FetchOptions + extends Omit, + FetchHooks { + /** Base URL prepended to all relative request URLs */ + baseURL?: string; + /** Request body — plain objects are automatically JSON-serialized */ + body?: BodyInit | Record | unknown[] | null; + /** Suppress throwing on 4xx/5xx responses */ + ignoreResponseError?: boolean; + /** URL query parameters serialized and appended to the request URL */ + query?: Record; + /** + * @deprecated use `query` instead + */ + params?: Record; + /** 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) => 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 + extends FetchOptions { + 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 { + request: FetchRequest; + options: ResolvedFetchOptions; + response?: FetchResponse; + error?: Error; +} + +export type MaybePromise = T | Promise; +export type MaybeArray = T | readonly T[]; + +/** + * @name FetchHook + * @category Fetch + * @description A function invoked at a specific point in the fetch lifecycle + */ +export type FetchHook = (context: C) => MaybePromise; + +/** + * @name FetchHooks + * @category Fetch + * @description Lifecycle hooks for the fetch pipeline + */ +export interface FetchHooks { + /** Called before the request is sent */ + onRequest?: MaybeArray>>; + /** Called when the request itself throws (e.g. network error, timeout) */ + onRequestError?: MaybeArray & { error: Error }>>; + /** Called after a successful response is received and parsed */ + onResponse?: MaybeArray & { response: FetchResponse }>>; + /** Called when the response status is 4xx or 5xx */ + onResponseError?: MaybeArray & { response: FetchResponse }>>; +} + +// -------------------------- +// 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; +} + +/** + * @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 keyof ResponseMap + ? ResponseMap[R] + : T; + +/** + * @name FetchResponse + * @category Fetch + * @description Extended Response with a parsed `_data` field + */ +export interface FetchResponse extends Response { + _data?: T; +} + +// -------------------------- +// Error +// -------------------------- + +/** + * @name IFetchError + * @category Fetch + * @description Shape of errors thrown by $fetch + */ +export interface IFetchError extends Error { + request?: FetchRequest; + options?: FetchOptions; + response?: FetchResponse; + data?: T; + status?: number; + statusText?: string; + statusCode?: number; + statusMessage?: string; +} + +// -------------------------- +// Primitives +// -------------------------- + +/** The native fetch function signature */ +export type Fetch = typeof globalThis.fetch; + +/** A fetch request — URL string, URL object, or Request object */ +export type FetchRequest = RequestInfo; diff --git a/core/fetch/src/utils.test.ts b/core/fetch/src/utils.test.ts new file mode 100644 index 0000000..41aae5d --- /dev/null +++ b/core/fetch/src/utils.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it } from 'vitest'; +import { + buildURL, + callHooks, + detectResponseType, + isJSONSerializable, + isPayloadMethod, + joinURL, + resolveFetchOptions, +} from './utils'; +import type { FetchContext } from './types'; + +// --------------------------------------------------------------------------- +// isPayloadMethod +// --------------------------------------------------------------------------- + +describe('isPayloadMethod', () => { + it('returns true for payload methods', () => { + expect(isPayloadMethod('POST')).toBe(true); + expect(isPayloadMethod('PUT')).toBe(true); + expect(isPayloadMethod('PATCH')).toBe(true); + expect(isPayloadMethod('DELETE')).toBe(true); + }); + + it('returns false for non-payload methods', () => { + expect(isPayloadMethod('GET')).toBe(false); + expect(isPayloadMethod('HEAD')).toBe(false); + expect(isPayloadMethod('OPTIONS')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// isJSONSerializable +// --------------------------------------------------------------------------- + +describe('isJSONSerializable', () => { + it('returns false for undefined', () => { + expect(isJSONSerializable(undefined)).toBe(false); + }); + + it('returns true for primitives', () => { + expect(isJSONSerializable('hello')).toBe(true); + expect(isJSONSerializable(42)).toBe(true); + expect(isJSONSerializable(true)).toBe(true); + expect(isJSONSerializable(null)).toBe(true); + }); + + it('returns false for functions, symbols, bigints', () => { + expect(isJSONSerializable(() => {})).toBe(false); + expect(isJSONSerializable(Symbol('x'))).toBe(false); + expect(isJSONSerializable(42n)).toBe(false); + }); + + it('returns true for plain arrays', () => { + expect(isJSONSerializable([1, 2, 3])).toBe(true); + }); + + it('returns false for ArrayBuffer-like values', () => { + expect(isJSONSerializable(new Uint8Array([1, 2]))).toBe(false); + }); + + it('returns false for FormData and URLSearchParams', () => { + expect(isJSONSerializable(new FormData())).toBe(false); + expect(isJSONSerializable(new URLSearchParams())).toBe(false); + }); + + it('returns true for plain objects', () => { + expect(isJSONSerializable({ a: 1 })).toBe(true); + }); + + it('returns true for objects with toJSON', () => { + expect(isJSONSerializable({ toJSON: () => ({}) })).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// detectResponseType +// --------------------------------------------------------------------------- + +describe('detectResponseType', () => { + it('defaults to json when content-type is empty', () => { + expect(detectResponseType('')).toBe('json'); + expect(detectResponseType()).toBe('json'); + }); + + it('detects json content types', () => { + expect(detectResponseType('application/json')).toBe('json'); + expect(detectResponseType('application/json; charset=utf-8')).toBe('json'); + expect(detectResponseType('application/vnd.api+json')).toBe('json'); + }); + + it('detects event-stream as stream', () => { + expect(detectResponseType('text/event-stream')).toBe('stream'); + }); + + it('detects text content types', () => { + expect(detectResponseType('text/plain')).toBe('text'); + expect(detectResponseType('text/html')).toBe('text'); + expect(detectResponseType('application/xml')).toBe('text'); + }); + + it('falls back to blob for binary types', () => { + expect(detectResponseType('image/png')).toBe('blob'); + expect(detectResponseType('application/octet-stream')).toBe('blob'); + }); +}); + +// --------------------------------------------------------------------------- +// buildURL +// --------------------------------------------------------------------------- + +describe('buildURL', () => { + it('appends query params to a clean URL', () => { + expect(buildURL('https://api.example.com', { page: 1, limit: 20 })).toBe( + 'https://api.example.com?page=1&limit=20', + ); + }); + + it('appends to an existing query string with &', () => { + expect(buildURL('https://api.example.com?foo=bar', { baz: 'qux' })).toBe( + 'https://api.example.com?foo=bar&baz=qux', + ); + }); + + it('omits null and undefined values', () => { + expect(buildURL('https://api.example.com', { a: null, b: undefined, c: 'keep' })).toBe( + 'https://api.example.com?c=keep', + ); + }); + + it('returns the URL unchanged when all params are omitted', () => { + expect(buildURL('https://api.example.com', { a: null })).toBe('https://api.example.com'); + }); +}); + +// --------------------------------------------------------------------------- +// joinURL +// --------------------------------------------------------------------------- + +describe('joinURL', () => { + it('joins base and path correctly', () => { + expect(joinURL('https://api.example.com/v1', '/users')).toBe( + 'https://api.example.com/v1/users', + ); + }); + + it('does not double slashes', () => { + expect(joinURL('https://api.example.com/v1/', '/users')).toBe( + 'https://api.example.com/v1/users', + ); + }); + + it('adds a slash when neither side has one', () => { + expect(joinURL('https://api.example.com/v1', 'users')).toBe( + 'https://api.example.com/v1/users', + ); + }); + + it('returns base when path is empty', () => { + expect(joinURL('https://api.example.com', '')).toBe('https://api.example.com'); + }); + + it('returns base when path is "/"', () => { + expect(joinURL('https://api.example.com', '/')).toBe('https://api.example.com'); + }); +}); + +// --------------------------------------------------------------------------- +// callHooks +// --------------------------------------------------------------------------- + +describe('callHooks', () => { + function makeCtx(): FetchContext { + return { + request: 'https://example.com', + options: { headers: new Headers() }, + response: undefined, + error: undefined, + } as FetchContext; + } + + it('does nothing when hooks is undefined', async () => { + await expect(callHooks(makeCtx(), undefined)).resolves.toBeUndefined(); + }); + + it('calls a single hook', async () => { + const calls: number[] = []; + await callHooks(makeCtx(), () => { + calls.push(1); + }); + expect(calls).toEqual([1]); + }); + + it('calls an array of hooks in order', async () => { + const calls: number[] = []; + await callHooks(makeCtx(), [ + () => { calls.push(1); }, + () => { calls.push(2); }, + () => { calls.push(3); }, + ]); + expect(calls).toEqual([1, 2, 3]); + }); + + it('awaits async hooks', async () => { + const calls: number[] = []; + await callHooks(makeCtx(), [ + async () => { + await Promise.resolve(); + calls.push(1); + }, + () => { + calls.push(2); + }, + ]); + expect(calls).toEqual([1, 2]); + }); +}); + +// --------------------------------------------------------------------------- +// resolveFetchOptions +// --------------------------------------------------------------------------- + +describe('resolveFetchOptions', () => { + it('returns an object with a Headers instance', () => { + const resolved = resolveFetchOptions('https://example.com', undefined, undefined); + expect(resolved.headers).toBeInstanceOf(Headers); + }); + + it('merges input and default headers (input wins)', () => { + const resolved = resolveFetchOptions( + 'https://example.com', + { headers: { 'x-custom': 'input' } }, + { headers: { 'x-custom': 'default', 'x-default-only': 'yes' } }, + ); + expect(resolved.headers.get('x-custom')).toBe('input'); + expect(resolved.headers.get('x-default-only')).toBe('yes'); + }); + + it('merges query params from defaults and input', () => { + const resolved = resolveFetchOptions( + 'https://example.com', + { query: { a: '1' } }, + { query: { b: '2' } }, + ); + expect(resolved.query).toEqual({ a: '1', b: '2' }); + }); + + it('merges params alias into query', () => { + const resolved = resolveFetchOptions( + 'https://example.com', + { params: { p: '10' } }, + undefined, + ); + expect(resolved.query).toEqual({ p: '10' }); + expect(resolved.params).toEqual({ p: '10' }); + }); +}); diff --git a/core/fetch/src/utils.ts b/core/fetch/src/utils.ts new file mode 100644 index 0000000..f506c0a --- /dev/null +++ b/core/fetch/src/utils.ts @@ -0,0 +1,285 @@ +import type { + FetchContext, + FetchHook, + FetchOptions, + FetchRequest, + ResolvedFetchOptions, + ResponseType, +} from './types'; + +// --------------------------------------------------------------------------- +// V8 optimisation: module-level frozen Sets avoid per-call allocations and +// allow V8 to treat them as compile-time constants in hidden-class analysis. +// --------------------------------------------------------------------------- + +/** HTTP methods whose requests carry a body */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment +const PAYLOAD_METHODS: ReadonlySet = /* @__PURE__ */ new Set(['PATCH', 'POST', 'PUT', 'DELETE']); + +/** HTTP status codes whose responses never have a body */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment +export const NULL_BODY_STATUSES: ReadonlySet = /* @__PURE__ */ new Set([101, 204, 205, 304]); + +/** Content-types treated as plain text */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment +const TEXT_CONTENT_TYPES: ReadonlySet = /* @__PURE__ */ new Set([ + 'image/svg', + 'application/xml', + 'application/xhtml', + 'application/html', +]); + +/** V8: pre-compiled at module load — avoids per-call RegExp construction */ +const JSON_CONTENT_TYPE_RE = /^application\/(?:[\w!#$%&*.^`~-]*\+)?json(;.+)?$/i; + +// --------------------------------------------------------------------------- +// Predicate helpers +// --------------------------------------------------------------------------- + +/** + * @name isPayloadMethod + * @category Fetch + * @description Returns true for HTTP methods that carry a request body + * + * V8: function is monomorphic — always called with an uppercase string. + * + * @param {string} method - Uppercase HTTP method string + * @returns {boolean} + * + * @since 0.0.1 + */ +export function isPayloadMethod(method: string): boolean { + return PAYLOAD_METHODS.has(method); +} + +/** + * @name isJSONSerializable + * @category Fetch + * @description Returns true when a value can be serialised with JSON.stringify + * + * V8: typeof checks are ordered from most-common to least-common to maximise + * the probability of an early return and keep the IC monomorphic. + * + * @param {unknown} value - Any value + * @returns {boolean} + * + * @since 0.0.1 + */ +export function isJSONSerializable(value: unknown): boolean { + if (value === undefined) return false; + + const type = typeof value; + + // Fast path — primitives are always serialisable + if (type === 'string' || type === 'number' || type === 'boolean' || value === null) return true; + + // Non-object types (bigint, function, symbol) are not serialisable + if (type !== 'object') return false; + + // Arrays are serialisable + if (Array.isArray(value)) return true; + + // TypedArrays / ArrayBuffers carry a .buffer property — not JSON-serialisable + if ((value as Record).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).toJSON === 'function' + ); +} + +// --------------------------------------------------------------------------- +// Response type detection +// --------------------------------------------------------------------------- + +/** + * @name detectResponseType + * @category Fetch + * @description Infers the response body parsing strategy from a Content-Type header value + * + * @param {string} [contentType] - Value of the Content-Type response header + * @returns {ResponseType} + * + * @since 0.0.1 + */ +export function detectResponseType(contentType = ''): ResponseType { + if (!contentType) return 'json'; + + // V8: split once and reuse — avoids calling split multiple times + const type = contentType.split(';')[0] ?? ''; + + if (JSON_CONTENT_TYPE_RE.test(type)) return 'json'; + if (type === 'text/event-stream') return 'stream'; + if (TEXT_CONTENT_TYPES.has(type) || type.startsWith('text/')) return 'text'; + + return 'blob'; +} + +// --------------------------------------------------------------------------- +// URL helpers +// --------------------------------------------------------------------------- + +/** + * @name buildURL + * @category Fetch + * @description Appends serialised query parameters to a URL string + * + * Null and undefined values are omitted. Existing query strings are preserved. + * + * @param {string} url - Base URL (may already contain a query string) + * @param {Record} query - Parameters to append + * @returns {string} URL with query string + * + * @since 0.0.1 + */ +export function buildURL( + url: string, + query: Record, +): string { + const params = new URLSearchParams(); + + for (const key of Object.keys(query)) { + const value = query[key]; + if (value !== null && value !== undefined) { + params.append(key, String(value)); + } + } + + const qs = params.toString(); + if (!qs) return url; + + return url.includes('?') ? `${url}&${qs}` : `${url}?${qs}`; +} + +/** + * @name joinURL + * @category Fetch + * @description Joins a base URL with a relative path, normalising the slash boundary + * + * @param {string} base - Base URL (e.g. "https://api.example.com/v1") + * @param {string} path - Relative path (e.g. "/users") + * @returns {string} Joined URL + * + * @since 0.0.1 + */ +export function joinURL(base: string, path: string): string { + if (!path || path === '/') return base; + + const baseEnds = base.endsWith('/'); + const pathStarts = path.startsWith('/'); + + if (baseEnds && pathStarts) return `${base}${path.slice(1)}`; + if (!baseEnds && !pathStarts) return `${base}/${path}`; + return `${base}${path}`; +} + +// --------------------------------------------------------------------------- +// Options resolution +// --------------------------------------------------------------------------- + +/** + * @name resolveFetchOptions + * @category Fetch + * @description Merges per-request options with global defaults + * + * V8: the returned object always has the same property set (fixed shape), + * which lets V8 reuse its hidden class across all calls. + * + * @since 0.0.1 + */ +export function resolveFetchOptions( + request: FetchRequest, + input: FetchOptions | undefined, + defaults: FetchOptions | undefined, +): ResolvedFetchOptions { + const headers = mergeHeaders( + input?.headers ?? (request as Request)?.headers, + defaults?.headers, + ); + + let query: Record | undefined; + if ( + defaults?.query !== undefined + || defaults?.params !== undefined + || input?.params !== undefined + || input?.query !== undefined + ) { + query = { + ...defaults?.params, + ...defaults?.query, + ...input?.params, + ...input?.query, + }; + } + + return { + ...defaults, + ...input, + query, + params: query, + headers, + }; +} + +/** + * Merge two HeadersInit sources into a single Headers instance. + * Input headers override default headers. + * + * V8: avoids constructing an intermediate Headers when defaults are absent. + */ +function mergeHeaders( + input: HeadersInit | undefined, + defaults: HeadersInit | undefined, +): Headers { + if (defaults === undefined) { + return new Headers(input); + } + + const merged = new Headers(defaults); + + if (input !== undefined) { + const src = input instanceof Headers ? input : new Headers(input); + for (const [key, value] of src) { + merged.set(key, value); + } + } + + return merged; +} + +// --------------------------------------------------------------------------- +// Hook dispatch +// --------------------------------------------------------------------------- + +/** + * @name callHooks + * @category Fetch + * @description Invokes one or more lifecycle hooks with the given context + * + * V8: the single-hook path avoids Array creation; the Array path uses a + * for-loop with a cached length to stay monomorphic inside the loop body. + * + * @since 0.0.1 + */ +export async function callHooks( + context: C, + hooks: FetchHook | readonly FetchHook[] | undefined, +): Promise { + if (hooks === undefined) return; + + if (Array.isArray(hooks)) { + const len = hooks.length; + for (let i = 0; i < len; i++) { + await (hooks as Array>)[i]!(context); + } + } + else { + await (hooks as FetchHook)(context); + } +} diff --git a/core/fetch/tsconfig.json b/core/fetch/tsconfig.json new file mode 100644 index 0000000..ab255ac --- /dev/null +++ b/core/fetch/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "@robonen/tsconfig/tsconfig.json" +} diff --git a/core/fetch/tsdown.config.ts b/core/fetch/tsdown.config.ts new file mode 100644 index 0000000..ae9657f --- /dev/null +++ b/core/fetch/tsdown.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'tsdown'; +import { sharedConfig } from '@robonen/tsdown'; + +export default defineConfig({ + ...sharedConfig, + entry: ['src/index.ts'], +}); diff --git a/core/fetch/vitest.config.ts b/core/fetch/vitest.config.ts new file mode 100644 index 0000000..4ac6027 --- /dev/null +++ b/core/fetch/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ddbce1..58e2e94 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,27 @@ importers: specifier: 'catalog:' version: 0.21.0(typescript@5.8.3)(vue-tsc@3.2.5(typescript@5.8.3)) + core/fetch: + devDependencies: + '@robonen/oxlint': + specifier: workspace:* + version: link:../../configs/oxlint + '@robonen/tsconfig': + specifier: workspace:* + version: link:../../configs/tsconfig + '@robonen/tsdown': + specifier: workspace:* + version: link:../../configs/tsdown + '@stylistic/eslint-plugin': + specifier: 'catalog:' + version: 5.10.0(eslint@10.0.3(jiti@2.6.1)) + oxlint: + specifier: 'catalog:' + version: 1.51.0 + tsdown: + specifier: 'catalog:' + version: 0.21.0(typescript@5.8.3)(vue-tsc@3.2.5(typescript@5.8.3)) + core/platform: devDependencies: '@robonen/oxlint': diff --git a/vitest.config.ts b/vitest.config.ts index 2fac659..f233d49 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ test: { projects: [ 'configs/oxlint/vitest.config.ts', + 'core/fetch/vitest.config.ts', 'core/stdlib/vitest.config.ts', 'core/platform/vitest.config.ts', 'vue/toolkit/vitest.config.ts',