diff --git a/docs/start/framework/react/guide/middleware.md b/docs/start/framework/react/guide/middleware.md index 1906c21c92..50ce64928d 100644 --- a/docs/start/framework/react/guide/middleware.md +++ b/docs/start/framework/react/guide/middleware.md @@ -520,11 +520,11 @@ Middleware that uses the `server` method executes in the same context as server ### Modifying the Client Request -Middleware that uses the `client` method executes in a **completely different client-side context** than server functions, so you can't use the same utilities to read and modify the request. However, you can still modify the request returning additional properties when calling the `next` function. Currently supported properties are: +Middleware that uses the `client` method executes in a **completely different client-side context** than server functions, so you can't use the same utilities to read and modify the request. However, you can still modify the request by returning additional properties when calling the `next` function. -- `headers`: An object containing headers to be added to the request. +#### Setting Custom Headers -Here's an example of adding an `Authorization` header any request using this middleware: +You can add headers to the outgoing request by passing a `headers` object to `next`: ```tsx import { getToken } from 'my-auth-library' @@ -540,6 +540,184 @@ const authMiddleware = createMiddleware({ type: 'function' }).client( ) ``` +#### Header Merging Across Middleware + +When multiple middlewares set headers, they are **merged together**. Later middlewares can add new headers or override headers set by earlier middlewares: + +```tsx +const firstMiddleware = createMiddleware({ type: 'function' }).client( + async ({ next }) => { + return next({ + headers: { + 'X-Request-ID': '12345', + 'X-Source': 'first-middleware', + }, + }) + }, +) + +const secondMiddleware = createMiddleware({ type: 'function' }).client( + async ({ next }) => { + return next({ + headers: { + 'X-Timestamp': Date.now().toString(), + 'X-Source': 'second-middleware', // Overrides first middleware + }, + }) + }, +) + +// Final headers will include: +// - X-Request-ID: '12345' (from first) +// - X-Timestamp: '' (from second) +// - X-Source: 'second-middleware' (second overrides first) +``` + +You can also set headers directly at the call site: + +```tsx +await myServerFn({ + data: { name: 'John' }, + headers: { + 'X-Custom-Header': 'call-site-value', + }, +}) +``` + +**Header precedence (all headers are merged, later values override earlier):** + +1. Earlier middleware headers +2. Later middleware headers (override earlier) +3. Call-site headers (override all middleware headers) + +#### Custom Fetch Implementation + +For advanced use cases, you can provide a custom `fetch` implementation to control how server function requests are made. This is useful for: + +- Adding request interceptors or retry logic +- Using a custom HTTP client +- Testing and mocking +- Adding telemetry or monitoring + +**Via Client Middleware:** + +```tsx +import type { CustomFetch } from '@tanstack/react-start' + +const customFetchMiddleware = createMiddleware({ type: 'function' }).client( + async ({ next }) => { + const customFetch: CustomFetch = async (url, init) => { + console.log('Request starting:', url) + const start = Date.now() + + const response = await fetch(url, init) + + console.log('Request completed in', Date.now() - start, 'ms') + return response + } + + return next({ fetch: customFetch }) + }, +) +``` + +**Directly at Call Site:** + +```tsx +import type { CustomFetch } from '@tanstack/react-start' + +const myFetch: CustomFetch = async (url, init) => { + // Add custom logic here + return fetch(url, init) +} + +await myServerFn({ + data: { name: 'John' }, + fetch: myFetch, +}) +``` + +#### Fetch Override Precedence + +When custom fetch implementations are provided at multiple levels, the following precedence applies (highest to lowest priority): + +| Priority | Source | Description | +| ----------- | ------------------ | ----------------------------------------------- | +| 1 (highest) | Call site | `serverFn({ fetch: customFetch })` | +| 2 | Later middleware | Last middleware in chain that provides `fetch` | +| 3 | Earlier middleware | First middleware in chain that provides `fetch` | +| 4 (lowest) | Default | Global `fetch` function | + +**Key principle:** The call site always wins. This allows you to override middleware behavior for specific calls when needed. + +```tsx +// Middleware sets a fetch that adds logging +const loggingMiddleware = createMiddleware({ type: 'function' }).client( + async ({ next }) => { + const loggingFetch: CustomFetch = async (url, init) => { + console.log('Middleware fetch:', url) + return fetch(url, init) + } + return next({ fetch: loggingFetch }) + }, +) + +const myServerFn = createServerFn() + .middleware([loggingMiddleware]) + .handler(async () => { + return { message: 'Hello' } + }) + +// Uses middleware's loggingFetch +await myServerFn() + +// Override with custom fetch for this specific call +const testFetch: CustomFetch = async (url, init) => { + console.log('Test fetch:', url) + return fetch(url, init) +} +await myServerFn({ fetch: testFetch }) // Uses testFetch, NOT loggingFetch +``` + +**Chained Middleware Example:** + +When multiple middlewares provide fetch, the last one wins: + +```tsx +const firstMiddleware = createMiddleware({ type: 'function' }).client( + async ({ next }) => { + const firstFetch: CustomFetch = (url, init) => { + const headers = new Headers(init?.headers) + headers.set('X-From', 'first-middleware') + return fetch(url, { ...init, headers }) + } + return next({ fetch: firstFetch }) + }, +) + +const secondMiddleware = createMiddleware({ type: 'function' }).client( + async ({ next }) => { + const secondFetch: CustomFetch = (url, init) => { + const headers = new Headers(init?.headers) + headers.set('X-From', 'second-middleware') + return fetch(url, { ...init, headers }) + } + return next({ fetch: secondFetch }) + }, +) + +const myServerFn = createServerFn() + .middleware([firstMiddleware, secondMiddleware]) + .handler(async () => { + // Request will have X-From: 'second-middleware' + // because secondMiddleware's fetch overrides firstMiddleware's fetch + return { message: 'Hello' } + }) +``` + +> [!NOTE] +> Custom fetch only applies on the client side. During SSR, server functions are called directly without going through fetch. + ## Environment and Performance ### Environment Tree Shaking diff --git a/e2e/react-start/server-functions/src/routeTree.gen.ts b/e2e/react-start/server-functions/src/routeTree.gen.ts index 63b3480aa5..3cacb6fed4 100644 --- a/e2e/react-start/server-functions/src/routeTree.gen.ts +++ b/e2e/react-start/server-functions/src/routeTree.gen.ts @@ -22,6 +22,7 @@ import { Route as HeadersRouteImport } from './routes/headers' import { Route as FormdataContextRouteImport } from './routes/formdata-context' import { Route as EnvOnlyRouteImport } from './routes/env-only' import { Route as DeadCodePreserveRouteImport } from './routes/dead-code-preserve' +import { Route as CustomFetchRouteImport } from './routes/custom-fetch' import { Route as ConsistentRouteImport } from './routes/consistent' import { Route as AsyncValidationRouteImport } from './routes/async-validation' import { Route as IndexRouteImport } from './routes/index' @@ -112,6 +113,11 @@ const DeadCodePreserveRoute = DeadCodePreserveRouteImport.update({ path: '/dead-code-preserve', getParentRoute: () => rootRouteImport, } as any) +const CustomFetchRoute = CustomFetchRouteImport.update({ + id: '/custom-fetch', + path: '/custom-fetch', + getParentRoute: () => rootRouteImport, +} as any) const ConsistentRoute = ConsistentRouteImport.update({ id: '/consistent', path: '/consistent', @@ -245,6 +251,7 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/async-validation': typeof AsyncValidationRoute '/consistent': typeof ConsistentRoute + '/custom-fetch': typeof CustomFetchRoute '/dead-code-preserve': typeof DeadCodePreserveRoute '/env-only': typeof EnvOnlyRoute '/formdata-context': typeof FormdataContextRoute @@ -284,6 +291,7 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/async-validation': typeof AsyncValidationRoute '/consistent': typeof ConsistentRoute + '/custom-fetch': typeof CustomFetchRoute '/dead-code-preserve': typeof DeadCodePreserveRoute '/env-only': typeof EnvOnlyRoute '/formdata-context': typeof FormdataContextRoute @@ -324,6 +332,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/async-validation': typeof AsyncValidationRoute '/consistent': typeof ConsistentRoute + '/custom-fetch': typeof CustomFetchRoute '/dead-code-preserve': typeof DeadCodePreserveRoute '/env-only': typeof EnvOnlyRoute '/formdata-context': typeof FormdataContextRoute @@ -365,6 +374,7 @@ export interface FileRouteTypes { | '/' | '/async-validation' | '/consistent' + | '/custom-fetch' | '/dead-code-preserve' | '/env-only' | '/formdata-context' @@ -404,6 +414,7 @@ export interface FileRouteTypes { | '/' | '/async-validation' | '/consistent' + | '/custom-fetch' | '/dead-code-preserve' | '/env-only' | '/formdata-context' @@ -443,6 +454,7 @@ export interface FileRouteTypes { | '/' | '/async-validation' | '/consistent' + | '/custom-fetch' | '/dead-code-preserve' | '/env-only' | '/formdata-context' @@ -483,6 +495,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute AsyncValidationRoute: typeof AsyncValidationRoute ConsistentRoute: typeof ConsistentRoute + CustomFetchRoute: typeof CustomFetchRoute DeadCodePreserveRoute: typeof DeadCodePreserveRoute EnvOnlyRoute: typeof EnvOnlyRoute FormdataContextRoute: typeof FormdataContextRoute @@ -612,6 +625,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DeadCodePreserveRouteImport parentRoute: typeof rootRouteImport } + '/custom-fetch': { + id: '/custom-fetch' + path: '/custom-fetch' + fullPath: '/custom-fetch' + preLoaderRoute: typeof CustomFetchRouteImport + parentRoute: typeof rootRouteImport + } '/consistent': { id: '/consistent' path: '/consistent' @@ -787,6 +807,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AsyncValidationRoute: AsyncValidationRoute, ConsistentRoute: ConsistentRoute, + CustomFetchRoute: CustomFetchRoute, DeadCodePreserveRoute: DeadCodePreserveRoute, EnvOnlyRoute: EnvOnlyRoute, FormdataContextRoute: FormdataContextRoute, diff --git a/e2e/react-start/server-functions/src/routes/custom-fetch.tsx b/e2e/react-start/server-functions/src/routes/custom-fetch.tsx new file mode 100644 index 0000000000..e2f3ff42ad --- /dev/null +++ b/e2e/react-start/server-functions/src/routes/custom-fetch.tsx @@ -0,0 +1,380 @@ +import { createFileRoute } from '@tanstack/react-router' +import * as React from 'react' +import { createMiddleware, createServerFn } from '@tanstack/react-start' +import { getRequestHeaders } from '@tanstack/react-start/server' +import type { CustomFetch } from '@tanstack/react-start' + +/** + * Custom Fetch Implementation Tests + * + * This route tests the custom fetch feature for server functions. Users can provide + * a custom `fetch` implementation to control how server function requests are made. + * + * ## Fetch Override Precedence (highest to lowest priority): + * + * 1. **Direct call-site fetch** - When `serverFn({ fetch: customFetch })` is called, + * this fetch takes highest priority and overrides any middleware fetch. + * + * 2. **Later middleware fetch** - When multiple middlewares provide fetch, the LAST + * middleware in the chain wins. Middleware is executed in order, and each call to + * `next({ fetch })` can override the previous fetch. + * + * 3. **Earlier middleware fetch** - Middlewares earlier in the chain have lower priority. + * Their fetch will be used only if no later middleware or direct call provides one. + * + * 4. **Default global fetch** - If no custom fetch is provided anywhere, the global + * `fetch` function is used. + * + * ## Why This Design? + * + * - **Direct call wins**: Gives maximum control to the call site. Users can always + * override middleware behavior when needed for specific use cases. + * + * - **Later middleware wins**: Follows the middleware chain execution order. Each + * middleware can see and override what previous middlewares set, similar to how + * middleware can modify context or headers. + * + * - **Fallback to default**: Ensures backward compatibility. Existing code without + * custom fetch continues to work as expected. + */ + +export const Route = createFileRoute('/custom-fetch')({ + component: CustomFetchComponent, +}) + +/** + * Basic server function that returns all request headers. + * Used to verify which custom fetch implementation was actually used + * by checking for the presence of custom headers. + */ +const getHeaders = createServerFn().handler(() => { + return Object.fromEntries(getRequestHeaders().entries()) +}) + +/** + * Middleware that injects 'x-custom-fetch-middleware: true' header. + * + * When used alone, this header should appear in the request. + * When a direct call-site fetch is also provided, this middleware's fetch + * should be OVERRIDDEN and this header should NOT appear. + */ +const customFetchMiddleware = createMiddleware({ type: 'function' }).client( + async ({ next }) => { + const customFetch: CustomFetch = (input, init) => { + const headers = new Headers(init?.headers) + headers.set('x-custom-fetch-middleware', 'true') + return fetch(input, { ...init, headers }) + } + return next({ fetch: customFetch }) + }, +) + +/** + * Server function using middleware for custom fetch. + * + * Expected behavior: + * - When called without options: Uses customFetchMiddleware's fetch + * → Request should have 'x-custom-fetch-middleware: true' + * - When called with { fetch: directFetch }: Direct fetch overrides middleware + * → Request should NOT have 'x-custom-fetch-middleware' + */ +const getHeadersWithMiddleware = createServerFn() + .middleware([customFetchMiddleware]) + .handler(() => { + return Object.fromEntries(getRequestHeaders().entries()) + }) + +/** + * First middleware in a chain - sets 'x-middleware-first: true' header. + * + * This middleware runs BEFORE secondMiddleware in the chain. + * Its fetch should be OVERRIDDEN by secondMiddleware's fetch. + */ +const firstMiddleware = createMiddleware({ type: 'function' }).client( + async ({ next }) => { + const customFetch: CustomFetch = (input, init) => { + const headers = new Headers(init?.headers) + headers.set('x-middleware-first', 'true') + return fetch(input, { ...init, headers }) + } + return next({ fetch: customFetch }) + }, +) + +/** + * Second middleware in a chain - sets 'x-middleware-second: true' header. + * + * This middleware runs AFTER firstMiddleware in the chain. + * Its fetch should WIN and override firstMiddleware's fetch because + * later middlewares take precedence. + */ +const secondMiddleware = createMiddleware({ type: 'function' }).client( + async ({ next }) => { + const customFetch: CustomFetch = (input, init) => { + const headers = new Headers(init?.headers) + headers.set('x-middleware-second', 'true') + return fetch(input, { ...init, headers }) + } + return next({ fetch: customFetch }) + }, +) + +/** + * Server function with chained middleware: [firstMiddleware, secondMiddleware] + * + * Expected behavior: + * - secondMiddleware's fetch WINS (later middleware overrides earlier) + * - Request should have 'x-middleware-second: true' + * - Request should NOT have 'x-middleware-first' (overridden) + * + * Execution order: + * 1. firstMiddleware sets its fetch, calls next({ fetch: firstFetch }) + * 2. secondMiddleware sets its fetch, calls next({ fetch: secondFetch }) + * 3. secondFetch overrides firstFetch because it came later + * 4. Actual request uses secondFetch + */ +const getHeadersWithChainedMiddleware = createServerFn() + .middleware([firstMiddleware, secondMiddleware]) + .handler(() => { + return Object.fromEntries(getRequestHeaders().entries()) + }) + +/** + * Server function with middleware that can be overridden by direct call. + * + * Expected behavior: + * - When called with { fetch: directFetch }: Direct fetch WINS + * → Request should have 'x-direct-override: true' + * → Request should NOT have 'x-custom-fetch-middleware' (overridden) + * + * This tests the highest priority rule: direct call-site fetch always wins + * over any middleware-provided fetch. + */ +const getHeadersWithOverridableMiddleware = createServerFn() + .middleware([customFetchMiddleware]) + .handler(() => { + return Object.fromEntries(getRequestHeaders().entries()) + }) + +function CustomFetchComponent() { + const [directResult, setDirectResult] = React.useState | null>(null) + const [middlewareResult, setMiddlewareResult] = React.useState | null>(null) + const [chainedResult, setChainedResult] = React.useState | null>(null) + const [overrideResult, setOverrideResult] = React.useState | null>(null) + const [noCustomFetchResult, setNoCustomFetchResult] = React.useState | null>(null) + + /** + * Test 1: Direct Custom Fetch + * + * Passes a custom fetch directly at the call site to a server function + * that has NO middleware. + * + * Expected: 'x-custom-fetch-direct: true' header should be present + * Precedence: Direct fetch is the only fetch configured → it wins + */ + const handleDirectFetch = async () => { + const customFetch: CustomFetch = (input, init) => { + const headers = new Headers(init?.headers) + headers.set('x-custom-fetch-direct', 'true') + return fetch(input, { ...init, headers }) + } + const result = await getHeaders({ fetch: customFetch }) + setDirectResult(result) + } + + /** + * Test 2: Middleware Custom Fetch + * + * Calls a server function that has middleware providing custom fetch. + * No direct fetch is passed at the call site. + * + * Expected: 'x-custom-fetch-middleware: true' header should be present + * Precedence: Middleware fetch is the only fetch configured → it wins + */ + const handleMiddlewareFetch = async () => { + const result = await getHeadersWithMiddleware() + setMiddlewareResult(result) + } + + /** + * Test 3: Chained Middleware - Later Overrides Earlier + * + * Calls a server function with TWO middlewares, each providing custom fetch: + * - firstMiddleware sets 'x-middleware-first: true' + * - secondMiddleware sets 'x-middleware-second: true' + * + * Expected: + * - 'x-middleware-second: true' SHOULD be present (later middleware wins) + * - 'x-middleware-first' should NOT be present (overridden by later middleware) + * + * Precedence: [firstMiddleware, secondMiddleware] → secondMiddleware wins + */ + const handleChainedMiddlewareFetch = async () => { + const result = await getHeadersWithChainedMiddleware() + setChainedResult(result) + } + + /** + * Test 4: Direct Fetch Overrides Middleware Fetch + * + * Calls a server function that has middleware providing custom fetch, + * BUT also passes a direct fetch at the call site. + * + * Expected: + * - 'x-direct-override: true' SHOULD be present (direct call wins) + * - 'x-custom-fetch-middleware' should NOT be present (overridden by direct) + * + * Precedence: Direct call > Middleware → Direct wins + * + * This is the most important override test: it verifies that users can + * always override middleware behavior at the call site when needed. + */ + const handleOverrideFetch = async () => { + const customFetch: CustomFetch = (input, init) => { + const headers = new Headers(init?.headers) + headers.set('x-direct-override', 'true') + return fetch(input, { ...init, headers }) + } + const result = await getHeadersWithOverridableMiddleware({ + fetch: customFetch, + }) + setOverrideResult(result) + } + + /** + * Test 5: No Custom Fetch (Default Behavior) + * + * Calls a server function with NO middleware and NO direct fetch. + * This tests the fallback to the default global fetch. + * + * Expected: + * - Neither 'x-custom-fetch-direct' nor 'x-custom-fetch-middleware' should be present + * - Request should succeed using the default fetch + * + * Precedence: No custom fetch anywhere → Default global fetch is used + */ + const handleNoCustomFetch = async () => { + const result = await getHeaders() + setNoCustomFetchResult(result) + } + + return ( +
+

