diff --git a/e2e/react-start/server-functions/src/functions/fnOnlyCalledByServer.ts b/e2e/react-start/server-functions/src/functions/fnOnlyCalledByServer.ts
new file mode 100644
index 00000000000..463db16d1c4
--- /dev/null
+++ b/e2e/react-start/server-functions/src/functions/fnOnlyCalledByServer.ts
@@ -0,0 +1,6 @@
+import { createServerFn } from '@tanstack/react-start'
+
+// This function is ONLY called from the server, never directly from client code
+export const fnOnlyCalledByServer = createServerFn().handler(() => {
+ return { message: 'hello from server-only function', secret: 42 }
+})
diff --git a/e2e/react-start/server-functions/src/routeTree.gen.ts b/e2e/react-start/server-functions/src/routeTree.gen.ts
index d30a27f37c0..c78224d894a 100644
--- a/e2e/react-start/server-functions/src/routeTree.gen.ts
+++ b/e2e/react-start/server-functions/src/routeTree.gen.ts
@@ -11,6 +11,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as SubmitPostFormdataRouteImport } from './routes/submit-post-formdata'
import { Route as StatusRouteImport } from './routes/status'
+import { Route as ServerOnlyFnRouteImport } from './routes/server-only-fn'
import { Route as SerializeFormDataRouteImport } from './routes/serialize-form-data'
import { Route as ReturnNullRouteImport } from './routes/return-null'
import { Route as RawResponseRouteImport } from './routes/raw-response'
@@ -49,6 +50,11 @@ const StatusRoute = StatusRouteImport.update({
path: '/status',
getParentRoute: () => rootRouteImport,
} as any)
+const ServerOnlyFnRoute = ServerOnlyFnRouteImport.update({
+ id: '/server-only-fn',
+ path: '/server-only-fn',
+ getParentRoute: () => rootRouteImport,
+} as any)
const SerializeFormDataRoute = SerializeFormDataRouteImport.update({
id: '/serialize-form-data',
path: '/serialize-form-data',
@@ -200,6 +206,7 @@ export interface FileRoutesByFullPath {
'/raw-response': typeof RawResponseRoute
'/return-null': typeof ReturnNullRoute
'/serialize-form-data': typeof SerializeFormDataRoute
+ '/server-only-fn': typeof ServerOnlyFnRoute
'/status': typeof StatusRoute
'/submit-post-formdata': typeof SubmitPostFormdataRoute
'/abort-signal/$method': typeof AbortSignalMethodRoute
@@ -231,6 +238,7 @@ export interface FileRoutesByTo {
'/raw-response': typeof RawResponseRoute
'/return-null': typeof ReturnNullRoute
'/serialize-form-data': typeof SerializeFormDataRoute
+ '/server-only-fn': typeof ServerOnlyFnRoute
'/status': typeof StatusRoute
'/submit-post-formdata': typeof SubmitPostFormdataRoute
'/abort-signal/$method': typeof AbortSignalMethodRoute
@@ -263,6 +271,7 @@ export interface FileRoutesById {
'/raw-response': typeof RawResponseRoute
'/return-null': typeof ReturnNullRoute
'/serialize-form-data': typeof SerializeFormDataRoute
+ '/server-only-fn': typeof ServerOnlyFnRoute
'/status': typeof StatusRoute
'/submit-post-formdata': typeof SubmitPostFormdataRoute
'/abort-signal/$method': typeof AbortSignalMethodRoute
@@ -296,6 +305,7 @@ export interface FileRouteTypes {
| '/raw-response'
| '/return-null'
| '/serialize-form-data'
+ | '/server-only-fn'
| '/status'
| '/submit-post-formdata'
| '/abort-signal/$method'
@@ -327,6 +337,7 @@ export interface FileRouteTypes {
| '/raw-response'
| '/return-null'
| '/serialize-form-data'
+ | '/server-only-fn'
| '/status'
| '/submit-post-formdata'
| '/abort-signal/$method'
@@ -358,6 +369,7 @@ export interface FileRouteTypes {
| '/raw-response'
| '/return-null'
| '/serialize-form-data'
+ | '/server-only-fn'
| '/status'
| '/submit-post-formdata'
| '/abort-signal/$method'
@@ -390,6 +402,7 @@ export interface RootRouteChildren {
RawResponseRoute: typeof RawResponseRoute
ReturnNullRoute: typeof ReturnNullRoute
SerializeFormDataRoute: typeof SerializeFormDataRoute
+ ServerOnlyFnRoute: typeof ServerOnlyFnRoute
StatusRoute: typeof StatusRoute
SubmitPostFormdataRoute: typeof SubmitPostFormdataRoute
AbortSignalMethodRoute: typeof AbortSignalMethodRoute
@@ -426,6 +439,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof StatusRouteImport
parentRoute: typeof rootRouteImport
}
+ '/server-only-fn': {
+ id: '/server-only-fn'
+ path: '/server-only-fn'
+ fullPath: '/server-only-fn'
+ preLoaderRoute: typeof ServerOnlyFnRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/serialize-form-data': {
id: '/serialize-form-data'
path: '/serialize-form-data'
@@ -630,6 +650,7 @@ const rootRouteChildren: RootRouteChildren = {
RawResponseRoute: RawResponseRoute,
ReturnNullRoute: ReturnNullRoute,
SerializeFormDataRoute: SerializeFormDataRoute,
+ ServerOnlyFnRoute: ServerOnlyFnRoute,
StatusRoute: StatusRoute,
SubmitPostFormdataRoute: SubmitPostFormdataRoute,
AbortSignalMethodRoute: AbortSignalMethodRoute,
diff --git a/e2e/react-start/server-functions/src/routes/index.tsx b/e2e/react-start/server-functions/src/routes/index.tsx
index 596b855c099..ca57f1540b9 100644
--- a/e2e/react-start/server-functions/src/routes/index.tsx
+++ b/e2e/react-start/server-functions/src/routes/index.tsx
@@ -88,6 +88,12 @@ function Home() {
Server Functions Factory E2E tests
+
+
+ Server Function only called by Server Environment is kept in the
+ server build
+
+
)
diff --git a/e2e/react-start/server-functions/src/routes/server-only-fn.tsx b/e2e/react-start/server-functions/src/routes/server-only-fn.tsx
new file mode 100644
index 00000000000..0d06b17f9dd
--- /dev/null
+++ b/e2e/react-start/server-functions/src/routes/server-only-fn.tsx
@@ -0,0 +1,105 @@
+import { createFileRoute } from '@tanstack/react-router'
+import * as React from 'react'
+import { createServerFn } from '@tanstack/react-start'
+import { fnOnlyCalledByServer } from '~/functions/fnOnlyCalledByServer'
+
+/**
+ * This tests that server functions called only from the server (not from the client)
+ * are still included in the build and work correctly at runtime.
+ *
+ * The `fnOnlyCalledByServer` is only called from `proxyFnThatCallsServerOnlyFn` on the server,
+ * and is never referenced directly from client code.
+ */
+
+// This function IS called from the client, and it calls serverOnlyFn on the server
+const proxyFnThatCallsServerOnlyFn = createServerFn().handler(async () => {
+ // Call the server-only function from within another server function
+ const result = await fnOnlyCalledByServer()
+ return {
+ fromServerOnlyFn: result,
+ wrapper: 'client-callable wrapper',
+ }
+})
+
+const getFnOnlyCalledByServer = createServerFn().handler(async () => {
+ return fnOnlyCalledByServer
+})
+
+export const Route = createFileRoute('/server-only-fn')({
+ component: ServerOnlyFnTest,
+})
+
+function ServerOnlyFnTest() {
+ const [result, setResult] = React.useState<{
+ fromServerOnlyFn: { message: string; secret: number }
+ wrapper: string
+ } | null>(null)
+
+ const [callFromServerResult, setCallFromServerResult] = React.useState<
+ string | null
+ >(null)
+
+ return (
+
+
Server-Only Function Test
+
+ This tests that server functions which are only called from other server
+ functions (and never directly from the client) still work correctly.
+
+
+ Expected result:{' '}
+
+
+ {JSON.stringify({
+ fromServerOnlyFn: {
+ message: 'hello from server-only function',
+ secret: 42,
+ },
+ wrapper: 'client-callable wrapper',
+ })}
+
+
+
+
+ Actual result:{' '}
+
+
+ {result ? JSON.stringify(result) : 'null'}
+
+
+
+
{
+ const res = await proxyFnThatCallsServerOnlyFn()
+ setResult(res)
+ }}
+ >
+ Test Server-Only Function
+
+
+
{
+ try {
+ const fn = await getFnOnlyCalledByServer()
+ await fn()
+ setCallFromServerResult('success')
+ } catch (e) {
+ setCallFromServerResult('error')
+ }
+ }}
+ >
+ Call Server Fn From Client
+
+ {callFromServerResult && (
+
+ {callFromServerResult}
+
+ )}
+
+ )
+}
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 208ef2519bc..a7ad7026f58 100644
--- a/e2e/react-start/server-functions/tests/server-functions.spec.ts
+++ b/e2e/react-start/server-functions/tests/server-functions.spec.ts
@@ -543,3 +543,40 @@ test('redirect in server function called in query during SSR', async ({
await expect(page.getByTestId('redirect-target-ssr')).toBeVisible()
expect(page.url()).toContain('/redirect-test-ssr/target')
})
+
+test('server function called only from server (not client) works correctly', async ({
+ page,
+}) => {
+ await page.goto('/server-only-fn')
+
+ await page.waitForLoadState('networkidle')
+
+ const expected =
+ (await page.getByTestId('expected-server-only-fn-result').textContent()) ||
+ ''
+ expect(expected).not.toBe('')
+
+ await page.getByTestId('test-server-only-fn-btn').click()
+ await page.waitForLoadState('networkidle')
+
+ await expect(page.getByTestId('server-only-fn-result')).toContainText(
+ expected,
+ )
+})
+
+test.use({
+ whitelistErrors: [
+ /Failed to load resource: the server responded with a status of 500/,
+ ],
+})
+test('server function called only from server (not client) cannot be called from the client', async ({
+ page,
+}) => {
+ await page.goto('/server-only-fn')
+ await page.waitForLoadState('networkidle')
+
+ await page.getByTestId('call-server-fn-from-client-btn').click()
+ await expect(
+ page.getByTestId('call-server-fn-from-client-result'),
+ ).toContainText('error')
+})
diff --git a/packages/directive-functions-plugin/src/compilers.ts b/packages/directive-functions-plugin/src/compilers.ts
index de71df6eaa5..a045dae88d9 100644
--- a/packages/directive-functions-plugin/src/compilers.ts
+++ b/packages/directive-functions-plugin/src/compilers.ts
@@ -16,6 +16,11 @@ export interface DirectiveFn {
extractedFilename: string
filename: string
chunkName: string
+ /**
+ * True when this function was discovered by the client build.
+ * Used to restrict HTTP access to only client-referenced functions.
+ */
+ isClientReferenced?: boolean
}
export type SupportedFunctionPath =
@@ -34,7 +39,14 @@ export type ReplacerFn = (opts: {
extractedFilename: string
filename: string
functionId: string
+ functionName: string
isSourceFn: boolean
+ /**
+ * True when this function was already discovered by a previous build (e.g., client).
+ * For SSR callers, this means the function is in the manifest and doesn't need
+ * an importer - the manifest lookup will find it.
+ */
+ isClientReferenced: boolean
}) => string
// const debug = process.env.TSR_VITE_DEBUG === 'true'
@@ -50,6 +62,18 @@ export type CompileDirectivesOpts = ParseAstOptions & {
root: string
isDirectiveSplitParam: boolean
directiveSplitParam: string
+ /**
+ * Previously discovered directive functions from other builds (e.g., client build).
+ * When provided, the compiler will use the canonical extractedFilename from this
+ * registry instead of computing it locally. This ensures SSR callers import from
+ * the same extracted file as the client.
+ */
+ knownDirectiveFns?: Record
+ /**
+ * Whether the current environment is a client environment.
+ * Functions discovered in client environments are always client-referenced.
+ */
+ isClientEnvironment?: boolean
}
export function compileDirectives(opts: CompileDirectivesOpts): {
@@ -217,6 +241,8 @@ export function findDirectives(
filename: string
root: string
isDirectiveSplitParam: boolean
+ knownDirectiveFns?: Record
+ isClientEnvironment?: boolean
},
): Record {
const directiveFnsById: Record = {}
@@ -340,7 +366,9 @@ export function findDirectives(
function compileDirective(
directiveFn: SupportedFunctionPath,
- compileDirectiveOpts: { isDirectiveSplitParam: boolean },
+ compileDirectiveOpts: {
+ isDirectiveSplitParam: boolean
+ },
) {
// Move the function to program level while preserving its position
// in the program body
@@ -410,6 +438,64 @@ export function findDirectives(
functionNameSet.add(functionName)
+ // Use functionId to determine if this is a client-referenced function
+ const [baseFilename, ..._searchParams] = opts.filename.split('?') as [
+ string,
+ ...Array,
+ ]
+ const searchParams = new URLSearchParams(_searchParams.join('&'))
+ searchParams.set(opts.directiveSplitParam, '')
+
+ const localExtractedFilename = `${baseFilename}?${searchParams.toString()}`
+
+ // Relative to have constant functionId regardless of the machine
+ // that we are executing
+ const relativeFilename = path.relative(opts.root, baseFilename)
+ const functionId = opts.generateFunctionId({
+ filename: relativeFilename,
+ functionName,
+ extractedFilename: localExtractedFilename,
+ })
+
+ // Use the canonical extracted filename from knownDirectiveFns if available.
+ // This ensures SSR callers import from the same extracted file as the client,
+ // avoiding duplicate chunks with the same server function.
+ const knownFn = opts.knownDirectiveFns?.[functionId]
+ const extractedFilename =
+ knownFn?.extractedFilename ?? localExtractedFilename
+ // A function is client-referenced if:
+ // 1. It was discovered by the client environment (isClientEnvironment), OR
+ // 2. It was already known from a previous (client) build (knownFn exists)
+ const isClientReferenced = opts.isClientEnvironment || !!knownFn
+
+ // For client-referenced functions in SSR caller files (not extracted files),
+ // we remove the second argument of .handler() because:
+ // 1. The RPC stub uses manifest lookup, which provides the implementation
+ // 2. The full implementation shouldn't be in the caller file
+ // We do this BEFORE hoisting/replacing to ensure we can still access the parent structure
+ if (isClientReferenced && !compileDirectiveOpts.isDirectiveSplitParam) {
+ // Find if this directive function is inside a .handler() call expression
+ // The structure is: .handler(directiveFn, originalImpl)
+ // We want to remove originalImpl (the second argument)
+ let currentPath: babel.NodePath | null =
+ directiveFn as babel.NodePath | null
+ while (currentPath && !currentPath.parentPath?.isProgram()) {
+ const parent: babel.NodePath | null = currentPath.parentPath
+ if (
+ parent?.isCallExpression() &&
+ babel.types.isMemberExpression(parent.node.callee) &&
+ babel.types.isIdentifier(parent.node.callee.property) &&
+ parent.node.callee.property.name === 'handler' &&
+ parent.node.arguments.length === 2
+ ) {
+ // Remove the second argument (the original implementation)
+ parent.node.arguments.pop()
+ break
+ }
+ currentPath = parent
+ }
+ }
+
const topParent =
directiveFn.findParent((p) => !!p.parentPath?.isProgram()) || directiveFn
@@ -470,23 +556,6 @@ export function findDirectives(
`body.${topParentIndex}.declarations.0.init`,
) as SupportedFunctionPath
- const [baseFilename, ..._searchParams] = opts.filename.split('?') as [
- string,
- ...Array,
- ]
- const searchParams = new URLSearchParams(_searchParams.join('&'))
- searchParams.set(opts.directiveSplitParam, '')
-
- const extractedFilename = `${baseFilename}?${searchParams.toString()}`
-
- // Relative to have constant functionId regardless of the machine
- // that we are executing
- const relativeFilename = path.relative(opts.root, baseFilename)
- const functionId = opts.generateFunctionId({
- filename: relativeFilename,
- functionName,
- extractedFilename,
- })
// If a replacer is provided, replace the function with the replacer
if (opts.replacer) {
const replacer = opts.replacer({
@@ -494,7 +563,9 @@ export function findDirectives(
extractedFilename,
filename: opts.filename,
functionId,
+ functionName,
isSourceFn: !!opts.directiveSplitParam,
+ isClientReferenced,
})
const replacement = babel.template.expression(replacer, {
@@ -518,6 +589,7 @@ export function findDirectives(
extractedFilename,
filename: opts.filename,
chunkName: fileNameToChunkName(opts.root, extractedFilename),
+ isClientReferenced,
}
}
}
diff --git a/packages/directive-functions-plugin/src/index.ts b/packages/directive-functions-plugin/src/index.ts
index d34a7e7196f..62405cc1b97 100644
--- a/packages/directive-functions-plugin/src/index.ts
+++ b/packages/directive-functions-plugin/src/index.ts
@@ -35,10 +35,21 @@ const createDirectiveRx = (directive: string) =>
export type DirectiveFunctionsVitePluginEnvOptions = {
directive: string
- callers: Array
+ callers: Array<
+ DirectiveFunctionsViteEnvOptions & {
+ envName: string
+ envConsumer?: 'client' | 'server'
+ }
+ >
provider: DirectiveFunctionsViteEnvOptions & { envName: string }
onDirectiveFnsById?: (directiveFnsById: Record) => void
generateFunctionId: GenerateFunctionIdFn
+ /**
+ * Returns the currently known directive functions from previous builds.
+ * Used by server callers to look up canonical extracted filenames,
+ * ensuring they import from the same extracted file as the client.
+ */
+ getKnownDirectiveFns?: () => Record
}
function buildDirectiveSplitParam(directive: string) {
@@ -79,7 +90,13 @@ export function TanStackDirectiveFunctionsPluginEnv(
const isDirectiveSplitParam = id.includes(directiveSplitParam)
- let envOptions: DirectiveFunctionsViteEnvOptions & { envName: string }
+ let envOptions: DirectiveFunctionsViteEnvOptions & {
+ envName: string
+ envConsumer?: 'client' | 'server'
+ }
+ // Use provider options ONLY for extracted function files (files with directive split param)
+ // For all other files, use caller options even if we're in the provider environment
+ // This ensures route files reference extracted functions instead of duplicating implementations
if (isDirectiveSplitParam) {
envOptions = opts.provider
if (debug)
@@ -88,15 +105,35 @@ export function TanStackDirectiveFunctionsPluginEnv(
id,
)
} else {
- envOptions = opts.callers.find(
+ // For non-extracted files, use caller options based on current environment
+ const callerOptions = opts.callers.find(
(e) => e.envName === this.environment.name,
- )!
- if (debug)
- console.info(
- `Compiling Directives for caller in environment ${envOptions.envName}: `,
- id,
- )
+ )
+ // If no caller is found for this environment (e.g., separate provider environment only processes extracted files),
+ // fall back to provider options
+ if (callerOptions) {
+ envOptions = callerOptions
+ if (debug)
+ console.info(
+ `Compiling Directives for caller in environment ${envOptions.envName}: `,
+ id,
+ )
+ } else {
+ envOptions = opts.provider
+ if (debug)
+ console.info(
+ `Compiling Directives for provider (fallback) in environment ${opts.provider.envName}: `,
+ id,
+ )
+ }
}
+ // Get known directive functions for looking up canonical extracted filenames
+ // This ensures SSR callers import from the same extracted file as the client
+ const knownDirectiveFns = opts.getKnownDirectiveFns?.()
+
+ // Determine if this is a client environment
+ const isClientEnvironment = envOptions.envConsumer === 'client'
+
const { compiledResult, directiveFnsById } = compileDirectives({
directive: opts.directive,
getRuntimeCode: envOptions.getRuntimeCode,
@@ -107,6 +144,8 @@ export function TanStackDirectiveFunctionsPluginEnv(
filename: id,
directiveSplitParam,
isDirectiveSplitParam,
+ knownDirectiveFns,
+ isClientEnvironment,
})
// when we process a file with a directive split param, we have already encountered the directives in that file
// (otherwise we wouldn't have gotten here)
diff --git a/packages/server-functions-plugin/src/index.ts b/packages/server-functions-plugin/src/index.ts
index 0d81ec67f87..38c612ebbec 100644
--- a/packages/server-functions-plugin/src/index.ts
+++ b/packages/server-functions-plugin/src/index.ts
@@ -23,6 +23,11 @@ export type TanStackServerFnPluginOpts = {
callers: Array<
ServerFnPluginEnvOpts & {
envConsumer: 'client' | 'server'
+ /**
+ * Custom getServerFnById implementation for server callers.
+ * Required for server callers that need to load modules from a different
+ * environment.
+ */
getServerFnById?: string
}
>
@@ -153,6 +158,11 @@ export function TanStackServerFnPlugin(
.map((c) => [c.envName, c]),
)
+ // SSR is the provider when the provider environment is also a server caller environment
+ // In this case, server-only-referenced functions won't be in the manifest (they're handled via direct imports)
+ // When SSR is NOT the provider, server-only-referenced functions ARE in the manifest and need isClientReferenced check
+ const ssrIsProvider = serverCallerEnvironments.has(opts.provider.envName)
+
return [
// The client plugin is used to compile the client directives
// and save them so we can create a manifest
@@ -162,6 +172,9 @@ export function TanStackServerFnPlugin(
generateFunctionId,
provider: opts.provider,
callers: opts.callers,
+ // Provide access to known directive functions so SSR callers can use
+ // canonical extracted filenames from the client build
+ getKnownDirectiveFns: () => directiveFnsById,
}),
{
name: 'tanstack-start-server-fn-vite-plugin-validate-serverfn-id',
@@ -231,21 +244,67 @@ export function TanStackServerFnPlugin(
return mod
}
- const mod = `
- const manifest = {${Object.entries(directiveFnsById)
- .map(
- ([id, fn]: any) =>
- `'${id}': {
+ // When SSR is the provider, server-only-referenced functions aren't in the manifest,
+ // so no isClientReferenced check is needed.
+ // When SSR is NOT the provider (custom provider env), server-only-referenced
+ // functions ARE in the manifest and need the isClientReferenced check to
+ // block direct client HTTP requests to server-only-referenced functions.
+ const includeClientReferencedCheck = !ssrIsProvider
+ return generateManifestModule(
+ directiveFnsById,
+ includeClientReferencedCheck,
+ )
+ },
+ },
+ },
+ ]
+}
+
+/**
+ * Generates the manifest module code for server functions.
+ * @param directiveFnsById - Map of function IDs to their directive function info
+ * @param includeClientReferencedCheck - Whether to include isClientReferenced flag and runtime check.
+ * This is needed when SSR is NOT the provider, so server-only-referenced functions in the manifest
+ * can be blocked from client HTTP requests.
+ */
+function generateManifestModule(
+ directiveFnsById: Record,
+ includeClientReferencedCheck: boolean,
+): string {
+ const manifestEntries = Object.entries(directiveFnsById)
+ .map(([id, fn]) => {
+ const baseEntry = `'${id}': {
functionName: '${fn.functionName}',
- importer: () => import(${JSON.stringify(fn.extractedFilename)})
- }`,
- )
- .join(',')}}
- export async function getServerFnById(id) {
+ importer: () => import(${JSON.stringify(fn.extractedFilename)})${
+ includeClientReferencedCheck
+ ? `,
+ isClientReferenced: ${fn.isClientReferenced ?? true}`
+ : ''
+ }
+ }`
+ return baseEntry
+ })
+ .join(',')
+
+ const getServerFnByIdParams = includeClientReferencedCheck ? 'id, opts' : 'id'
+ const clientReferencedCheck = includeClientReferencedCheck
+ ? `
+ // If called from client, only allow client-referenced functions
+ if (opts?.fromClient && !serverFnInfo.isClientReferenced) {
+ throw new Error('Server function not accessible from client: ' + id)
+ }
+`
+ : ''
+
+ return `
+ const manifest = {${manifestEntries}}
+
+ export async function getServerFnById(${getServerFnByIdParams}) {
const serverFnInfo = manifest[id]
if (!serverFnInfo) {
throw new Error('Server function info not found for ' + id)
}
+${clientReferencedCheck}
const fnModule = await serverFnInfo.importer()
if (!fnModule) {
@@ -266,12 +325,6 @@ export function TanStackServerFnPlugin(
return action
}
`
-
- return mod
- },
- },
- },
- ]
}
function resolveViteId(id: string) {
diff --git a/packages/start-plugin-core/src/create-server-fn-plugin/compiler.ts b/packages/start-plugin-core/src/create-server-fn-plugin/compiler.ts
index 1fb95ee0782..12496bbdc89 100644
--- a/packages/start-plugin-core/src/create-server-fn-plugin/compiler.ts
+++ b/packages/start-plugin-core/src/create-server-fn-plugin/compiler.ts
@@ -201,7 +201,15 @@ export class ServerFnCompiler {
return this.moduleCache.delete(id)
}
- public async compile({ code, id }: { code: string; id: string }) {
+ public async compile({
+ code,
+ id,
+ isProviderFile,
+ }: {
+ code: string
+ id: string
+ isProviderFile: boolean
+ }) {
if (!this.initialized) {
await this.init(id)
}
@@ -257,6 +265,7 @@ export class ServerFnCompiler {
env: this.options.env,
code,
directive: this.options.directive,
+ isProviderFile,
})
} else {
handleCreateMiddleware(p.nodePath, { env: this.options.env })
diff --git a/packages/start-plugin-core/src/create-server-fn-plugin/handleCreateServerFn.ts b/packages/start-plugin-core/src/create-server-fn-plugin/handleCreateServerFn.ts
index 140a4fbdcec..2ccbf450d8a 100644
--- a/packages/start-plugin-core/src/create-server-fn-plugin/handleCreateServerFn.ts
+++ b/packages/start-plugin-core/src/create-server-fn-plugin/handleCreateServerFn.ts
@@ -11,6 +11,11 @@ export function handleCreateServerFn(
env: 'client' | 'server'
code: string
directive: string
+ /**
+ * Whether this file is a provider file (extracted server function file).
+ * Only provider files should have the handler implementation as a second argument.
+ */
+ isProviderFile: boolean
},
) {
// Traverse the member expression and find the call expressions for
@@ -151,7 +156,11 @@ export function handleCreateServerFn(
),
)
- if (opts.env === 'server') {
+ // Add the serverFn as a second argument on the server side,
+ // but ONLY for provider files (extracted server function files).
+ // Caller files must NOT have the second argument because the implementation is already available in the extracted chunk
+ // and including it would duplicate code
+ if (opts.env === 'server' && opts.isProviderFile) {
callExpressionPaths.handler.node.arguments.push(handlerFn)
}
}
diff --git a/packages/start-plugin-core/src/create-server-fn-plugin/plugin.ts b/packages/start-plugin-core/src/create-server-fn-plugin/plugin.ts
index 7afd4f9432f..eebb000487b 100644
--- a/packages/start-plugin-core/src/create-server-fn-plugin/plugin.ts
+++ b/packages/start-plugin-core/src/create-server-fn-plugin/plugin.ts
@@ -39,12 +39,18 @@ const getLookupConfigurationsForEnv = (
}
}
const SERVER_FN_LOOKUP = 'server-fn-module-lookup'
+
+function buildDirectiveSplitParam(directive: string) {
+ return `tsr-directive-${directive.replace(/[^a-zA-Z0-9]/g, '-')}`
+}
+
export function createServerFnPlugin(opts: {
framework: CompileStartFrameworkOptions
directive: string
environments: Array<{ name: string; type: 'client' | 'server' }>
}): PluginOption {
const compilers: Record = {}
+ const directiveSplitParam = buildDirectiveSplitParam(opts.directive)
function perEnvServerFnPlugin(environment: {
name: string
@@ -120,8 +126,10 @@ export function createServerFnPlugin(opts: {
compilers[this.environment.name] = compiler
}
+ const isProviderFile = id.includes(directiveSplitParam)
+
id = cleanId(id)
- const result = await compiler.compile({ id, code })
+ const result = await compiler.compile({ id, code, isProviderFile })
return result
},
},
diff --git a/packages/start-plugin-core/src/plugin.ts b/packages/start-plugin-core/src/plugin.ts
index 310d7309cf4..ee77f77f3c4 100644
--- a/packages/start-plugin-core/src/plugin.ts
+++ b/packages/start-plugin-core/src/plugin.ts
@@ -50,11 +50,13 @@ export interface ResolvedStartConfig {
routerFilePath: string
srcDirectory: string
viteAppBase: string
+ serverFnProviderEnv: string
}
export type GetConfigFn = () => {
startConfig: TanStackStartOutputConfig
resolvedStartConfig: ResolvedStartConfig
+ corePluginOpts: TanStackStartVitePluginCoreOptions
}
function isFullUrl(str: string): boolean {
@@ -70,12 +72,19 @@ export function TanStackStartVitePluginCore(
corePluginOpts: TanStackStartVitePluginCoreOptions,
startPluginOpts: TanStackStartInputConfig,
): Array {
+ // Determine the provider environment for server functions
+ // If providerEnv is set, use that; otherwise default to SSR as the provider
+ const serverFnProviderEnv =
+ corePluginOpts.serverFn?.providerEnv || VITE_ENVIRONMENT_NAMES.server
+ const ssrIsProvider = serverFnProviderEnv === VITE_ENVIRONMENT_NAMES.server
+
const resolvedStartConfig: ResolvedStartConfig = {
root: '',
startFilePath: undefined,
routerFilePath: '',
srcDirectory: '',
viteAppBase: '',
+ serverFnProviderEnv,
}
const directive = corePluginOpts.serverFn?.directive ?? 'use server'
@@ -92,7 +101,7 @@ export function TanStackStartVitePluginCore(
resolvedStartConfig.root,
)
}
- return { startConfig, resolvedStartConfig }
+ return { startConfig, resolvedStartConfig, corePluginOpts }
}
const capturedBundle: Partial<
@@ -347,6 +356,22 @@ export function TanStackStartVitePluginCore(
// Build the SSR bundle
await builder.build(server)
}
+
+ // If a custom provider environment is configured (not SSR),
+ // build it last so the manifest includes functions from all environments
+ if (!ssrIsProvider) {
+ const providerEnv = builder.environments[serverFnProviderEnv]
+ if (!providerEnv) {
+ throw new Error(
+ `Provider environment "${serverFnProviderEnv}" not found`,
+ )
+ }
+ if (!providerEnv.isBuilt) {
+ // Build the provider environment last
+ // This ensures all server functions are discovered from client/ssr builds
+ await builder.build(providerEnv)
+ }
+ }
},
},
}
@@ -383,20 +408,24 @@ export function TanStackStartVitePluginCore(
envName: VITE_ENVIRONMENT_NAMES.client,
},
{
- envConsumer: 'server',
+ envConsumer: 'server' as const,
getRuntimeCode: () =>
`import { createSsrRpc } from '@tanstack/${corePluginOpts.framework}-start/ssr-rpc'`,
envName: VITE_ENVIRONMENT_NAMES.server,
- replacer: (d) => `createSsrRpc('${d.functionId}')`,
- getServerFnById: corePluginOpts.serverFn?.ssr?.getServerFnById,
+ replacer: (d: any) =>
+ // When the function is client-referenced, it's in the manifest - use manifest lookup
+ // When SSR is NOT the provider, always use manifest lookup (no import() for different env)
+ // Otherwise, use the importer for functions only referenced on the server when SSR is the provider
+ d.isClientReferenced || !ssrIsProvider
+ ? `createSsrRpc('${d.functionId}')`
+ : `createSsrRpc('${d.functionId}', () => import(${JSON.stringify(d.extractedFilename)}).then(m => m['${d.functionName}']))`,
},
],
provider: {
getRuntimeCode: () =>
`import { createServerRpc } from '@tanstack/${corePluginOpts.framework}-start/server-rpc'`,
replacer: (d) => `createServerRpc('${d.functionId}', ${d.fn})`,
- envName:
- corePluginOpts.serverFn?.providerEnv || VITE_ENVIRONMENT_NAMES.server,
+ envName: serverFnProviderEnv,
},
}),
loadEnvPlugin(),
diff --git a/packages/start-plugin-core/src/start-manifest-plugin/plugin.ts b/packages/start-plugin-core/src/start-manifest-plugin/plugin.ts
index 059116839a5..50fc50ad47e 100644
--- a/packages/start-plugin-core/src/start-manifest-plugin/plugin.ts
+++ b/packages/start-plugin-core/src/start-manifest-plugin/plugin.ts
@@ -81,8 +81,10 @@ export function startManifestPlugin(opts: {
handler(id) {
const { resolvedStartConfig } = opts.getConfig()
if (id === resolvedModuleId) {
- if (this.environment.config.consumer !== 'server') {
- // this will ultimately fail the build if the plugin is used outside the server environment
+ if (
+ this.environment.name !== resolvedStartConfig.serverFnProviderEnv
+ ) {
+ // this will ultimately fail the build if the plugin is used outside the provider environment
// TODO: do we need special handling for `serve`?
return `export default {}`
}
diff --git a/packages/start-plugin-core/tests/createMiddleware-create-server-fn-plugin/createMiddleware.test.ts b/packages/start-plugin-core/tests/createMiddleware-create-server-fn-plugin/createMiddleware.test.ts
index 088ff312817..06d141c5a2a 100644
--- a/packages/start-plugin-core/tests/createMiddleware-create-server-fn-plugin/createMiddleware.test.ts
+++ b/packages/start-plugin-core/tests/createMiddleware-create-server-fn-plugin/createMiddleware.test.ts
@@ -33,7 +33,11 @@ async function compile(opts: {
},
directive: 'use server',
})
- const result = await compiler.compile({ code: opts.code, id: opts.id })
+ const result = await compiler.compile({
+ code: opts.code,
+ id: opts.id,
+ isProviderFile: false,
+ })
return result
}
diff --git a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts
index fca2dd0242b..a8404619554 100644
--- a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts
+++ b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts
@@ -11,6 +11,7 @@ async function compile(opts: {
env: 'client' | 'server'
code: string
id: string
+ isProviderFile: boolean
}) {
const compiler = new ServerFnCompiler({
...opts,
@@ -29,7 +30,11 @@ async function compile(opts: {
},
directive: 'use server',
})
- const result = await compiler.compile({ code: opts.code, id: opts.id })
+ const result = await compiler.compile({
+ code: opts.code,
+ id: opts.id,
+ isProviderFile: opts.isProviderFile,
+ })
return result
}
@@ -45,7 +50,12 @@ describe('createServerFn compiles correctly', async () => {
test.each(['client', 'server'] as const)(
`should compile for ${filename} %s`,
async (env) => {
- const result = await compile({ env, code, id: filename })
+ const result = await compile({
+ env,
+ code,
+ id: filename,
+ isProviderFile: env === 'server',
+ })
await expect(result!.code).toMatchFileSnapshot(
`./snapshots/${env}/${filename}`,
@@ -66,12 +76,25 @@ describe('createServerFn compiles correctly', async () => {
id: 'test.ts',
code,
env: 'client',
+ isProviderFile: false,
})
- const compiledResultServer = await compile({
+ // Server caller (route file - no directive split param)
+ // Should NOT have the second argument since implementation comes from extracted chunk
+ const compiledResultServerCaller = await compile({
id: 'test.ts',
code,
env: 'server',
+ isProviderFile: false,
+ })
+
+ // Server provider (extracted file - has directive split param)
+ // Should HAVE the second argument since this is the implementation file
+ const compiledResultServerProvider = await compile({
+ id: 'test.ts?tsr-directive-use-server',
+ code,
+ env: 'server',
+ isProviderFile: true,
})
expect(compiledResultClient!.code).toMatchInlineSnapshot(`
@@ -83,7 +106,18 @@ describe('createServerFn compiles correctly', async () => {
});"
`)
- expect(compiledResultServer!.code).toMatchInlineSnapshot(`
+ // Server caller: no second argument (implementation from extracted chunk)
+ expect(compiledResultServerCaller!.code).toMatchInlineSnapshot(`
+ "import { createServerFn } from '@tanstack/react-start';
+ const myServerFn = createServerFn().handler((opts, signal) => {
+ "use server";
+
+ return myServerFn.__executeServer(opts, signal);
+ });"
+ `)
+
+ // Server provider: has second argument (this is the implementation file)
+ expect(compiledResultServerProvider!.code).toMatchInlineSnapshot(`
"import { createServerFn } from '@tanstack/react-start';
const myFunc = () => {
return 'hello from the server';
@@ -113,6 +147,7 @@ describe('createServerFn compiles correctly', async () => {
id: 'test.ts',
code,
env: 'client',
+ isProviderFile: false,
})
expect(compiledResult!.code).toMatchInlineSnapshot(`
@@ -129,14 +164,37 @@ describe('createServerFn compiles correctly', async () => {
});"
`)
- // Server
- const compiledResultServer = await compile({
+ // Server caller (route file) - no second argument
+ const compiledResultServerCaller = await compile({
id: 'test.ts',
code,
env: 'server',
+ isProviderFile: false,
+ })
+
+ expect(compiledResultServerCaller!.code).toMatchInlineSnapshot(`
+ "import { createServerFn } from '@tanstack/react-start';
+ export const exportedFn = createServerFn().handler((opts, signal) => {
+ "use server";
+
+ return exportedFn.__executeServer(opts, signal);
+ });
+ const nonExportedFn = createServerFn().handler((opts, signal) => {
+ "use server";
+
+ return nonExportedFn.__executeServer(opts, signal);
+ });"
+ `)
+
+ // Server provider (extracted file) - has second argument
+ const compiledResultServerProvider = await compile({
+ id: 'test.ts?tsr-directive-use-server',
+ code,
+ env: 'server',
+ isProviderFile: true,
})
- expect(compiledResultServer!.code).toMatchInlineSnapshot(`
+ expect(compiledResultServerProvider!.code).toMatchInlineSnapshot(`
"import { createServerFn } from '@tanstack/react-start';
const exportedVar = 'exported';
export const exportedFn = createServerFn().handler((opts, signal) => {
diff --git a/packages/start-server-core/src/createSsrRpc.ts b/packages/start-server-core/src/createSsrRpc.ts
index e1695b50b52..6a61cd3f0bd 100644
--- a/packages/start-server-core/src/createSsrRpc.ts
+++ b/packages/start-server-core/src/createSsrRpc.ts
@@ -1,10 +1,17 @@
import { TSS_SERVER_FUNCTION } from '@tanstack/start-client-core'
import { getServerFnById } from './getServerFnById'
+import type { ServerFn } from '#tanstack-start-server-fn-manifest'
-export const createSsrRpc = (functionId: string) => {
+export type SsrRpcImporter = () => Promise
+
+export const createSsrRpc = (functionId: string, importer?: SsrRpcImporter) => {
const url = process.env.TSS_SERVER_FN_BASE + functionId
const fn = async (...args: Array): Promise => {
- const serverFn = await getServerFnById(functionId)
+ // If an importer is provided, use it directly (server-to-server call within the SSR environment)
+ // Otherwise, fall back to manifest lookup (client-to-server call, server functions that are only referenced on the server or if the provider environment is not SSR)
+ const serverFn = importer
+ ? await importer()
+ : await getServerFnById(functionId)
return serverFn(...args)
}
diff --git a/packages/start-server-core/src/request-response.ts b/packages/start-server-core/src/request-response.ts
index 0c5f8efffd7..be1d90b929f 100644
--- a/packages/start-server-core/src/request-response.ts
+++ b/packages/start-server-core/src/request-response.ts
@@ -42,7 +42,20 @@ import type { RequestHandler } from './request-handler'
interface StartEvent {
h3Event: H3Event
}
-const eventStorage = new AsyncLocalStorage()
+
+// Use a global symbol to ensure the same AsyncLocalStorage instance is shared
+// across different bundles that may each bundle this module.
+const GLOBAL_EVENT_STORAGE_KEY = Symbol.for('tanstack-start:event-storage')
+
+const globalObj = globalThis as typeof globalThis & {
+ [GLOBAL_EVENT_STORAGE_KEY]?: AsyncLocalStorage
+}
+
+if (!globalObj[GLOBAL_EVENT_STORAGE_KEY]) {
+ globalObj[GLOBAL_EVENT_STORAGE_KEY] = new AsyncLocalStorage()
+}
+
+const eventStorage = globalObj[GLOBAL_EVENT_STORAGE_KEY]
export type { ResponseHeaderName, RequestHeaderName }
diff --git a/packages/start-server-core/src/serializer/ServerFunctionSerializationAdapter.ts b/packages/start-server-core/src/serializer/ServerFunctionSerializationAdapter.ts
index 80accd81ea0..6a1f701aab8 100644
--- a/packages/start-server-core/src/serializer/ServerFunctionSerializationAdapter.ts
+++ b/packages/start-server-core/src/serializer/ServerFunctionSerializationAdapter.ts
@@ -15,7 +15,10 @@ export const ServerFunctionSerializationAdapter = createSerializationAdapter({
toSerializable: ({ functionId }) => ({ functionId }),
fromSerializable: ({ functionId }) => {
const fn = async (opts: any, signal: any): Promise => {
- const serverFn = await getServerFnById(functionId)
+ // When a function ID is received through serialization (e.g., as a parameter
+ // to another server function), it originates from the client and must be
+ // validated the same way as direct HTTP calls to server functions.
+ const serverFn = await getServerFnById(functionId, { fromClient: true })
const result = await serverFn(opts ?? {}, signal)
return result.result
}
diff --git a/packages/start-server-core/src/server-functions-handler.ts b/packages/start-server-core/src/server-functions-handler.ts
index 69e0dc19bc4..300c56a0bde 100644
--- a/packages/start-server-core/src/server-functions-handler.ts
+++ b/packages/start-server-core/src/server-functions-handler.ts
@@ -47,7 +47,7 @@ export const handleServerAction = async ({
throw new Error('Invalid server action param for serverFnId: ' + serverFnId)
}
- const action = await getServerFnById(serverFnId)
+ const action = await getServerFnById(serverFnId, { fromClient: true })
// Known FormData 'Content-Type' header values
const formDataContentTypes = [
diff --git a/packages/start-server-core/src/tanstack-start.d.ts b/packages/start-server-core/src/tanstack-start.d.ts
index b760a9d2c97..78c5c6538d7 100644
--- a/packages/start-server-core/src/tanstack-start.d.ts
+++ b/packages/start-server-core/src/tanstack-start.d.ts
@@ -12,7 +12,10 @@ declare module 'tanstack-start-route-tree:v' {
declare module '#tanstack-start-server-fn-manifest' {
type ServerFn = (...args: Array) => Promise
- export function getServerFnById(id: string): Promise
+ export function getServerFnById(
+ id: string,
+ opts?: { fromClient?: boolean },
+ ): Promise
}
declare module 'tanstack-start-injected-head-scripts:v' {
diff --git a/packages/start-storage-context/src/async-local-storage.ts b/packages/start-storage-context/src/async-local-storage.ts
index ec02f0a47be..d952ca6bdda 100644
--- a/packages/start-storage-context/src/async-local-storage.ts
+++ b/packages/start-storage-context/src/async-local-storage.ts
@@ -10,7 +10,19 @@ export interface StartStorageContext {
contextAfterGlobalMiddlewares: any
}
-const startStorage = new AsyncLocalStorage()
+// Use a global symbol to ensure the same AsyncLocalStorage instance is shared
+// across different bundles that may each bundle this module.
+const GLOBAL_STORAGE_KEY = Symbol.for('tanstack-start:start-storage-context')
+
+const globalObj = globalThis as typeof globalThis & {
+ [GLOBAL_STORAGE_KEY]?: AsyncLocalStorage
+}
+
+if (!globalObj[GLOBAL_STORAGE_KEY]) {
+ globalObj[GLOBAL_STORAGE_KEY] = new AsyncLocalStorage()
+}
+
+const startStorage = globalObj[GLOBAL_STORAGE_KEY]
export async function runWithStartContext(
context: StartStorageContext,