tanstack-fetch 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +815 -0
- package/dist/chunk-T27KPHCL.js +867 -0
- package/dist/chunk-T27KPHCL.js.map +1 -0
- package/dist/cli.js +257 -0
- package/dist/cli.js.map +1 -0
- package/dist/fetch-error-B10Od9AT.d.cts +257 -0
- package/dist/fetch-error-B10Od9AT.d.ts +257 -0
- package/dist/index.cjs +881 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +23 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +951 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +34 -0
- package/dist/react.d.ts +34 -0
- package/dist/react.js +91 -0
- package/dist/react.js.map +1 -0
- package/package.json +97 -0
package/README.md
ADDED
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
# tanstack-fetch
|
|
2
|
+
|
|
3
|
+
Typed `fetch` client shaped for **TanStack Query**.
|
|
4
|
+
|
|
5
|
+
`queryFn` / `mutationFn` ready by default: returns **data**, throws **`FetchError`**, passes **`signal`**. Works in browser, SSR, and Edge — with SSE, named interceptors, and OpenAPI codegen.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install tanstack-fetch
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Node 18+ (native `fetch`).
|
|
12
|
+
|
|
13
|
+
> Not an official TanStack package — built to fit the same mental model as `@tanstack/react-query`.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Two ways to configure
|
|
18
|
+
|
|
19
|
+
### 1) Simple path — `baseUrl`, token, status handlers
|
|
20
|
+
|
|
21
|
+
Most apps only need this: set API URL, attach a token, and decide what happens on **401 / 403 / 404 / 5xx**.
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { createFetch } from 'tanstack-fetch'
|
|
25
|
+
|
|
26
|
+
export const api = createFetch({
|
|
27
|
+
baseUrl: import.meta.env.VITE_API_URL,
|
|
28
|
+
getToken: () => localStorage.getItem('access_token'),
|
|
29
|
+
|
|
30
|
+
onUnauthorized: () => {
|
|
31
|
+
localStorage.removeItem('access_token')
|
|
32
|
+
window.location.href = '/login' // 401
|
|
33
|
+
},
|
|
34
|
+
onForbidden: () => {
|
|
35
|
+
console.warn('No permission') // 403
|
|
36
|
+
},
|
|
37
|
+
onNotFound: ({ error }) => {
|
|
38
|
+
console.warn('Missing resource', error.message) // 404
|
|
39
|
+
},
|
|
40
|
+
onServerError: ({ status }) => {
|
|
41
|
+
console.error('Server error', status) // 500–599
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Handlers run **before** the error is thrown (so TanStack Query still gets `isError` / `FetchError`).
|
|
47
|
+
|
|
48
|
+
| Option | When |
|
|
49
|
+
| --- | --- |
|
|
50
|
+
| `getToken` / `auth` | Every request — sets `Authorization: Bearer …` |
|
|
51
|
+
| `onUnauthorized` | HTTP **401** |
|
|
52
|
+
| `onForbidden` | HTTP **403** |
|
|
53
|
+
| `onNotFound` | HTTP **404** |
|
|
54
|
+
| `onServerError` | HTTP **5xx** |
|
|
55
|
+
| `onStatus` | Advanced map (exact code, `4xx`, `5xx`, `default`) |
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
createFetch({
|
|
59
|
+
baseUrl: 'https://api.example.com',
|
|
60
|
+
auth: {
|
|
61
|
+
getToken: async () => (await cookies()).get('token')?.value,
|
|
62
|
+
header: 'authorization',
|
|
63
|
+
scheme: 'Bearer', // use '' for a raw token / API key
|
|
64
|
+
},
|
|
65
|
+
onStatus: {
|
|
66
|
+
401: () => redirect('/login'),
|
|
67
|
+
403: () => toast.error('Forbidden'),
|
|
68
|
+
404: () => toast.error('Not found'),
|
|
69
|
+
500: () => toast.error('Server error'),
|
|
70
|
+
'5xx': ({ status }) => console.error('upstream', status),
|
|
71
|
+
default: ({ status }) => console.warn('unhandled', status),
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### 2) Advanced path — plugins + custom interceptors
|
|
77
|
+
|
|
78
|
+
Full control: plugins, named interceptors, per-request eject, match filters.
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { createFetch } from 'tanstack-fetch'
|
|
82
|
+
|
|
83
|
+
export const api = createFetch({
|
|
84
|
+
baseUrl: 'https://api.example.com',
|
|
85
|
+
plugins: ['trace', 'ssr-forward', 'retry-idempotent', 'sse-resume'],
|
|
86
|
+
getToken: () => getAccessToken(),
|
|
87
|
+
onUnauthorized: () => logout(),
|
|
88
|
+
interceptors: [
|
|
89
|
+
{
|
|
90
|
+
name: 'locale',
|
|
91
|
+
order: 25,
|
|
92
|
+
onRequest: (context) => {
|
|
93
|
+
context.request.headers.set('accept-language', 'fa')
|
|
94
|
+
return { action: 'continue', context }
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
api.use('audit', {
|
|
101
|
+
onResponse: (context) => {
|
|
102
|
+
console.log(context.response?.status, context.request.url.pathname)
|
|
103
|
+
return { action: 'continue', context }
|
|
104
|
+
},
|
|
105
|
+
})
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### React `FetchProvider` (optional)
|
|
109
|
+
|
|
110
|
+
Same config, shared via context — like wrapping your app once.
|
|
111
|
+
|
|
112
|
+
```tsx
|
|
113
|
+
import { FetchProvider, useFetch } from 'tanstack-fetch/react'
|
|
114
|
+
import { useQuery } from '@tanstack/react-query'
|
|
115
|
+
import { isFetchError } from 'tanstack-fetch'
|
|
116
|
+
|
|
117
|
+
const App = () => (
|
|
118
|
+
<FetchProvider
|
|
119
|
+
baseUrl={import.meta.env.VITE_API_URL}
|
|
120
|
+
getToken={() => localStorage.getItem('access_token')}
|
|
121
|
+
onUnauthorized={() => {
|
|
122
|
+
localStorage.removeItem('access_token')
|
|
123
|
+
window.location.href = '/login'
|
|
124
|
+
}}
|
|
125
|
+
onForbidden={() => console.warn('403')}
|
|
126
|
+
onNotFound={() => console.warn('404')}
|
|
127
|
+
onServerError={({ status }) => console.error('5xx', status)}
|
|
128
|
+
plugins={['trace', 'retry-idempotent']}
|
|
129
|
+
>
|
|
130
|
+
<UsersPage />
|
|
131
|
+
</FetchProvider>
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
const UsersPage = () => {
|
|
135
|
+
const api = useFetch()
|
|
136
|
+
const { data, error, isPending } = useQuery({
|
|
137
|
+
queryKey: ['users'],
|
|
138
|
+
queryFn: ({ signal }) => api.get<User[]>('/users', { signal }),
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
if (isPending) return <p>Loading…</p>
|
|
142
|
+
if (isFetchError(error)) return <p>{error.status}: {error.message}</p>
|
|
143
|
+
return <ul>{data.map((u) => <li key={u.id}>{u.name}</li>)}</ul>
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Or pass an existing client:
|
|
148
|
+
|
|
149
|
+
```tsx
|
|
150
|
+
const api = createFetch({ baseUrl: '…', getToken: … })
|
|
151
|
+
|
|
152
|
+
<FetchProvider client={api}>
|
|
153
|
+
<App />
|
|
154
|
+
</FetchProvider>
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Why this API matches TanStack Query
|
|
160
|
+
|
|
161
|
+
| TanStack Query needs | `tanstack-fetch` does |
|
|
162
|
+
| --- | --- |
|
|
163
|
+
| `queryFn` returns data | `api.get<T>()` → `Promise<T>` |
|
|
164
|
+
| Failures must throw | HTTP errors throw `FetchError` |
|
|
165
|
+
| Cancellation | Pass `{ signal }` from `queryFn` context |
|
|
166
|
+
| Typed errors | `isFetchError(error)` → `status`, `code`, `body` |
|
|
167
|
+
|
|
168
|
+
### Install peers
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
npm install tanstack-fetch @tanstack/react-query
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Shared client
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
// src/lib/api.ts
|
|
178
|
+
import { createFetch } from 'tanstack-fetch'
|
|
179
|
+
|
|
180
|
+
export const api = createFetch({
|
|
181
|
+
baseUrl: import.meta.env.VITE_API_URL ?? 'https://api.example.com',
|
|
182
|
+
plugins: ['trace', 'retry-idempotent'],
|
|
183
|
+
})
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### `useQuery` — basic
|
|
187
|
+
|
|
188
|
+
```tsx
|
|
189
|
+
import { useQuery } from '@tanstack/react-query'
|
|
190
|
+
import { isFetchError } from 'tanstack-fetch'
|
|
191
|
+
import { api } from '#/lib/api'
|
|
192
|
+
|
|
193
|
+
type User = { id: string; name: string }
|
|
194
|
+
|
|
195
|
+
const UsersPage = () => {
|
|
196
|
+
const { data, error, isPending, isFetching, refetch } = useQuery({
|
|
197
|
+
queryKey: ['users'],
|
|
198
|
+
queryFn: ({ signal }) => api.get<User[]>('/users', { signal }),
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
if (isPending) return <p>Loading…</p>
|
|
202
|
+
if (isFetchError(error)) return <p>{error.status}: {error.message}</p>
|
|
203
|
+
if (error) return <p>Something went wrong</p>
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
<div>
|
|
207
|
+
<button onClick={() => refetch()} disabled={isFetching}>
|
|
208
|
+
Refresh
|
|
209
|
+
</button>
|
|
210
|
+
<ul>
|
|
211
|
+
{data.map((user) => (
|
|
212
|
+
<li key={user.id}>{user.name}</li>
|
|
213
|
+
))}
|
|
214
|
+
</ul>
|
|
215
|
+
</div>
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export default UsersPage
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### `useQuery` — with params + `enabled`
|
|
223
|
+
|
|
224
|
+
```tsx
|
|
225
|
+
import { useQuery } from '@tanstack/react-query'
|
|
226
|
+
import { isFetchError } from 'tanstack-fetch'
|
|
227
|
+
import { api } from '#/lib/api'
|
|
228
|
+
|
|
229
|
+
type User = { id: string; name: string; email: string }
|
|
230
|
+
|
|
231
|
+
const UserDetail = ({ userId }: { userId?: string }) => {
|
|
232
|
+
const { data, error, isPending } = useQuery({
|
|
233
|
+
queryKey: ['users', userId],
|
|
234
|
+
enabled: Boolean(userId),
|
|
235
|
+
queryFn: ({ signal }) =>
|
|
236
|
+
api.get<User>('/users/:id', {
|
|
237
|
+
params: { id: userId! },
|
|
238
|
+
signal,
|
|
239
|
+
}),
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
if (!userId) return <p>Select a user</p>
|
|
243
|
+
if (isPending) return <p>Loading…</p>
|
|
244
|
+
if (isFetchError(error)) {
|
|
245
|
+
if (error.status === 404) return <p>User not found</p>
|
|
246
|
+
return <p>{error.code}: {error.message}</p>
|
|
247
|
+
}
|
|
248
|
+
if (error) return <p>Something went wrong</p>
|
|
249
|
+
|
|
250
|
+
return (
|
|
251
|
+
<article>
|
|
252
|
+
<h1>{data.name}</h1>
|
|
253
|
+
<p>{data.email}</p>
|
|
254
|
+
</article>
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export default UserDetail
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### `useQuery` + official `queryOptions`
|
|
262
|
+
|
|
263
|
+
Share the same options between components, prefetch, and SSR:
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
// src/queries/users.ts
|
|
267
|
+
import { queryOptions } from '@tanstack/react-query'
|
|
268
|
+
import { api } from '#/lib/api'
|
|
269
|
+
|
|
270
|
+
type User = { id: string; name: string }
|
|
271
|
+
|
|
272
|
+
export const usersQueryOptions = queryOptions({
|
|
273
|
+
queryKey: ['users'],
|
|
274
|
+
queryFn: ({ signal }) => api.get<User[]>('/users', { signal }),
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
export const userQueryOptions = (id: string) =>
|
|
278
|
+
queryOptions({
|
|
279
|
+
queryKey: ['users', id],
|
|
280
|
+
queryFn: ({ signal }) =>
|
|
281
|
+
api.get<User>('/users/:id', { params: { id }, signal }),
|
|
282
|
+
})
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
```tsx
|
|
286
|
+
import { useQuery } from '@tanstack/react-query'
|
|
287
|
+
import { userQueryOptions, usersQueryOptions } from '#/queries/users'
|
|
288
|
+
|
|
289
|
+
const UsersPage = () => {
|
|
290
|
+
const { data: users } = useQuery(usersQueryOptions)
|
|
291
|
+
return <ul>{users?.map((u) => <li key={u.id}>{u.name}</li>)}</ul>
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const UserPage = ({ id }: { id: string }) => {
|
|
295
|
+
const { data: user } = useQuery(userQueryOptions(id))
|
|
296
|
+
return <h1>{user?.name}</h1>
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### `useMutation` + invalidate
|
|
301
|
+
|
|
302
|
+
```tsx
|
|
303
|
+
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|
304
|
+
import { isFetchError } from 'tanstack-fetch'
|
|
305
|
+
import { api } from '#/lib/api'
|
|
306
|
+
|
|
307
|
+
type CreateUser = { name: string; email: string }
|
|
308
|
+
type User = CreateUser & { id: string }
|
|
309
|
+
|
|
310
|
+
const CreateUserForm = () => {
|
|
311
|
+
const queryClient = useQueryClient()
|
|
312
|
+
|
|
313
|
+
const mutation = useMutation({
|
|
314
|
+
mutationFn: (body: CreateUser) => api.post<User>('/users', { body }),
|
|
315
|
+
onSuccess: () => {
|
|
316
|
+
void queryClient.invalidateQueries({ queryKey: ['users'] })
|
|
317
|
+
},
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
return (
|
|
321
|
+
<form
|
|
322
|
+
onSubmit={(event) => {
|
|
323
|
+
event.preventDefault()
|
|
324
|
+
const form = new FormData(event.currentTarget)
|
|
325
|
+
mutation.mutate({
|
|
326
|
+
name: String(form.get('name')),
|
|
327
|
+
email: String(form.get('email')),
|
|
328
|
+
})
|
|
329
|
+
}}
|
|
330
|
+
>
|
|
331
|
+
<input name="name" />
|
|
332
|
+
<input name="email" type="email" />
|
|
333
|
+
<button type="submit" disabled={mutation.isPending}>
|
|
334
|
+
Create
|
|
335
|
+
</button>
|
|
336
|
+
{isFetchError(mutation.error) && (
|
|
337
|
+
<p>{mutation.error.status}: {mutation.error.message}</p>
|
|
338
|
+
)}
|
|
339
|
+
</form>
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export default CreateUserForm
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
### Provider setup
|
|
347
|
+
|
|
348
|
+
```tsx
|
|
349
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
350
|
+
import { useState } from 'react'
|
|
351
|
+
|
|
352
|
+
const App = ({ children }: { children: React.ReactNode }) => {
|
|
353
|
+
const [queryClient] = useState(() => new QueryClient())
|
|
354
|
+
|
|
355
|
+
return (
|
|
356
|
+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
357
|
+
)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export default App
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
---
|
|
364
|
+
|
|
365
|
+
## Quick start
|
|
366
|
+
|
|
367
|
+
```ts
|
|
368
|
+
import { createFetch, isFetchError } from 'tanstack-fetch'
|
|
369
|
+
|
|
370
|
+
const api = createFetch({
|
|
371
|
+
baseUrl: 'https://api.example.com',
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
// Success → data
|
|
375
|
+
const user = await api.get<User>('/users/:id', { params: { id: '1' } })
|
|
376
|
+
user.name
|
|
377
|
+
|
|
378
|
+
// Failure → throws FetchError (like queryFn)
|
|
379
|
+
try {
|
|
380
|
+
await api.get('/missing')
|
|
381
|
+
} catch (error) {
|
|
382
|
+
if (isFetchError(error)) {
|
|
383
|
+
error.status // 404
|
|
384
|
+
error.code
|
|
385
|
+
error.body
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
Need a Result union instead? Opt out per client or per call:
|
|
391
|
+
|
|
392
|
+
```ts
|
|
393
|
+
const api = createFetch({ baseUrl: '...', throwOnError: false })
|
|
394
|
+
|
|
395
|
+
const result = await api.get<User>('/users/1', { throwOnError: false })
|
|
396
|
+
if (result.ok) result.data
|
|
397
|
+
else result.error
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
---
|
|
401
|
+
|
|
402
|
+
## Create a client (`createFetch`)
|
|
403
|
+
|
|
404
|
+
Same spirit as `createQueryClient` — one shared client, default options, plugins.
|
|
405
|
+
|
|
406
|
+
```ts
|
|
407
|
+
import { createFetch } from 'tanstack-fetch'
|
|
408
|
+
import { cookies } from 'next/headers'
|
|
409
|
+
|
|
410
|
+
export const api = createFetch({
|
|
411
|
+
baseUrl: process.env.API_URL ?? process.env.NEXT_PUBLIC_API_URL,
|
|
412
|
+
source: 'ssr', // 'browser' | 'ssr' | 'edge'
|
|
413
|
+
timeoutMs: 15_000,
|
|
414
|
+
maxRetries: 2,
|
|
415
|
+
throwOnError: true, // default — Query-friendly
|
|
416
|
+
credentials: 'include',
|
|
417
|
+
headers: { 'x-app': 'web' },
|
|
418
|
+
plugins: ['trace', 'ssr-forward', 'retry-idempotent', 'sse-resume'],
|
|
419
|
+
incoming: async () => ({
|
|
420
|
+
cookie: (await cookies()).toString(),
|
|
421
|
+
}),
|
|
422
|
+
})
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
| Option | Default | Notes |
|
|
426
|
+
| --- | --- | --- |
|
|
427
|
+
| `baseUrl` | — | Absolute URL required on SSR/Edge |
|
|
428
|
+
| `source` | `'browser'` | Runtime |
|
|
429
|
+
| `throwOnError` | `true` | `false` → `FetchResult` |
|
|
430
|
+
| `plugins` | `[]` | Built-in interceptors |
|
|
431
|
+
| `timeoutMs` | `30000` | Combined with Query `signal` |
|
|
432
|
+
| `maxRetries` | `2` | For interceptor `retry` actions |
|
|
433
|
+
| `fetch` | `globalThis.fetch` | Inject in tests |
|
|
434
|
+
|
|
435
|
+
---
|
|
436
|
+
|
|
437
|
+
## HTTP
|
|
438
|
+
|
|
439
|
+
```ts
|
|
440
|
+
await api.get<User[]>('/users')
|
|
441
|
+
await api.get<User>('/users/:id', { params: { id: '42' }, signal })
|
|
442
|
+
await api.get<User[]>('/users', { query: { page: 1, active: true } })
|
|
443
|
+
|
|
444
|
+
await api.post<User>('/users', { body: { name: 'Ada' } })
|
|
445
|
+
await api.put<User>('/users/:id', { params: { id: '42' }, body: { name: 'Ada' } })
|
|
446
|
+
await api.patch<User>('/users/:id', { params: { id: '42' }, body: { email: 'a@b.c' } })
|
|
447
|
+
await api.delete<void>('/users/:id', { params: { id: '42' } })
|
|
448
|
+
|
|
449
|
+
await api.request<User>('GET', '/users/:id', { params: { id: '1' } })
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
---
|
|
453
|
+
|
|
454
|
+
## `FetchError`
|
|
455
|
+
|
|
456
|
+
```ts
|
|
457
|
+
import { FetchError, isFetchError } from 'tanstack-fetch'
|
|
458
|
+
|
|
459
|
+
try {
|
|
460
|
+
await api.get('/secure')
|
|
461
|
+
} catch (error) {
|
|
462
|
+
if (isFetchError(error)) {
|
|
463
|
+
error.status
|
|
464
|
+
error.code
|
|
465
|
+
error.message
|
|
466
|
+
error.body
|
|
467
|
+
error.headers
|
|
468
|
+
error.result // full FetchResult
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
Abort / cancel from TanStack Query is **not** wrapped — `AbortError` propagates so Query can ignore cancelled fetches.
|
|
474
|
+
|
|
475
|
+
---
|
|
476
|
+
|
|
477
|
+
## Plugins
|
|
478
|
+
|
|
479
|
+
Built-in interceptors — enable by name:
|
|
480
|
+
|
|
481
|
+
```ts
|
|
482
|
+
createFetch({
|
|
483
|
+
baseUrl: 'https://api.example.com',
|
|
484
|
+
plugins: ['trace', 'ssr-forward', 'retry-idempotent', 'sse-resume'],
|
|
485
|
+
})
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
| Plugin | Role |
|
|
489
|
+
| --- | --- |
|
|
490
|
+
| `trace` | Sets `x-request-id` |
|
|
491
|
+
| `ssr-forward` | Forwards cookie/auth/request-id on SSR (no-op in browser) |
|
|
492
|
+
| `retry-idempotent` | Retries GET/HEAD/OPTIONS on 502/503/504 |
|
|
493
|
+
| `sse-resume` | Drops heartbeats; sends `Last-Event-ID` on reconnect |
|
|
494
|
+
|
|
495
|
+
```ts
|
|
496
|
+
import {
|
|
497
|
+
createFetch,
|
|
498
|
+
createTraceInterceptor,
|
|
499
|
+
createSsrForwardInterceptor,
|
|
500
|
+
} from 'tanstack-fetch'
|
|
501
|
+
|
|
502
|
+
const api = createFetch({ baseUrl: 'https://api.example.com' })
|
|
503
|
+
api.use('trace', createTraceInterceptor())
|
|
504
|
+
api.use('ssr-forward', createSsrForwardInterceptor())
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
### `trace`
|
|
508
|
+
|
|
509
|
+
```ts
|
|
510
|
+
const api = createFetch({
|
|
511
|
+
baseUrl: 'https://api.example.com',
|
|
512
|
+
plugins: ['trace'],
|
|
513
|
+
incoming: { requestId: 'req-from-gateway' },
|
|
514
|
+
})
|
|
515
|
+
await api.get('/users')
|
|
516
|
+
// → header x-request-id: req-from-gateway
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
### `ssr-forward` (Next.js)
|
|
520
|
+
|
|
521
|
+
```ts
|
|
522
|
+
import { cookies, headers } from 'next/headers'
|
|
523
|
+
import { createFetch } from 'tanstack-fetch'
|
|
524
|
+
|
|
525
|
+
export const createServerApi = async () =>
|
|
526
|
+
createFetch({
|
|
527
|
+
baseUrl: process.env.API_URL!,
|
|
528
|
+
source: 'ssr',
|
|
529
|
+
plugins: ['ssr-forward', 'trace', 'retry-idempotent'],
|
|
530
|
+
incoming: async () => {
|
|
531
|
+
const jar = await cookies()
|
|
532
|
+
const h = await headers()
|
|
533
|
+
return {
|
|
534
|
+
cookie: jar.toString(),
|
|
535
|
+
authorization: h.get('authorization') ?? undefined,
|
|
536
|
+
requestId: h.get('x-request-id') ?? undefined,
|
|
537
|
+
}
|
|
538
|
+
},
|
|
539
|
+
})
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
### `retry-idempotent`
|
|
543
|
+
|
|
544
|
+
```ts
|
|
545
|
+
const api = createFetch({
|
|
546
|
+
baseUrl: 'https://api.example.com',
|
|
547
|
+
plugins: ['retry-idempotent'],
|
|
548
|
+
maxRetries: 2,
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
await api.get('/health') // may retry on 503
|
|
552
|
+
await api.post('/orders', { body: { sku: 'A' } }) // not retried
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
### `sse-resume`
|
|
556
|
+
|
|
557
|
+
```ts
|
|
558
|
+
const api = createFetch({
|
|
559
|
+
baseUrl: 'https://api.example.com',
|
|
560
|
+
plugins: ['sse-resume'],
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
api.sse<OrderEvent>('/orders/stream', {
|
|
564
|
+
onMessage: (data) => {
|
|
565
|
+
// ping / heartbeat never reach here
|
|
566
|
+
console.log(data)
|
|
567
|
+
},
|
|
568
|
+
})
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
---
|
|
572
|
+
|
|
573
|
+
## Interceptors
|
|
574
|
+
|
|
575
|
+
Named, ordered, removable — customize auth, logging, mocks.
|
|
576
|
+
|
|
577
|
+
```ts
|
|
578
|
+
api.use('auth', {
|
|
579
|
+
order: 20,
|
|
580
|
+
onRequest: async (context) => {
|
|
581
|
+
context.request.headers.set('authorization', `Bearer ${await getToken()}`)
|
|
582
|
+
return { action: 'continue', context }
|
|
583
|
+
},
|
|
584
|
+
onResponseError: async (context) => {
|
|
585
|
+
if (context.error?.status !== 401 || context.meta.attempt > 0) {
|
|
586
|
+
return { action: 'continue', context }
|
|
587
|
+
}
|
|
588
|
+
await refreshToken()
|
|
589
|
+
return { action: 'retry' }
|
|
590
|
+
},
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
api.eject('auth')
|
|
594
|
+
|
|
595
|
+
// Per-request
|
|
596
|
+
await api.get('/public', { interceptors: { eject: ['auth'] } })
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
### Actions
|
|
600
|
+
|
|
601
|
+
`continue` · `skip` · `drop` (SSE) · `retry` · `short-circuit`
|
|
602
|
+
|
|
603
|
+
### Hooks
|
|
604
|
+
|
|
605
|
+
`onRequest` · `onRequestError` · `onResponse` · `onResponseError` · `onSseOpen` · `onSseEvent` · `onSseError` · `onSseReconnect`
|
|
606
|
+
|
|
607
|
+
### Mock short-circuit
|
|
608
|
+
|
|
609
|
+
```ts
|
|
610
|
+
api.use('mock-users', {
|
|
611
|
+
match: { pathPrefix: '/users' },
|
|
612
|
+
onRequest: () => ({
|
|
613
|
+
action: 'short-circuit',
|
|
614
|
+
result: {
|
|
615
|
+
ok: true,
|
|
616
|
+
status: 200,
|
|
617
|
+
data: [{ id: '1', name: 'Ada' }],
|
|
618
|
+
headers: new Headers(),
|
|
619
|
+
},
|
|
620
|
+
}),
|
|
621
|
+
})
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
---
|
|
625
|
+
|
|
626
|
+
## TanStack Query + Next.js SSR
|
|
627
|
+
|
|
628
|
+
```ts
|
|
629
|
+
// lib/api.ts
|
|
630
|
+
import { createFetch } from 'tanstack-fetch'
|
|
631
|
+
import { cookies } from 'next/headers'
|
|
632
|
+
|
|
633
|
+
export const createServerApi = async () =>
|
|
634
|
+
createFetch({
|
|
635
|
+
baseUrl: process.env.API_URL!,
|
|
636
|
+
source: 'ssr',
|
|
637
|
+
plugins: ['trace', 'ssr-forward', 'retry-idempotent'],
|
|
638
|
+
incoming: async () => ({ cookie: (await cookies()).toString() }),
|
|
639
|
+
})
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
```tsx
|
|
643
|
+
// app/users/page.tsx — prefetch into Query cache
|
|
644
|
+
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'
|
|
645
|
+
import { createServerApi } from '#/lib/api'
|
|
646
|
+
import { UsersClient } from './users-client'
|
|
647
|
+
|
|
648
|
+
const UsersPage = async () => {
|
|
649
|
+
const api = await createServerApi()
|
|
650
|
+
const queryClient = new QueryClient()
|
|
651
|
+
|
|
652
|
+
await queryClient.prefetchQuery({
|
|
653
|
+
queryKey: ['users'],
|
|
654
|
+
queryFn: () => api.get<User[]>('/users'),
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
return (
|
|
658
|
+
<HydrationBoundary state={dehydrate(queryClient)}>
|
|
659
|
+
<UsersClient />
|
|
660
|
+
</HydrationBoundary>
|
|
661
|
+
)
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
export default UsersPage
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
```tsx
|
|
668
|
+
// users-client.tsx
|
|
669
|
+
'use client'
|
|
670
|
+
|
|
671
|
+
import { useQuery } from '@tanstack/react-query'
|
|
672
|
+
import { createFetch } from 'tanstack-fetch'
|
|
673
|
+
|
|
674
|
+
const browserApi = createFetch({
|
|
675
|
+
baseUrl: process.env.NEXT_PUBLIC_API_URL,
|
|
676
|
+
source: 'browser',
|
|
677
|
+
credentials: 'include',
|
|
678
|
+
plugins: ['trace', 'retry-idempotent'],
|
|
679
|
+
})
|
|
680
|
+
|
|
681
|
+
export const UsersClient = () => {
|
|
682
|
+
const { data } = useQuery({
|
|
683
|
+
queryKey: ['users'],
|
|
684
|
+
queryFn: ({ signal }) => browserApi.get<User[]>('/users', { signal }),
|
|
685
|
+
})
|
|
686
|
+
|
|
687
|
+
return (
|
|
688
|
+
<ul>
|
|
689
|
+
{data?.map((user) => (
|
|
690
|
+
<li key={user.id}>{user.name}</li>
|
|
691
|
+
))}
|
|
692
|
+
</ul>
|
|
693
|
+
)
|
|
694
|
+
}
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
---
|
|
698
|
+
|
|
699
|
+
## SSE
|
|
700
|
+
|
|
701
|
+
Uses `fetch` streams (not `EventSource`) — Authorization, cookies, and SSR work.
|
|
702
|
+
|
|
703
|
+
### Simple — `onMessage`
|
|
704
|
+
|
|
705
|
+
```ts
|
|
706
|
+
const stream = api.sse<OrderEvent>('/orders/stream', {
|
|
707
|
+
onMessage: (data) => {
|
|
708
|
+
console.log(data) // just the payload
|
|
709
|
+
},
|
|
710
|
+
onError: (error) => console.error(error),
|
|
711
|
+
})
|
|
712
|
+
|
|
713
|
+
// later
|
|
714
|
+
stream.close()
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
### React — `useSse`
|
|
718
|
+
|
|
719
|
+
```tsx
|
|
720
|
+
import { useSse } from 'tanstack-fetch/react'
|
|
721
|
+
|
|
722
|
+
const OrdersLive = () => {
|
|
723
|
+
const { data, isConnected, error } = useSse<OrderEvent>('/orders/stream')
|
|
724
|
+
|
|
725
|
+
if (error) return <p>Stream failed</p>
|
|
726
|
+
return (
|
|
727
|
+
<p>
|
|
728
|
+
{isConnected ? 'Live' : 'Connecting…'} {data?.status}
|
|
729
|
+
</p>
|
|
730
|
+
)
|
|
731
|
+
}
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
### Advanced — `for await`
|
|
735
|
+
|
|
736
|
+
```ts
|
|
737
|
+
for await (const event of api.sse<OrderEvent>('/orders/stream', { signal })) {
|
|
738
|
+
event.event
|
|
739
|
+
event.data
|
|
740
|
+
event.id
|
|
741
|
+
}
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
---
|
|
745
|
+
|
|
746
|
+
## OpenAPI CLI
|
|
747
|
+
|
|
748
|
+
```bash
|
|
749
|
+
npx tanstack-fetch generate --spec ./openapi.yaml --out ./src/api
|
|
750
|
+
```
|
|
751
|
+
|
|
752
|
+
```ts
|
|
753
|
+
import { createApi } from './api'
|
|
754
|
+
import { queryOptions } from '@tanstack/react-query'
|
|
755
|
+
|
|
756
|
+
const api = createApi({
|
|
757
|
+
baseUrl: process.env.NEXT_PUBLIC_API_URL,
|
|
758
|
+
plugins: ['trace', 'retry-idempotent'],
|
|
759
|
+
})
|
|
760
|
+
|
|
761
|
+
export const getUserOptions = (id: string) =>
|
|
762
|
+
queryOptions({
|
|
763
|
+
queryKey: ['users', id],
|
|
764
|
+
queryFn: ({ signal }) => api.users.getUser({ params: { id }, signal }),
|
|
765
|
+
})
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
---
|
|
769
|
+
|
|
770
|
+
## Helpers
|
|
771
|
+
|
|
772
|
+
```ts
|
|
773
|
+
import { unwrap, unwrapAsync, isFetchError, isAbortError } from 'tanstack-fetch'
|
|
774
|
+
|
|
775
|
+
// When you already have a FetchResult
|
|
776
|
+
const user = unwrap(result)
|
|
777
|
+
const user2 = await unwrapAsync(api.get('/users/1', { throwOnError: false }))
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
---
|
|
781
|
+
|
|
782
|
+
## Example
|
|
783
|
+
|
|
784
|
+
```bash
|
|
785
|
+
npm run example
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
## API
|
|
789
|
+
|
|
790
|
+
```ts
|
|
791
|
+
import {
|
|
792
|
+
createFetch,
|
|
793
|
+
createFetchError,
|
|
794
|
+
isFetchError,
|
|
795
|
+
isAbortError,
|
|
796
|
+
unwrap,
|
|
797
|
+
unwrapAsync,
|
|
798
|
+
createTraceInterceptor,
|
|
799
|
+
createSsrForwardInterceptor,
|
|
800
|
+
createRetryIdempotentInterceptor,
|
|
801
|
+
createSseResumeInterceptor,
|
|
802
|
+
} from 'tanstack-fetch'
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
| Method | Description |
|
|
806
|
+
| --- | --- |
|
|
807
|
+
| `get/post/put/patch/delete` | Typed HTTP → `Promise<T>` |
|
|
808
|
+
| `request(method, path, opts?)` | Generic verb |
|
|
809
|
+
| `sse(path, { onMessage })` | Simple stream — returns `{ close }` |
|
|
810
|
+
| `sse(path)` | Advanced — `for await` iterable |
|
|
811
|
+
| `use` / `eject` | Interceptors |
|
|
812
|
+
|
|
813
|
+
## License
|
|
814
|
+
|
|
815
|
+
MIT
|