Skip to content

Commit abcc2f3

Browse files
committed
Enable pluggable fetch implementations
1 parent a18a1d5 commit abcc2f3

File tree

10 files changed

+256
-30
lines changed

10 files changed

+256
-30
lines changed

README.md

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111

1212
# @gkoos/ffetch
1313

14-
**A production-ready TypeScript-first drop-in replacement for native fetch.**
14+
**A production-ready TypeScript-first drop-in replacement for native fetch, or any fetch-compatible implementation.**
15+
16+
ffetch can wrap any fetch-compatible implementation (native fetch, node-fetch, undici, or framework-provided fetch), making it flexible for SSR, edge, and custom environments.
1517

1618
**Key Features:**
1719

@@ -49,6 +51,26 @@ const response = await api('https://api.example.com/users')
4951
const data = await response.json()
5052
```
5153

54+
### Using a Custom fetchHandler (SSR, metaframeworks, or polyfills)
55+
56+
```typescript
57+
// Example: SvelteKit, Next.js, Nuxt, or node-fetch
58+
import createClient from '@gkoos/ffetch'
59+
60+
// Pass your framework's fetch implementation
61+
const api = createClient({
62+
fetchHandler: fetch, // SvelteKit/Next.js/Nuxt provide their own fetch
63+
timeout: 5000,
64+
})
65+
66+
// Or use node-fetch/undici in Node.js
67+
import nodeFetch from 'node-fetch'
68+
const apiNode = createClient({ fetchHandler: nodeFetch })
69+
70+
// All ffetch features work identically
71+
const response = await api('/api/data')
72+
```
73+
5274
### Advanced Example
5375

5476
```typescript
@@ -57,6 +79,7 @@ const client = createClient({
5779
timeout: 10000,
5880
retries: 2,
5981
circuit: { threshold: 5, reset: 30000 },
82+
fetchHandler: fetch, // Use custom fetch if needed
6083
hooks: {
6184
before: async (req) => console.log('', req.url),
6285
after: async (req, res) => console.log('', res.status),
@@ -104,6 +127,9 @@ try {
104127

105128
For older environments, see the [compatibility guide](./docs/compatibility.md).
106129

130+
**Custom fetch support:**
131+
You can pass any fetch-compatible implementation (native fetch, node-fetch, undici, SvelteKit, Next.js, Nuxt, or a polyfill) via the `fetchHandler` option. This makes ffetch fully compatible with SSR, edge, metaframework environments, custom backends, and test runners.
132+
107133
## CDN Usage
108134

109135
```html
@@ -117,17 +143,18 @@ For older environments, see the [compatibility guide](./docs/compatibility.md).
117143

118144
## Fetch vs. Axios vs. `ffetch`
119145

120-
| Feature | Native Fetch | Axios | ffetch |
121-
| ------------------ | ------------------------- | -------------------- | -------------------------------- |
122-
| Timeouts | ❌ Manual AbortController | ✅ Built-in | ✅ Built-in with fallbacks |
123-
| Retries | ❌ Manual implementation | ❌ Manual or plugins | ✅ Smart exponential backoff |
124-
| Circuit Breaker | ❌ Not available | ❌ Manual or plugins | ✅ Automatic failure protection |
125-
| Request Monitoring | ❌ Manual tracking | ❌ Manual tracking | ✅ Built-in pending requests |
126-
| Error Types | ❌ Generic errors | ⚠️ HTTP errors only | ✅ Specific error classes |
127-
| TypeScript | ⚠️ Basic types | ⚠️ Basic types | ✅ Full type safety |
128-
| Hooks/Middleware | ❌ Not available | ✅ Interceptors | ✅ Comprehensive lifecycle hooks |
129-
| Bundle Size | ✅ Native (0kb) |~13kb minified |~3kb minified |
130-
| Modern APIs | ✅ Web standards | ❌ XMLHttpRequest | ✅ Fetch + modern features |
146+
| Feature | Native Fetch | Axios | ffetch |
147+
| -------------------- | ------------------------- | -------------------- | -------------------------------------------------------------------------------------- |
148+
| Timeouts | ❌ Manual AbortController | ✅ Built-in | ✅ Built-in with fallbacks |
149+
| Retries | ❌ Manual implementation | ❌ Manual or plugins | ✅ Smart exponential backoff |
150+
| Circuit Breaker | ❌ Not available | ❌ Manual or plugins | ✅ Automatic failure protection |
151+
| Request Monitoring | ❌ Manual tracking | ❌ Manual tracking | ✅ Built-in pending requests |
152+
| Error Types | ❌ Generic errors | ⚠️ HTTP errors only | ✅ Specific error classes |
153+
| TypeScript | ⚠️ Basic types | ⚠️ Basic types | ✅ Full type safety |
154+
| Hooks/Middleware | ❌ Not available | ✅ Interceptors | ✅ Comprehensive lifecycle hooks |
155+
| Bundle Size | ✅ Native (0kb) |~13kb minified |~3kb minified |
156+
| Modern APIs | ✅ Web standards | ❌ XMLHttpRequest | ✅ Fetch + modern features |
157+
| Custom Fetch Support | ❌ No (global only) | ❌ No | ✅ Yes (wrap any fetch-compatible implementation, including framework or custom fetch) |
131158

132159
## Contributing
133160

docs/advanced.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,19 @@ await client('https://api.example.com/v1/metrics', {
3434
})
3535
```
3636

37+
## Custom Fetch Compatibility
38+
39+
ffetch can wrap any fetch-compatible implementation using the `fetchHandler` option. This includes native fetch, node-fetch, undici, or framework-provided fetch (SvelteKit, Next.js, Nuxt, etc.), as well as polyfills and test runners. All advanced features (timeouts, retries, circuit breaker, hooks, pending requests) work identically regardless of the underlying fetch implementation, making ffetch highly flexible for SSR, edge, and custom environments.
40+
3741
## Pending Requests Monitoring
3842

39-
> **Technical Note:**
40-
> Every `PendingRequest` always has a `controller` property, even if you did not supply an AbortController. This allows you to abort any pending request programmatically, regardless of how it was created.
43+
Pending requests and abort logic work identically whether you use the default global fetch or a custom fetch implementation via `fetchHandler`. All requests are tracked, and you can abort them programmatically using the controller in each `PendingRequest`.
44+
45+
Every `PendingRequest` always has a `controller` property, even if you did not supply an AbortController. This allows you to abort any pending request programmatically, regardless of how it was created.
4146

42-
> When multiple signals (user, timeout, transformRequest) are combined and `AbortSignal.any` is not available, ffetch creates a new internal `AbortController` to manage aborts. This controller is always available in `PendingRequest.controller`.
47+
When multiple signals (user, timeout, transformRequest) are combined and `AbortSignal.any` is not available, ffetch creates a new internal `AbortController` to manage aborts. This controller is always available in `PendingRequest.controller`.
4348

44-
> You can always abort a pending request using `pendingRequest.controller.abort()`, even if you did not provide a controller or signal. This works for all requests tracked in `pendingRequests`.
49+
You can always abort a pending request using `pendingRequest.controller.abort()`, even if you did not provide a controller or signal. This works for all requests tracked in `pendingRequests`.
4550

4651
You can access and monitor all active requests through the `pendingRequests` property on the client instance:
4752

docs/api.md

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## createClient(options?)
44

5-
Creates a new HTTP client instance with the specified configuration.
5+
Creates a new HTTP client instance with the specified configuration. You can use ffetch as a drop-in replacement for native fetch, or wrap any fetch-compatible implementation (e.g., node-fetch, undici, SvelteKit/Next.js/Nuxt-provided fetch) for SSR, edge, and custom environments.
66

77
```typescript
88
import createClient from '@gkoos/ffetch'
@@ -16,14 +16,15 @@ const client = createClient({
1616

1717
### Configuration Options
1818

19-
| Option | Type | Default | Description |
20-
| ------------- | ------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ----------------------------------------------------------------- |
21-
| `timeout` | `number` (ms) | `5000` | Whole-request timeout in milliseconds. Use `0` to disable timeout |
22-
| `retries` | `number` | `0` | Maximum retry attempts |
23-
| `retryDelay` | `number \| (ctx: { attempt, request, response, error }) => number` | Exponential backoff + jitter | Delay between retries |
24-
| `shouldRetry` | `(ctx: { attempt, request, response, error }) => boolean` | Retries on network errors, 5xx, 429 | Custom retry logic |
25-
| `circuit` | `{ threshold: number, reset: number }` | `undefined` | Circuit-breaker configuration |
26-
| `hooks` | `{ before, after, onError, onRetry, onTimeout, onAbort, onCircuitOpen, onComplete, transformRequest, transformResponse }` | `{}` | Lifecycle hooks and transformers |
19+
| Option | Type | Default | Description |
20+
| -------------- | ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
21+
| `timeout` | `number` (ms) | `5000` | Whole-request timeout in milliseconds. Use `0` to disable timeout |
22+
| `retries` | `number` | `0` | Maximum retry attempts |
23+
| `retryDelay` | `number \| (ctx: { attempt, request, response, error }) => number` | Exponential backoff + jitter | Delay between retries |
24+
| `shouldRetry` | `(ctx: { attempt, request, response, error }) => boolean` | Retries on network errors, 5xx, 429 | Custom retry logic |
25+
| `circuit` | `{ threshold: number, reset: number }` | `undefined` | Circuit-breaker configuration |
26+
| `hooks` | `{ before, after, onError, onRetry, onTimeout, onAbort, onCircuitOpen, onComplete, transformRequest, transformResponse }` | `{}` | Lifecycle hooks and transformers |
27+
| `fetchHandler` | `(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>` | `global fetch` | Custom fetch-compatible implementation to wrap (e.g., SvelteKit, Next.js, Nuxt, node-fetch, undici, or any polyfill). Defaults to global fetch. |
2728

2829
### Return Type
2930

@@ -38,6 +39,10 @@ type FFetch = (
3839
shouldRetry?: (ctx: RetryContext) => boolean
3940
circuit?: { threshold: number; reset: number }
4041
hooks?: HooksConfig
42+
fetchHandler?: (
43+
input: RequestInfo | URL,
44+
init?: RequestInit
45+
) => Promise<Response>
4146
}
4247
) => Promise<Response>
4348
```
@@ -116,3 +121,26 @@ interface HooksConfig {
116121
) => Promise<Response> | Response
117122
}
118123
```
124+
125+
### Usage Examples
126+
127+
```typescript
128+
import createClient from '@gkoos/ffetch'
129+
130+
// Basic usage
131+
const client = createClient({
132+
timeout: 5000,
133+
retries: 3,
134+
})
135+
136+
// Pass a custom fetch-compatible implementation (SSR, metaframeworks, polyfills, node-fetch, undici, etc.)
137+
const client = createClient({
138+
timeout: 5000,
139+
retries: 3,
140+
fetchHandler: fetch, // SvelteKit/Next.js/Nuxt provide their own fetch
141+
})
142+
143+
// Or use node-fetch/undici in Node.js
144+
import nodeFetch from 'node-fetch'
145+
const clientNode = createClient({ fetchHandler: nodeFetch })
146+
```

docs/compatibility.md

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,13 +141,18 @@ await client('https://api.external.com/data', {
141141

142142
## Environment Detection
143143

144-
`ffetch` automatically adapts to the environment:
144+
`ffetch` automatically adapts to the environment and can wrap any fetch-compatible implementation:
145145

146146
```javascript
147147
// Automatically detects environment and uses appropriate fetch implementation
148-
const client = createClient()
148+
// Or pass your own fetch-compatible implementation for SSR, edge, or custom environments
149+
const client = createClient() // Uses global fetch by default
150+
151+
// Example: Use node-fetch, undici, or framework-provided fetch
152+
import fetch from 'node-fetch'
153+
const clientNode = createClient({ fetchHandler: fetch })
149154

150-
// Works in Node.js, browsers, workers, etc.
155+
// Works in Node.js, browsers, workers, SSR, edge, etc.
151156
const response = await client('https://api.example.com')
152157
```
153158

@@ -348,6 +353,47 @@ onUnmounted(() => {
348353
<div>{data ? JSON.stringify(data) : 'Loading...'}</div>
349354
```
350355
356+
### SSR Frameworks: SvelteKit, Next.js, Nuxt
357+
358+
For SvelteKit, Next.js, and Nuxt, you must pass the exact fetch instance provided by the framework in your handler or context. This is not the global fetch, and the parameter name may vary (often `fetch`, but check your framework docs).
359+
360+
**SvelteKit example:**
361+
362+
```typescript
363+
// In load functions, actions, or endpoints, use the provided fetch
364+
export async function load({ fetch }) {
365+
const client = createClient({ fetchHandler: fetch })
366+
// Use client for SSR-safe requests
367+
}
368+
369+
// In endpoints
370+
export async function GET({ fetch }) {
371+
const client = createClient({ fetchHandler: fetch })
372+
// ...
373+
}
374+
```
375+
376+
**Nuxt example:**
377+
378+
```typescript
379+
// In server routes, use event.fetch
380+
export default defineEventHandler((event) => {
381+
const client = createClient({ fetchHandler: event.fetch })
382+
// ...
383+
})
384+
```
385+
386+
**Next.js edge API route (if fetch is provided):**
387+
388+
```typescript
389+
export default async function handler(request) {
390+
const client = createClient({ fetchHandler: request.fetch })
391+
// ...
392+
}
393+
```
394+
395+
> Always use the fetch instance provided by the framework in your handler/context, not the global fetch. The parameter name may vary, but it is always context-specific.
396+
351397
## Troubleshooting
352398
353399
### Common Issues

docs/examples.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,36 @@ const users = await api.get<User[]>('/users')
9191
const newUser = await api.post<User>('/users', { name: 'John' })
9292
```
9393

94+
## Custom Fetch Usage
95+
96+
### Using ffetch with a Custom Fetch (e.g., node-fetch)
97+
98+
```typescript
99+
import createClient from '@gkoos/ffetch'
100+
import fetch from 'node-fetch'
101+
102+
const client = createClient({ fetchHandler: fetch })
103+
const response = await client('https://api.example.com/data')
104+
const data = await response.json()
105+
```
106+
107+
### Injecting a Mock Fetch for Unit Tests
108+
109+
```typescript
110+
import createClient from '@gkoos/ffetch'
111+
112+
function mockFetch(url, options) {
113+
return Promise.resolve(
114+
new Response(JSON.stringify({ ok: true, url }), { status: 200 })
115+
)
116+
}
117+
118+
const client = createClient({ fetchHandler: mockFetch })
119+
const response = await client('https://api.example.com/test')
120+
const data = await response.json()
121+
// data: { ok: true, url: 'https://api.example.com/test' }
122+
```
123+
94124
## Advanced Patterns
95125

96126
### Microservices Client

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
- **Circuit Breaker**: Automatic failure protection and recovery
9191
- **Hooks**: Lifecycle events for logging, auth, and transformation
9292
- **Pending Requests**: Real-time monitoring of active requests
93+
- **Custom fetch wrapping**: Pluggable fetch implementation for SSR, node-fetch, undici, and framework-provided fetch
9394

9495
### **Error Handling**
9596

docs/migration.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,18 @@ const response = await client('https://api.example.com/data', {
4343

4444
## Key Compatibility Points
4545

46-
### **Fully Compatible**
46+
### **Fully Compatible & Pluggable**
47+
48+
ffetch can now be used as a drop-in wrapper for custom fetch implementations. This makes migration easier for SSR/metaframeworks (SvelteKit, Next.js, Nuxt, etc.) and for environments where you need to provide your own fetch (e.g., node-fetch, undici, framework-provided fetch).
49+
50+
Simply pass your custom fetch implementation using the `fetchHandler` option:
51+
52+
```typescript
53+
import createClient from '@gkoos/ffetch'
54+
import fetch from 'node-fetch'
55+
56+
const client = createClient({ fetchHandler: fetch })
57+
```
4758

4859
These work exactly the same as native fetch:
4960

src/client.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export function createClient(opts: FFetchOptions = {}): FFetch {
2323
shouldRetry: clientDefaultShouldRetry = defaultShouldRetry,
2424
hooks: clientDefaultHooks = {},
2525
circuit: clientDefaultCircuit,
26+
fetchHandler,
2627
} = opts
2728

2829
const breaker = clientDefaultCircuit
@@ -220,7 +221,8 @@ export function createClient(opts: FFetchOptions = {}): FFetch {
220221
signal: combinedSignal,
221222
})
222223
try {
223-
return await fetch(reqWithSignal)
224+
const handler = fetchHandler ?? fetch
225+
return await handler(reqWithSignal)
224226
} catch (err) {
225227
throw mapToCustomError(err)
226228
}

src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ export interface FFetchOptions {
1414
shouldRetry?: (ctx: RetryContext) => boolean
1515
circuit?: { threshold: number; reset: number }
1616
hooks?: Hooks
17+
fetchHandler?: (
18+
input: RequestInfo | URL,
19+
init?: RequestInit
20+
) => Promise<Response>
1721
}
1822

1923
export type FFetch = {

0 commit comments

Comments
 (0)