diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..8c0a318 --- /dev/null +++ b/.npmignore @@ -0,0 +1,2 @@ +dist/**/*.map +dist/**/*.d.cts diff --git a/README.md b/README.md new file mode 100644 index 0000000..5040a5a --- /dev/null +++ b/README.md @@ -0,0 +1,373 @@ +# vfetch + +

+ A lightweight, Axios-like HTTP client built on native fetch. +
+ Predictable. Concurrent-safe. Dependency-free. +

+ +

+ + + + + +

+ +--- + +## โœจ Why vfetch? + +Most HTTP clients fall into two extremes: + +- **Axios** โ†’ powerful but heavy and legacy-oriented +- **Ky** โ†’ lightweight but limited for real-world auth flows + +**vfetch sits in the middle.** + +- Built directly on native `fetch` +- No dependencies +- Handles **token refresh + concurrency safely** +- Returns **predictable, non-throwing responses** + +--- + +## ๐Ÿ“ฆ Installation + +```bash +npm install vfetch-client +yarn add vfetch-client +pnpm add vfetch-client +bun add vfetch-client +``` + +--- + +## ๐Ÿš€ Quick Start + +```ts +import { createClient } from "vfetch-client"; + +const api = createClient({ + baseURL: "https://api.example.com", +}); + +const res = await api.get("/users"); + +if (res.ok) { + console.log(res.data); +} else { + console.error(res.error); +} +``` + +--- + +## ๐Ÿง  Core Response Model + +vfetch never throws HTTP errors. Everything resolves into a consistent shape: + +```ts +type VfetchResponse = + | { ok: true; data: T; status: number } + | { ok: false; error: string; status: number }; +``` + +This removes the need for excessive `try/catch` and keeps control flow predictable. + +--- + +## ๐Ÿงฉ TypeScript (Optional) + +Typing is **completely optional**. + +```ts +// No typing (default) +const res = await api.get("/users"); + +// Typed (when you know the shape) +const res = await api.get("/users"); +``` + +Types improve DX โ€” they are not enforced at runtime. + +--- + +## ๐Ÿ”ง Request Methods + +```ts +api.get("/users"); + +api.post("/users", { name: "John" }); + +api.put("/users/1", { status: "active" }); + +api.patch("/users/1", { role: "admin" }); + +api.delete("/users/1"); +``` + +--- + +## ๐Ÿ” Authentication & Token Refresh + +vfetch handles token injection and **deduplicated refresh flows**. + +If multiple requests fail with `401`, only **one refresh request runs**, while others wait safely. + +```ts +let token = "initial-token"; + +const api = createClient({ + baseURL: "https://api.example.com", + + getToken: () => token, + + onRefresh: async () => { + const res = await fetch("https://api.example.com/refresh", { + method: "POST", + }); + + const data = await res.json(); + token = data.accessToken; + + return token; + }, + + onAuthFailure: () => { + console.error("Session expired"); + }, +}); +``` + +--- + +## ๐Ÿช Cookie-Based Authentication (Web) + +vfetch supports cookie-based auth via the native `credentials` option. +The default behavior is `"same-origin"`. + +This can be configured globally or per request. + +### Example (Global) + +```ts +const api = createClient({ + baseURL: "https://api.example.com", + credentials: "include", +}); +``` + +### Example (Per Request) + +```ts +await api.get("/me", { + credentials: "include", +}); +``` + +### Important Note + +For cross-origin requests using `credentials: "include"`, your backend must be explicitly configured to allow them. + +You **cannot** use `Access-Control-Allow-Origin: *`. + +Example backend CORS headers: + +```http +Access-Control-Allow-Credentials: true +Access-Control-Allow-Origin: https://your-frontend.com +``` + +--- + +## ๐Ÿ”„ Interceptors + +```ts +const api = createClient({ + baseURL: "https://api.example.com", + + onRequest: (url, options) => { + console.log("โ†’", options.method, url); + }, + + onResponse: (url, res, duration) => { + console.log("โ†", url, `${duration}ms`); + }, + + onError: (url, err) => { + console.error("โœ•", url, err.error); + }, +}); +``` + +--- + +## โš ๏ธ Error Handling + +No thrown HTTP errors: + +```ts +const res = await api.get("/users"); + +if (!res.ok) { + console.error(res.status, res.error); + return; +} + +console.log(res.data); +``` + +Handles: + +- 4xx / 5xx +- network failures +- invalid JSON +- timeouts +- aborted requests + +--- + +## โฑ๏ธ Retry & Timeout + +```ts +const api = createClient({ + baseURL: "https://api.example.com", + timeout: 5000, + retry: 2, + retryDelay: 1000, +}); +``` + +Override per request: + +```ts +await api.get("/heavy", { + retry: 0, + timeout: 10000, +}); +``` + +--- + +## ๐Ÿงต Abort Requests + +```ts +const controller = new AbortController(); + +const res = await api.get("/long-task", { + signal: controller.signal, +}); + +controller.abort(); +``` + +--- + +## โš›๏ธ TanStack Query (React Query) + +vfetch works cleanly with TanStack Query. + +--- + +### Queries + +#### Pattern A โ€” Clean abstraction + +```ts +export const getUsersFn = async () => { + return api.get("/users"); +}; +``` + +```ts +const { data } = useQuery({ + queryKey: ["users"], + queryFn: getUsersFn, +}); +``` + +--- + +#### Pattern B โ€” Inline + +```ts +const { data } = useQuery({ + queryKey: ["users"], + queryFn: async () => { + return api.get("/users"); + }, +}); +``` + +--- + +### Mutations + +#### Named function + +```ts +export const resendOtpFn = async (identifier: string) => { + return api.post("/auth/request-otp", { identifier }); +}; +``` + +```ts +const { mutate } = useMutation({ + mutationFn: resendOtpFn, +}); +``` + +--- + +#### Inline mutation + +```ts +const { mutate } = useMutation({ + mutationFn: async (identifier: string) => { + return api.post("/auth/request-otp", { identifier }); + }, +}); +``` + +--- + +## ๐Ÿงญ Design Philosophy + +- **Transport layer only** + No schema validation, no assumptions about backend structure + +- **Predictable responses** + No hidden throws โ€” always `{ ok, data | error }` + +- **Concurrency safety first** + Token refresh, retries, and interceptors behave correctly under load + +- **Minimal & dependency-free** + Built directly on native `fetch` + +--- + +## โš–๏ธ vfetch vs Axios vs Ky + +| Feature | vfetch | Axios | Ky | +| ---------------------- | ------ | ------- | --------------- | +| Built on fetch | โœ… | โŒ | โœ… | +| Zero dependencies | โœ… | โŒ | โœ… | +| Interceptors | โœ… | โœ… | โš ๏ธ (hooks only) | +| Token refresh flow | โœ… | manual | โŒ | +| TypeScript-first | โœ… | partial | โœ… | +| Response normalization | โœ… | โŒ | partial | +| Lightweight | โœ… | โŒ | โœ… | + +### Summary + +- **Axios** โ†’ mature, but heavier and more legacy-oriented +- **Ky** โ†’ minimal, but lacks structured auth + retry control +- **vfetch** โ†’ modern balance with safer concurrency and predictable responses + +--- + +## ๐Ÿ“„ License + +MIT diff --git a/package-lock.json b/package-lock.json index d5a491a..7298aa1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "vfetch", + "name": "vfetch-client", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "vfetch", + "name": "vfetch-client", "version": "0.1.0", "license": "MIT", "devDependencies": { diff --git a/package.json b/package.json index d10c264..bbe9535 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,16 @@ { - "name": "vfetch", + "name": "vfetch-client", "version": "0.1.0", - "description": "Vfetch is a lightweight HTTP client for Web, Mobile and Desktop apps.", - "main": "./dist/index.js", - "module": "./dist/index.mjs", + "description": "A modern Axios alternative built on native fetch with interceptors, automatic token refresh, retries, and predictable response handling", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { - "import": { - "types": "./dist/index.d.mts", - "default": "./dist/index.mjs" - }, - "require": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - } + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" } }, "files": [ @@ -30,12 +26,24 @@ }, "keywords": [ "fetch", - "http", - "axios", - "request" + "http-client", + "axios-alternative", + "typescript", + "api-client", + "interceptors", + "react-query", + "node-fetch" ], "author": "gab-codes", "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/Gab-codes/vfetch.git" + }, + "homepage": "https://github.com/Gab-codes/vfetch#readme", + "bugs": { + "url": "https://github.com/Gab-codes/vfetch/issues" + }, "devDependencies": { "@types/node": "^25.6.0", "@vitest/coverage-v8": "^4.1.5", diff --git a/src/client.ts b/src/client.ts index ce88442..d583a1f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -111,12 +111,14 @@ export class VfetchClient { } const finalSignal = this.resolveSignal(userSignal, timeoutController); + const credentials = options.credentials ?? this.config.credentials ?? "same-origin"; const fetchOptions: RequestInit = { method, headers, body: body != null ? JSON.stringify(body) : undefined, signal: finalSignal, + credentials, }; const urlString = url.toString(); diff --git a/src/types.ts b/src/types.ts index e710042..85483f0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -43,6 +43,11 @@ export interface VfetchConfig { readonly retry?: number; /** Delay in milliseconds between retry attempts. Default is 0. */ readonly retryDelay?: number; + /** + * The request credentials to be sent with the request. + * Default is "same-origin". + */ + readonly credentials?: RequestCredentials; /** * Function to retrieve the authentication token. * Can be synchronous or asynchronous. @@ -97,4 +102,6 @@ export interface RequestOptions { readonly retry?: number; /** Request-specific retry delay. Overrides global retryDelay. */ readonly retryDelay?: number; + /** Request-specific credentials. Overrides global credentials. */ + readonly credentials?: RequestCredentials; } diff --git a/tests/credentials.test.ts b/tests/credentials.test.ts new file mode 100644 index 0000000..ecb8a1b --- /dev/null +++ b/tests/credentials.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createClient } from '../src'; +import { withMockedFetch, jsonResponse, FetchMock } from './utils'; + +describe('Credentials Support', () => { + let fetchMock: FetchMock; + + beforeEach(() => { + fetchMock = withMockedFetch(); + }); + + it('defaults to "same-origin" when no credentials option is provided', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ success: true })); + + const client = createClient({ baseURL: 'https://api.test.com' }); + await client.get('/test'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const options = fetchMock.mock.calls[0][1] as RequestInit; + expect(options.credentials).toBe('same-origin'); + }); + + it('uses global config credentials when provided', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ success: true })); + + const client = createClient({ + baseURL: 'https://api.test.com', + credentials: 'include', + }); + await client.get('/test'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const options = fetchMock.mock.calls[0][1] as RequestInit; + expect(options.credentials).toBe('include'); + }); + + it('overrides global credentials with per-request credentials', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ success: true })); + + const client = createClient({ + baseURL: 'https://api.test.com', + credentials: 'include', + }); + + // Per-request override + await client.get('/test', { credentials: 'omit' }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const options = fetchMock.mock.calls[0][1] as RequestInit; + expect(options.credentials).toBe('omit'); + }); + + it('allows headers and credentials to coexist without breaking Authorization injection', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ success: true })); + + const client = createClient({ + baseURL: 'https://api.test.com', + credentials: 'include', + getToken: () => 'test-token', + }); + + await client.get('/test', { + headers: { 'X-Custom-Header': 'custom-value' }, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const options = fetchMock.mock.calls[0][1] as RequestInit; + + expect(options.credentials).toBe('include'); + + const headers = new Headers(options.headers); + expect(headers.get('Authorization')).toBe('Bearer test-token'); + expect(headers.get('X-Custom-Header')).toBe('custom-value'); + }); + + it('maintains credentials during the retry / refresh flow', async () => { + // 1st request fails with 401 + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 })); + // 2nd request (the refresh) succeeds + fetchMock.mockResolvedValueOnce(jsonResponse({ token: 'refreshed-token' })); + // 3rd request (the retry) succeeds + fetchMock.mockResolvedValueOnce(jsonResponse({ success: true })); + + const onRefresh = vi.fn().mockImplementation(async () => { + const res = await fetch('https://api.test.com/refresh', { method: 'POST' }); + const data = await res.json() as { token: string }; + return data.token; + }); + + const client = createClient({ + baseURL: 'https://api.test.com', + credentials: 'include', // globally configured + onRefresh, + }); + + await client.get('/protected'); + + // 1 (initial get) + 1 (refresh inside onRefresh) + 1 (retry get) + expect(fetchMock).toHaveBeenCalledTimes(3); + + // Initial Request Options + const initialOptions = fetchMock.mock.calls[0][1] as RequestInit; + expect(initialOptions.credentials).toBe('include'); + + // Retry Request Options (index 2) + const retryOptions = fetchMock.mock.calls[2][1] as RequestInit; + expect(retryOptions.credentials).toBe('include'); + + const retryHeaders = new Headers(retryOptions.headers); + expect(retryHeaders.get('Authorization')).toBe('Bearer refreshed-token'); + }); +}); diff --git a/tsup.config.ts b/tsup.config.ts index 688012e..23158be 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,10 +1,13 @@ import { defineConfig } from "tsup"; export default defineConfig({ + entry: ["src/index.ts"], format: ["cjs", "esm"], - entry: ["./src/index.ts"], dts: true, - shims: true, - skipNodeModulesBundle: true, clean: true, + + target: "es2022", + sourcemap: false, + + skipNodeModulesBundle: true, });