Custom Fetch Implementation Test

+

+ Tests custom fetch override precedence: Direct call > Later + middleware > Earlier middleware > Default fetch +

+ +
+

Test 1: Direct Custom Fetch

+

+ Expected: x-custom-fetch-direct header present +

+ +
+          {directResult ? JSON.stringify(directResult, null, 2) : 'null'}
+        
+
+ +
+

Test 2: Middleware Custom Fetch

+

+ Expected: x-custom-fetch-middleware header present +

+ +
+          {middlewareResult
+            ? JSON.stringify(middlewareResult, null, 2)
+            : 'null'}
+        
+
+ +
+

Test 3: Chained Middleware (Later Wins)

+

+ Expected: x-middleware-second present, x-middleware-first NOT present +

+ +
+          {chainedResult ? JSON.stringify(chainedResult, null, 2) : 'null'}
+        
+
+ +
+

Test 4: Direct Overrides Middleware

+

+ Expected: x-direct-override present, x-custom-fetch-middleware NOT + present +

+ +
+          {overrideResult ? JSON.stringify(overrideResult, null, 2) : 'null'}
+        
+
+ +
+

Test 5: No Custom Fetch (Default)

+

+ Expected: No custom headers, uses default fetch +

+ +
+          {noCustomFetchResult
+            ? JSON.stringify(noCustomFetchResult, null, 2)
+            : 'null'}
+        
+
+
+ ) +} diff --git a/e2e/react-start/server-functions/src/routes/index.tsx b/e2e/react-start/server-functions/src/routes/index.tsx index 8d24cda2bb..0342c0d988 100644 --- a/e2e/react-start/server-functions/src/routes/index.tsx +++ b/e2e/react-start/server-functions/src/routes/index.tsx @@ -104,6 +104,11 @@ function Home() { Server Functions Middleware Unhandled Exception E2E tests +
  • + + Server function with custom fetch implementation + +
  • ) diff --git a/e2e/react-start/server-functions/tests/server-functions.spec.ts b/e2e/react-start/server-functions/tests/server-functions.spec.ts index 920cf602f3..16678202df 100644 --- a/e2e/react-start/server-functions/tests/server-functions.spec.ts +++ b/e2e/react-start/server-functions/tests/server-functions.spec.ts @@ -757,3 +757,94 @@ test.describe('unhandled exception in middleware (issue #5266)', () => { await expect(page.getByTestId('route-success')).not.toBeVisible() }) }) + +test('server function with custom fetch implementation passed directly', async ({ + page, +}) => { + await page.goto('/custom-fetch') + await page.waitForLoadState('networkidle') + + await page.getByTestId('test-direct-custom-fetch-btn').click() + await page.waitForSelector( + '[data-testid="direct-custom-fetch-result"]:not(:has-text("null"))', + ) + + const result = await page + .getByTestId('direct-custom-fetch-result') + .textContent() + expect(result).toContain('x-custom-fetch-direct') +}) + +test('server function with custom fetch implementation via middleware', async ({ + page, +}) => { + await page.goto('/custom-fetch') + await page.waitForLoadState('networkidle') + + await page.getByTestId('test-middleware-custom-fetch-btn').click() + await page.waitForSelector( + '[data-testid="middleware-custom-fetch-result"]:not(:has-text("null"))', + ) + + const result = await page + .getByTestId('middleware-custom-fetch-result') + .textContent() + expect(result).toContain('x-custom-fetch-middleware') +}) + +test('server function with chained middleware - later middleware overrides earlier', async ({ + page, +}) => { + await page.goto('/custom-fetch') + await page.waitForLoadState('networkidle') + + await page.getByTestId('test-chained-middleware-btn').click() + await page.waitForSelector( + '[data-testid="chained-middleware-result"]:not(:has-text("null"))', + ) + + const result = await page + .getByTestId('chained-middleware-result') + .textContent() + // Second middleware should override first, so only x-middleware-second should be present + expect(result).toContain('x-middleware-second') + expect(result).not.toContain('x-middleware-first') +}) + +test('server function with direct fetch overrides middleware fetch', async ({ + page, +}) => { + await page.goto('/custom-fetch') + await page.waitForLoadState('networkidle') + + await page.getByTestId('test-direct-override-btn').click() + await page.waitForSelector( + '[data-testid="direct-override-result"]:not(:has-text("null"))', + ) + + const result = await page.getByTestId('direct-override-result').textContent() + // Direct fetch should override middleware, so x-direct-override should be present + // and x-custom-fetch-middleware should NOT be present + expect(result).toContain('x-direct-override') + expect(result).not.toContain('x-custom-fetch-middleware') +}) + +test('server function without custom fetch uses default fetch', async ({ + page, +}) => { + await page.goto('/custom-fetch') + await page.waitForLoadState('networkidle') + + await page.getByTestId('test-no-custom-fetch-btn').click() + await page.waitForSelector( + '[data-testid="no-custom-fetch-result"]:not(:has-text("null"))', + ) + + const result = await page.getByTestId('no-custom-fetch-result').textContent() + // No custom headers should be present + expect(result).not.toContain('x-custom-fetch-direct') + expect(result).not.toContain('x-custom-fetch-middleware') + expect(result).not.toContain('x-middleware-first') + expect(result).not.toContain('x-middleware-second') + expect(result).not.toContain('x-direct-override') +}) diff --git a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts index 11b18fe81b..7d543b1df0 100644 --- a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts +++ b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts @@ -56,6 +56,10 @@ export async function serverFnFetcher( const first = _first as FunctionMiddlewareClientFnOptions & { headers?: HeadersInit } + + // Use custom fetch if provided, otherwise fall back to the passed handler (global fetch) + const fetchImpl = first.fetch ?? handler + const type = first.data instanceof FormData ? 'formData' : 'payload' // Arrange the headers @@ -97,7 +101,7 @@ export async function serverFnFetcher( } return await getResponse(async () => - handler(url, { + fetchImpl(url, { method: first.method, headers, signal: first.signal, diff --git a/packages/start-client-core/src/createMiddleware.ts b/packages/start-client-core/src/createMiddleware.ts index f2fb60dc95..d4a5d7cf31 100644 --- a/packages/start-client-core/src/createMiddleware.ts +++ b/packages/start-client-core/src/createMiddleware.ts @@ -1,5 +1,10 @@ import type { StartInstanceOptions } from './createStart' -import type { AnyServerFn, ConstrainValidator, Method } from './createServerFn' +import type { + AnyServerFn, + ConstrainValidator, + CustomFetch, + Method, +} from './createServerFn' import type { AnyContext, Assign, @@ -402,6 +407,7 @@ export type FunctionMiddlewareClientNextFn = < context?: TNewClientContext sendContext?: ValidateSerializableInput headers?: HeadersInit + fetch?: CustomFetch }) => Promise< FunctionClientResultWithContext > @@ -611,6 +617,7 @@ export interface FunctionMiddlewareClientFnOptions< next: FunctionMiddlewareClientNextFn filename: string functionId: string + fetch?: CustomFetch } export type FunctionMiddlewareClientFnResult< @@ -636,6 +643,7 @@ export type FunctionClientResultWithContext< context: Expand> sendContext: Expand> headers: HeadersInit + fetch?: CustomFetch } export interface FunctionMiddlewareAfterClient< diff --git a/packages/start-client-core/src/createServerFn.ts b/packages/start-client-core/src/createServerFn.ts index 4c50739d2c..530853580f 100644 --- a/packages/start-client-core/src/createServerFn.ts +++ b/packages/start-client-core/src/createServerFn.ts @@ -119,6 +119,7 @@ export const createServerFn: CreateServerFn = (options, __opts) => { data: opts?.data as any, headers: opts?.headers, signal: opts?.signal, + fetch: opts?.fetch, context: createNullProtoObject(), }) @@ -248,6 +249,8 @@ export async function executeMiddleware( context: safeObjectMerge(ctx.context, userCtx.context), sendContext: safeObjectMerge(ctx.sendContext, userCtx.sendContext), headers: mergeHeaders(ctx.headers, userCtx.headers), + _callSiteFetch: ctx._callSiteFetch, + fetch: ctx._callSiteFetch ?? userCtx.fetch ?? ctx.fetch, result: userCtx.result !== undefined ? userCtx.result @@ -313,6 +316,7 @@ export async function executeMiddleware( headers: opts.headers || {}, sendContext: opts.sendContext || {}, context: opts.context || createNullProtoObject(), + _callSiteFetch: opts.fetch, }) } @@ -321,6 +325,7 @@ export type CompiledFetcherFnOptions = { data: unknown headers?: HeadersInit signal?: AbortSignal + fetch?: CustomFetch context?: any } @@ -355,9 +360,12 @@ export interface RequiredFetcher ): Promise> } +export type CustomFetch = typeof globalThis.fetch + export type FetcherBaseOptions = { headers?: HeadersInit signal?: AbortSignal + fetch?: CustomFetch } export interface OptionalFetcherDataOptions @@ -686,6 +694,9 @@ export type ServerFnMiddlewareOptions = { sendContext?: any context?: any functionId: string + fetch?: CustomFetch + /** @internal - Preserves the call-site fetch to ensure it has highest priority over middleware */ + _callSiteFetch?: CustomFetch } export type ServerFnMiddlewareResult = ServerFnMiddlewareOptions & { @@ -736,11 +747,12 @@ function serverFnBaseToMiddleware( '~types': undefined!, options: { inputValidator: options.inputValidator, - client: async ({ next, sendContext, ...ctx }) => { + client: async ({ next, sendContext, fetch, ...ctx }) => { const payload = { ...ctx, // switch the sendContext over to context context: sendContext, + fetch, } as any // Execute the extracted function diff --git a/packages/start-client-core/src/index.tsx b/packages/start-client-core/src/index.tsx index 0996e684c9..37b227cbcf 100644 --- a/packages/start-client-core/src/index.tsx +++ b/packages/start-client-core/src/index.tsx @@ -59,6 +59,7 @@ export { export type { CompiledFetcherFnOptions, CompiledFetcherFn, + CustomFetch, Fetcher, RscStream, FetcherBaseOptions, diff --git a/packages/start-client-core/src/tests/createServerFn.test-d.ts b/packages/start-client-core/src/tests/createServerFn.test-d.ts index 32121a468a..d1632b969e 100644 --- a/packages/start-client-core/src/tests/createServerFn.test-d.ts +++ b/packages/start-client-core/src/tests/createServerFn.test-d.ts @@ -9,7 +9,11 @@ import type { ValidateSerializableInput, Validator, } from '@tanstack/router-core' -import type { ConstrainValidator, ServerFnReturnType } from '../createServerFn' +import type { + ConstrainValidator, + CustomFetch, + ServerFnReturnType, +} from '../createServerFn' test('createServerFn without middleware', () => { expectTypeOf(createServerFn()).toHaveProperty('handler') @@ -50,6 +54,7 @@ test('createServerFn with validator function', () => { data: { input: string } headers?: HeadersInit signal?: AbortSignal + fetch?: CustomFetch }>() expectTypeOf>().resolves.toEqualTypeOf() @@ -76,6 +81,7 @@ test('createServerFn with async validator function', () => { data: string headers?: HeadersInit signal?: AbortSignal + fetch?: CustomFetch }>() expectTypeOf>().resolves.toEqualTypeOf() @@ -104,6 +110,7 @@ test('createServerFn with validator with parse method', () => { data: string headers?: HeadersInit signal?: AbortSignal + fetch?: CustomFetch }>() expectTypeOf>().resolves.toEqualTypeOf() @@ -132,6 +139,7 @@ test('createServerFn with async validator with parse method', () => { data: string headers?: HeadersInit signal?: AbortSignal + fetch?: CustomFetch }>() expectTypeOf>().resolves.toEqualTypeOf() @@ -177,6 +185,7 @@ test('createServerFn with standard validator', () => { data: string headers?: HeadersInit signal?: AbortSignal + fetch?: CustomFetch }>() expectTypeOf>().resolves.toEqualTypeOf() @@ -223,6 +232,7 @@ test('createServerFn with async standard validator', () => { data: string headers?: HeadersInit signal?: AbortSignal + fetch?: CustomFetch }>() expectTypeOf>().resolves.toEqualTypeOf() @@ -333,6 +343,7 @@ describe('createServerFn with middleware and validator', () => { } headers?: HeadersInit signal?: AbortSignal + fetch?: CustomFetch }>() expectTypeOf(fn).returns.resolves.toEqualTypeOf<'some-data'>() @@ -436,6 +447,7 @@ test('createServerFn where validator is optional if object is optional', () => { data?: 'c' | undefined headers?: HeadersInit signal?: AbortSignal + fetch?: CustomFetch } | undefined >() @@ -457,6 +469,7 @@ test('createServerFn where data is optional if there is no validator', () => { data?: undefined headers?: HeadersInit signal?: AbortSignal + fetch?: CustomFetch } | undefined >() @@ -599,6 +612,7 @@ test('createServerFn validator infers unknown for default input type', () => { data?: unknown | undefined headers?: HeadersInit signal?: AbortSignal + fetch?: CustomFetch } | undefined >() diff --git a/packages/start-client-core/src/tests/createServerMiddleware.test-d.ts b/packages/start-client-core/src/tests/createServerMiddleware.test-d.ts index 11bfb6598c..ffb47d3122 100644 --- a/packages/start-client-core/src/tests/createServerMiddleware.test-d.ts +++ b/packages/start-client-core/src/tests/createServerMiddleware.test-d.ts @@ -1,7 +1,7 @@ import { expectTypeOf, test } from 'vitest' import { createMiddleware } from '../createMiddleware' import type { RequestServerNextFn } from '../createMiddleware' -import type { ConstrainValidator } from '../createServerFn' +import type { ConstrainValidator, CustomFetch } from '../createServerFn' import type { Register } from '@tanstack/router-core' test('createServeMiddleware removes middleware after middleware,', () => { @@ -155,6 +155,7 @@ test('createMiddleware merges client context and sends to the server', () => { context: { a: boolean } sendContext: undefined headers: HeadersInit + fetch?: CustomFetch }>() return result @@ -172,6 +173,7 @@ test('createMiddleware merges client context and sends to the server', () => { context: { b: string } sendContext: undefined headers: HeadersInit + fetch?: CustomFetch }>() return result @@ -190,6 +192,7 @@ test('createMiddleware merges client context and sends to the server', () => { context: { a: boolean; b: string; c: number } sendContext: undefined headers: HeadersInit + fetch?: CustomFetch }>() return result @@ -213,6 +216,7 @@ test('createMiddleware merges client context and sends to the server', () => { context: { a: boolean; b: string; c: number } sendContext: { a: boolean; b: string; c: number; d: 5 } headers: HeadersInit + fetch?: CustomFetch }>() return result @@ -301,6 +305,7 @@ test('createMiddleware merges server context and client context, sends server co context: { fromClient1: string } sendContext: undefined headers: HeadersInit + fetch?: CustomFetch }>() return result @@ -340,6 +345,7 @@ test('createMiddleware merges server context and client context, sends server co context: { fromClient2: string } sendContext: undefined headers: HeadersInit + fetch?: CustomFetch }>() return result @@ -387,6 +393,7 @@ test('createMiddleware merges server context and client context, sends server co } sendContext: undefined headers: HeadersInit + fetch?: CustomFetch }>() return result @@ -444,6 +451,7 @@ test('createMiddleware merges server context and client context, sends server co } sendContext: { toServer1: 'toServer1' } headers: HeadersInit + fetch?: CustomFetch }>() return result @@ -511,6 +519,7 @@ test('createMiddleware merges server context and client context, sends server co } sendContext: { toServer1: 'toServer1'; toServer2: 'toServer2' } headers: HeadersInit + fetch?: CustomFetch }>() return result