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'}
    +          
    +
    +
    + + + + {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,