From c29ae77c396f99d9730b5f091335d86bb573d041 Mon Sep 17 00:00:00 2001
From: Temidayo Gabriel
Date: Fri, 1 May 2026 14:13:28 -0400
Subject: [PATCH 1/2] feat: add support for web request credentials
configuration with same-origin default
---
README.md | 373 ++++++++++++++++++++++++++++++++++++++
src/client.ts | 2 +
src/types.ts | 7 +
tests/credentials.test.ts | 112 ++++++++++++
4 files changed, 494 insertions(+)
create mode 100644 README.md
create mode 100644 tests/credentials.test.ts
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e5e55bd
--- /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
+yarn add vfetch
+pnpm add vfetch
+bun add vfetch
+```
+
+---
+
+## ๐ Quick Start
+
+```ts
+import { createClient } from "vfetch";
+
+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/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');
+ });
+});
From 6126d9d26ff65bdf804f3e5cadede905b8b221fc Mon Sep 17 00:00:00 2001
From: Temidayo Gabriel
Date: Fri, 1 May 2026 15:52:54 -0400
Subject: [PATCH 2/2] chore: rename package to vfetch-client, update build
configuration, refine project metadata and update readme
---
.npmignore | 2 ++
README.md | 22 +++++++++++-----------
package-lock.json | 4 ++--
package.json | 38 +++++++++++++++++++++++---------------
tsup.config.ts | 9 ++++++---
5 files changed, 44 insertions(+), 31 deletions(-)
create mode 100644 .npmignore
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
index e5e55bd..5040a5a 100644
--- a/README.md
+++ b/README.md
@@ -7,12 +7,11 @@
-
-
-
-
-
-
+
+
+
+
+
---
@@ -36,10 +35,10 @@ Most HTTP clients fall into two extremes:
## ๐ฆ Installation
```bash
-npm install vfetch
-yarn add vfetch
-pnpm add vfetch
-bun add vfetch
+npm install vfetch-client
+yarn add vfetch-client
+pnpm add vfetch-client
+bun add vfetch-client
```
---
@@ -47,7 +46,7 @@ bun add vfetch
## ๐ Quick Start
```ts
-import { createClient } from "vfetch";
+import { createClient } from "vfetch-client";
const api = createClient({
baseURL: "https://api.example.com",
@@ -174,6 +173,7 @@ For cross-origin requests using `credentials: "include"`, your backend must be e
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
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/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,
});