Skip to content

Commit af99c0e

Browse files
feat: allow custom fetch impl for server functions
1 parent 4afc1f1 commit af99c0e

File tree

10 files changed

+206
-5
lines changed

10 files changed

+206
-5
lines changed

e2e/react-start/server-functions/src/routeTree.gen.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { Route as HeadersRouteImport } from './routes/headers'
2222
import { Route as FormdataContextRouteImport } from './routes/formdata-context'
2323
import { Route as EnvOnlyRouteImport } from './routes/env-only'
2424
import { Route as DeadCodePreserveRouteImport } from './routes/dead-code-preserve'
25+
import { Route as CustomFetchRouteImport } from './routes/custom-fetch'
2526
import { Route as ConsistentRouteImport } from './routes/consistent'
2627
import { Route as AsyncValidationRouteImport } from './routes/async-validation'
2728
import { Route as IndexRouteImport } from './routes/index'
@@ -112,6 +113,11 @@ const DeadCodePreserveRoute = DeadCodePreserveRouteImport.update({
112113
path: '/dead-code-preserve',
113114
getParentRoute: () => rootRouteImport,
114115
} as any)
116+
const CustomFetchRoute = CustomFetchRouteImport.update({
117+
id: '/custom-fetch',
118+
path: '/custom-fetch',
119+
getParentRoute: () => rootRouteImport,
120+
} as any)
115121
const ConsistentRoute = ConsistentRouteImport.update({
116122
id: '/consistent',
117123
path: '/consistent',
@@ -245,6 +251,7 @@ export interface FileRoutesByFullPath {
245251
'/': typeof IndexRoute
246252
'/async-validation': typeof AsyncValidationRoute
247253
'/consistent': typeof ConsistentRoute
254+
'/custom-fetch': typeof CustomFetchRoute
248255
'/dead-code-preserve': typeof DeadCodePreserveRoute
249256
'/env-only': typeof EnvOnlyRoute
250257
'/formdata-context': typeof FormdataContextRoute
@@ -284,6 +291,7 @@ export interface FileRoutesByTo {
284291
'/': typeof IndexRoute
285292
'/async-validation': typeof AsyncValidationRoute
286293
'/consistent': typeof ConsistentRoute
294+
'/custom-fetch': typeof CustomFetchRoute
287295
'/dead-code-preserve': typeof DeadCodePreserveRoute
288296
'/env-only': typeof EnvOnlyRoute
289297
'/formdata-context': typeof FormdataContextRoute
@@ -324,6 +332,7 @@ export interface FileRoutesById {
324332
'/': typeof IndexRoute
325333
'/async-validation': typeof AsyncValidationRoute
326334
'/consistent': typeof ConsistentRoute
335+
'/custom-fetch': typeof CustomFetchRoute
327336
'/dead-code-preserve': typeof DeadCodePreserveRoute
328337
'/env-only': typeof EnvOnlyRoute
329338
'/formdata-context': typeof FormdataContextRoute
@@ -365,6 +374,7 @@ export interface FileRouteTypes {
365374
| '/'
366375
| '/async-validation'
367376
| '/consistent'
377+
| '/custom-fetch'
368378
| '/dead-code-preserve'
369379
| '/env-only'
370380
| '/formdata-context'
@@ -404,6 +414,7 @@ export interface FileRouteTypes {
404414
| '/'
405415
| '/async-validation'
406416
| '/consistent'
417+
| '/custom-fetch'
407418
| '/dead-code-preserve'
408419
| '/env-only'
409420
| '/formdata-context'
@@ -443,6 +454,7 @@ export interface FileRouteTypes {
443454
| '/'
444455
| '/async-validation'
445456
| '/consistent'
457+
| '/custom-fetch'
446458
| '/dead-code-preserve'
447459
| '/env-only'
448460
| '/formdata-context'
@@ -483,6 +495,7 @@ export interface RootRouteChildren {
483495
IndexRoute: typeof IndexRoute
484496
AsyncValidationRoute: typeof AsyncValidationRoute
485497
ConsistentRoute: typeof ConsistentRoute
498+
CustomFetchRoute: typeof CustomFetchRoute
486499
DeadCodePreserveRoute: typeof DeadCodePreserveRoute
487500
EnvOnlyRoute: typeof EnvOnlyRoute
488501
FormdataContextRoute: typeof FormdataContextRoute
@@ -612,6 +625,13 @@ declare module '@tanstack/react-router' {
612625
preLoaderRoute: typeof DeadCodePreserveRouteImport
613626
parentRoute: typeof rootRouteImport
614627
}
628+
'/custom-fetch': {
629+
id: '/custom-fetch'
630+
path: '/custom-fetch'
631+
fullPath: '/custom-fetch'
632+
preLoaderRoute: typeof CustomFetchRouteImport
633+
parentRoute: typeof rootRouteImport
634+
}
615635
'/consistent': {
616636
id: '/consistent'
617637
path: '/consistent'
@@ -787,6 +807,7 @@ const rootRouteChildren: RootRouteChildren = {
787807
IndexRoute: IndexRoute,
788808
AsyncValidationRoute: AsyncValidationRoute,
789809
ConsistentRoute: ConsistentRoute,
810+
CustomFetchRoute: CustomFetchRoute,
790811
DeadCodePreserveRoute: DeadCodePreserveRoute,
791812
EnvOnlyRoute: EnvOnlyRoute,
792813
FormdataContextRoute: FormdataContextRoute,
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { createFileRoute } from '@tanstack/react-router'
2+
import * as React from 'react'
3+
import { createMiddleware, createServerFn } from '@tanstack/react-start'
4+
import { getRequestHeaders } from '@tanstack/react-start/server'
5+
import type { CustomFetch } from '@tanstack/react-start'
6+
7+
export const Route = createFileRoute('/custom-fetch')({
8+
component: CustomFetchComponent,
9+
})
10+
11+
// Server function that returns request headers
12+
const getHeaders = createServerFn().handler(() => {
13+
return Object.fromEntries(getRequestHeaders().entries())
14+
})
15+
16+
// Middleware that sets a custom fetch with a special header
17+
const customFetchMiddleware = createMiddleware({ type: 'function' }).client(
18+
async ({ next }) => {
19+
const customFetch: CustomFetch = (input, init) => {
20+
const headers = new Headers(init?.headers)
21+
headers.set('x-custom-fetch-middleware', 'true')
22+
return fetch(input, { ...init, headers })
23+
}
24+
return next({ fetch: customFetch })
25+
},
26+
)
27+
28+
// Server function using middleware for custom fetch
29+
const getHeadersWithMiddleware = createServerFn()
30+
.middleware([customFetchMiddleware])
31+
.handler(() => {
32+
return Object.fromEntries(getRequestHeaders().entries())
33+
})
34+
35+
function CustomFetchComponent() {
36+
const [directResult, setDirectResult] = React.useState<Record<
37+
string,
38+
string
39+
> | null>(null)
40+
const [middlewareResult, setMiddlewareResult] = React.useState<Record<
41+
string,
42+
string
43+
> | null>(null)
44+
45+
const handleDirectFetch = async () => {
46+
const customFetch: CustomFetch = (input, init) => {
47+
const headers = new Headers(init?.headers)
48+
headers.set('x-custom-fetch-direct', 'true')
49+
return fetch(input, { ...init, headers })
50+
}
51+
const result = await getHeaders({ fetch: customFetch })
52+
setDirectResult(result)
53+
}
54+
55+
const handleMiddlewareFetch = async () => {
56+
const result = await getHeadersWithMiddleware()
57+
setMiddlewareResult(result)
58+
}
59+
60+
return (
61+
<div className="p-2 m-2 grid gap-4">
62+
<h3>Custom Fetch Implementation Test</h3>
63+
64+
<div>
65+
<h4>Direct Custom Fetch</h4>
66+
<button
67+
type="button"
68+
data-testid="test-direct-custom-fetch-btn"
69+
onClick={handleDirectFetch}
70+
className="rounded-md bg-white px-2.5 py-1.5 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
71+
>
72+
Test Direct Custom Fetch
73+
</button>
74+
<pre data-testid="direct-custom-fetch-result">
75+
{directResult ? JSON.stringify(directResult, null, 2) : 'null'}
76+
</pre>
77+
</div>
78+
79+
<div>
80+
<h4>Middleware Custom Fetch</h4>
81+
<button
82+
type="button"
83+
data-testid="test-middleware-custom-fetch-btn"
84+
onClick={handleMiddlewareFetch}
85+
className="rounded-md bg-white px-2.5 py-1.5 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
86+
>
87+
Test Middleware Custom Fetch
88+
</button>
89+
<pre data-testid="middleware-custom-fetch-result">
90+
{middlewareResult
91+
? JSON.stringify(middlewareResult, null, 2)
92+
: 'null'}
93+
</pre>
94+
</div>
95+
</div>
96+
)
97+
}

e2e/react-start/server-functions/src/routes/index.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,11 @@ function Home() {
104104
Server Functions Middleware Unhandled Exception E2E tests
105105
</Link>
106106
</li>
107+
<li>
108+
<Link to="/custom-fetch">
109+
Server function with custom fetch implementation
110+
</Link>
111+
</li>
107112
</ul>
108113
</div>
109114
)

e2e/react-start/server-functions/tests/server-functions.spec.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,3 +757,37 @@ test.describe('unhandled exception in middleware (issue #5266)', () => {
757757
await expect(page.getByTestId('route-success')).not.toBeVisible()
758758
})
759759
})
760+
761+
test('server function with custom fetch implementation passed directly', async ({
762+
page,
763+
}) => {
764+
await page.goto('/custom-fetch')
765+
await page.waitForLoadState('networkidle')
766+
767+
await page.getByTestId('test-direct-custom-fetch-btn').click()
768+
await page.waitForSelector(
769+
'[data-testid="direct-custom-fetch-result"]:not(:has-text("null"))',
770+
)
771+
772+
const result = await page
773+
.getByTestId('direct-custom-fetch-result')
774+
.textContent()
775+
expect(result).toContain('x-custom-fetch-direct')
776+
})
777+
778+
test('server function with custom fetch implementation via middleware', async ({
779+
page,
780+
}) => {
781+
await page.goto('/custom-fetch')
782+
await page.waitForLoadState('networkidle')
783+
784+
await page.getByTestId('test-middleware-custom-fetch-btn').click()
785+
await page.waitForSelector(
786+
'[data-testid="middleware-custom-fetch-result"]:not(:has-text("null"))',
787+
)
788+
789+
const result = await page
790+
.getByTestId('middleware-custom-fetch-result')
791+
.textContent()
792+
expect(result).toContain('x-custom-fetch-middleware')
793+
})

packages/start-client-core/src/client-rpc/serverFnFetcher.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ export async function serverFnFetcher(
5656
const first = _first as FunctionMiddlewareClientFnOptions<any, any, any> & {
5757
headers?: HeadersInit
5858
}
59+
60+
// Use custom fetch if provided, otherwise fall back to the passed handler (global fetch)
61+
const fetchImpl = first.fetch ?? handler
62+
5963
const type = first.data instanceof FormData ? 'formData' : 'payload'
6064

6165
// Arrange the headers
@@ -97,7 +101,7 @@ export async function serverFnFetcher(
97101
}
98102

99103
return await getResponse(async () =>
100-
handler(url, {
104+
fetchImpl(url, {
101105
method: first.method,
102106
headers,
103107
signal: first.signal,

packages/start-client-core/src/createMiddleware.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import type { StartInstanceOptions } from './createStart'
2-
import type { AnyServerFn, ConstrainValidator, Method } from './createServerFn'
2+
import type {
3+
AnyServerFn,
4+
ConstrainValidator,
5+
CustomFetch,
6+
Method,
7+
} from './createServerFn'
38
import type {
49
AnyContext,
510
Assign,
@@ -402,6 +407,7 @@ export type FunctionMiddlewareClientNextFn<TRegister, TMiddlewares> = <
402407
context?: TNewClientContext
403408
sendContext?: ValidateSerializableInput<TRegister, TSendContext>
404409
headers?: HeadersInit
410+
fetch?: CustomFetch
405411
}) => Promise<
406412
FunctionClientResultWithContext<TMiddlewares, TSendContext, TNewClientContext>
407413
>
@@ -611,6 +617,7 @@ export interface FunctionMiddlewareClientFnOptions<
611617
next: FunctionMiddlewareClientNextFn<TRegister, TMiddlewares>
612618
filename: string
613619
functionId: string
620+
fetch?: CustomFetch
614621
}
615622

616623
export type FunctionMiddlewareClientFnResult<
@@ -636,6 +643,7 @@ export type FunctionClientResultWithContext<
636643
context: Expand<AssignAllClientContextAfterNext<TMiddlewares, TClientContext>>
637644
sendContext: Expand<AssignAllServerSendContext<TMiddlewares, TSendContext>>
638645
headers: HeadersInit
646+
fetch?: CustomFetch
639647
}
640648

641649
export interface FunctionMiddlewareAfterClient<

packages/start-client-core/src/createServerFn.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ export const createServerFn: CreateServerFn<Register> = (options, __opts) => {
119119
data: opts?.data as any,
120120
headers: opts?.headers,
121121
signal: opts?.signal,
122+
fetch: opts?.fetch,
122123
context: createNullProtoObject(),
123124
})
124125

@@ -248,6 +249,7 @@ export async function executeMiddleware(
248249
context: safeObjectMerge(ctx.context, userCtx.context),
249250
sendContext: safeObjectMerge(ctx.sendContext, userCtx.sendContext),
250251
headers: mergeHeaders(ctx.headers, userCtx.headers),
252+
fetch: userCtx.fetch ?? ctx.fetch,
251253
result:
252254
userCtx.result !== undefined
253255
? userCtx.result
@@ -321,6 +323,7 @@ export type CompiledFetcherFnOptions = {
321323
data: unknown
322324
headers?: HeadersInit
323325
signal?: AbortSignal
326+
fetch?: CustomFetch
324327
context?: any
325328
}
326329

@@ -355,9 +358,12 @@ export interface RequiredFetcher<TMiddlewares, TInputValidator, TResponse>
355358
): Promise<Awaited<TResponse>>
356359
}
357360

361+
export type CustomFetch = typeof globalThis.fetch
362+
358363
export type FetcherBaseOptions = {
359364
headers?: HeadersInit
360365
signal?: AbortSignal
366+
fetch?: CustomFetch
361367
}
362368

363369
export interface OptionalFetcherDataOptions<TMiddlewares, TInputValidator>
@@ -686,6 +692,7 @@ export type ServerFnMiddlewareOptions = {
686692
sendContext?: any
687693
context?: any
688694
functionId: string
695+
fetch?: CustomFetch
689696
}
690697

691698
export type ServerFnMiddlewareResult = ServerFnMiddlewareOptions & {
@@ -736,11 +743,12 @@ function serverFnBaseToMiddleware(
736743
'~types': undefined!,
737744
options: {
738745
inputValidator: options.inputValidator,
739-
client: async ({ next, sendContext, ...ctx }) => {
746+
client: async ({ next, sendContext, fetch, ...ctx }) => {
740747
const payload = {
741748
...ctx,
742749
// switch the sendContext over to context
743750
context: sendContext,
751+
fetch,
744752
} as any
745753

746754
// Execute the extracted function

packages/start-client-core/src/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export {
5959
export type {
6060
CompiledFetcherFnOptions,
6161
CompiledFetcherFn,
62+
CustomFetch,
6263
Fetcher,
6364
RscStream,
6465
FetcherBaseOptions,

0 commit comments

Comments
 (0)