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/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