diff --git a/docs/start/framework/react/server-functions.md b/docs/start/framework/react/server-functions.md index 6e0a0e7386d..82c649636da 100644 --- a/docs/start/framework/react/server-functions.md +++ b/docs/start/framework/react/server-functions.md @@ -224,6 +224,52 @@ Cache server function results at build time for static generation. See [Static S Handle request cancellation with `AbortSignal` for long-running operations. +### Function ID generation + +Server functions are addressed by a generated, stable function ID under the hood. These IDs are embedded into the client/SSR builds and used by the server to locate and import the correct module at runtime. + +Defaults: + +- In development, IDs are URL-safe strings derived from `${filename}--${functionName}` to aid debugging. +- In production, IDs are SHA256 hashes of the same seed to keep bundles compact and avoid leaking file paths. +- If two server functions end up with the same ID (including when using a custom generator), the system de-duplicates by appending an incrementing suffix like `_1`, `_2`, etc. +- IDs are stable for a given file/function tuple for the lifetime of the process (hot updates keep the same mapping). + +Customization: + +You can customize function ID generation by providing a `generateFunctionId` function when configuring the TanStack Start Vite plugin: + +Example: + +```ts +// vite.config.ts +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' + +export default defineConfig({ + plugins: [ + tanstackStart({ + serverFns: { + generateFunctionId: ({ filename, functionName }) => { + // Return a custom ID string. If you return undefined, the default is used. + // For example, always hash (even in dev): + // return createHash('sha256').update(`${filename}--${functionName}`).digest('hex') + return undefined + }, + }, + }), + react(), + ], +}) +``` + +Tips: + +- Prefer deterministic inputs (filename + functionName) so IDs remain stable between builds. +- If you don’t want file paths in dev IDs, return a hash in all environments. +- Ensure the returned ID is **URL-safe**. + --- > **Note**: Server functions use a compilation process that extracts server code from client bundles while maintaining seamless calling patterns. On the client, calls become `fetch` requests to the server. diff --git a/e2e/react-start/custom-basepath/tests/navigation.spec.ts b/e2e/react-start/custom-basepath/tests/navigation.spec.ts index 765609c47de..c96cc384c88 100644 --- a/e2e/react-start/custom-basepath/tests/navigation.spec.ts +++ b/e2e/react-start/custom-basepath/tests/navigation.spec.ts @@ -41,9 +41,7 @@ test('Server function URLs correctly include app basepath', async ({ const form = page.locator('form') const actionUrl = await form.getAttribute('action') - expect(actionUrl).toBe( - '/custom/basepath/_serverFn/src_routes_logout_tsx--logoutFn_createServerFn_handler', - ) + expect(actionUrl).toMatch(/^\/custom\/basepath\/_serverFn\//) }) test('client-side redirect', async ({ page, baseURL }) => { 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 990487289fd..72488ad0dc1 100644 --- a/e2e/react-start/server-functions/tests/server-functions.spec.ts +++ b/e2e/react-start/server-functions/tests/server-functions.spec.ts @@ -6,6 +6,20 @@ import type { Page } from '@playwright/test' const PORT = await getTestServerPort(packageJson.name) +test('Server function URLs correctly include constant ids', async ({ + page, +}) => { + for (const currentPage of ['/submit-post-formdata', '/formdata-redirect']) { + await page.goto(currentPage) + await page.waitForLoadState('networkidle') + + const form = page.locator('form') + const actionUrl = await form.getAttribute('action') + + expect(actionUrl).toMatch(/^\/_serverFn\/constant_id/) + } +}) + test('invoking a server function with custom response status code', async ({ page, }) => { diff --git a/e2e/react-start/server-functions/vite.config.ts b/e2e/react-start/server-functions/vite.config.ts index dc57f144e4f..97ff657630e 100644 --- a/e2e/react-start/server-functions/vite.config.ts +++ b/e2e/react-start/server-functions/vite.config.ts @@ -3,12 +3,25 @@ import tsConfigPaths from 'vite-tsconfig-paths' import { tanstackStart } from '@tanstack/react-start/plugin/vite' import viteReact from '@vitejs/plugin-react' +const FUNCTIONS_WITH_CONSTANT_ID = [ + 'src/routes/submit-post-formdata.tsx/greetUser_createServerFn_handler', + 'src/routes/formdata-redirect/index.tsx/greetUser_createServerFn_handler', +] + export default defineConfig({ plugins: [ tsConfigPaths({ projects: ['./tsconfig.json'], }), - tanstackStart(), + tanstackStart({ + serverFns: { + generateFunctionId: (opts) => { + const id = `${opts.filename}/${opts.functionName}` + if (FUNCTIONS_WITH_CONSTANT_ID.includes(id)) return 'constant_id' + else return undefined + }, + }, + }), viteReact(), ], }) diff --git a/packages/directive-functions-plugin/package.json b/packages/directive-functions-plugin/package.json index a1a44e45bcd..427118a18a9 100644 --- a/packages/directive-functions-plugin/package.json +++ b/packages/directive-functions-plugin/package.json @@ -71,6 +71,7 @@ "@babel/types": "^7.27.7", "@tanstack/router-utils": "workspace:*", "babel-dead-code-elimination": "^1.0.10", + "pathe": "^2.0.3", "tiny-invariant": "^1.3.3" }, "devDependencies": { diff --git a/packages/directive-functions-plugin/src/compilers.ts b/packages/directive-functions-plugin/src/compilers.ts index 0bb79020272..99537674a16 100644 --- a/packages/directive-functions-plugin/src/compilers.ts +++ b/packages/directive-functions-plugin/src/compilers.ts @@ -5,6 +5,7 @@ import { deadCodeElimination, findReferencedIdentifiers, } from 'babel-dead-code-elimination' +import path from 'pathe' import { generateFromAst, parseAst } from '@tanstack/router-utils' import type { GeneratorResult, ParseAstOptions } from '@tanstack/router-utils' @@ -22,6 +23,11 @@ export type SupportedFunctionPath = | babel.NodePath | babel.NodePath +export type GenerateFunctionIdFn = (opts: { + filename: string + functionName: string +}) => string + export type ReplacerFn = (opts: { fn: string extractedFilename: string @@ -38,6 +44,7 @@ export type CompileDirectivesOpts = ParseAstOptions & { getRuntimeCode?: (opts: { directiveFnsById: Record }) => string + generateFunctionId: GenerateFunctionIdFn replacer: ReplacerFn // devSplitImporter: string filename: string @@ -198,14 +205,6 @@ function findNearestVariableName( return nameParts.length > 0 ? nameParts.join('_') : 'anonymous' } -function makeFileLocationUrlSafe(location: string): string { - return location - .replace(/[^a-zA-Z0-9-_]/g, '_') // Replace unsafe chars with underscore - .replace(/_{2,}/g, '_') // Collapse multiple underscores - .replace(/^_|_$/g, '') // Trim leading/trailing underscores - .replace(/_--/g, '--') // Clean up the joiner -} - function makeIdentifierSafe(identifier: string): string { return identifier .replace(/[^a-zA-Z0-9_$]/g, '_') // Replace unsafe chars with underscore @@ -221,6 +220,7 @@ export function findDirectives( directive: string directiveLabel: string replacer?: ReplacerFn + generateFunctionId: GenerateFunctionIdFn directiveSplitParam: string filename: string root: string @@ -460,16 +460,22 @@ export function findDirectives( `body.${topParentIndex}.declarations.0.init`, ) as SupportedFunctionPath - const [baseFilename, ..._searchParams] = opts.filename.split('?') + const [baseFilename, ..._searchParams] = opts.filename.split('?') as [ + string, + ...Array, + ] const searchParams = new URLSearchParams(_searchParams.join('&')) searchParams.set(opts.directiveSplitParam, '') const extractedFilename = `${baseFilename}?${searchParams.toString()}` - const functionId = makeFileLocationUrlSafe( - `${baseFilename}--${functionName}`.replace(opts.root, ''), - ) - + // 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: functionName, + }) // If a replacer is provided, replace the function with the replacer if (opts.replacer) { const replacer = opts.replacer({ diff --git a/packages/directive-functions-plugin/src/index.ts b/packages/directive-functions-plugin/src/index.ts index 4e05962e948..ff86b2f6baa 100644 --- a/packages/directive-functions-plugin/src/index.ts +++ b/packages/directive-functions-plugin/src/index.ts @@ -2,7 +2,11 @@ import { fileURLToPath, pathToFileURL } from 'node:url' import { logDiff } from '@tanstack/router-utils' import { compileDirectives } from './compilers' -import type { CompileDirectivesOpts, DirectiveFn } from './compilers' +import type { + CompileDirectivesOpts, + DirectiveFn, + GenerateFunctionIdFn, +} from './compilers' import type { Plugin } from 'vite' const debug = @@ -13,6 +17,7 @@ export type { DirectiveFn, CompileDirectivesOpts, ReplacerFn, + GenerateFunctionIdFn, } from './compilers' export type DirectiveFunctionsViteEnvOptions = Pick< @@ -28,6 +33,7 @@ export type DirectiveFunctionsViteOptions = Pick< > & DirectiveFunctionsViteEnvOptions & { onDirectiveFnsById?: (directiveFnsById: Record) => void + generateFunctionId: GenerateFunctionIdFn } const createDirectiveRx = (directive: string) => @@ -61,6 +67,7 @@ export type DirectiveFunctionsVitePluginEnvOptions = Pick< server: DirectiveFunctionsViteEnvOptions & { envName?: string } } onDirectiveFnsById?: (directiveFnsById: Record) => void + generateFunctionId: GenerateFunctionIdFn } export function TanStackDirectiveFunctionsPluginEnv( @@ -131,6 +138,7 @@ function transformCode({ directive, directiveLabel, getRuntimeCode, + generateFunctionId, replacer, onDirectiveFnsById, root, @@ -155,6 +163,7 @@ function transformCode({ directive, directiveLabel, getRuntimeCode, + generateFunctionId, replacer, code, root, diff --git a/packages/directive-functions-plugin/tests/compiler.test.ts b/packages/directive-functions-plugin/tests/compiler.test.ts index a943f7f0803..6381e124267 100644 --- a/packages/directive-functions-plugin/tests/compiler.test.ts +++ b/packages/directive-functions-plugin/tests/compiler.test.ts @@ -3,12 +3,27 @@ import { describe, expect, test } from 'vitest' import { compileDirectives } from '../src/compilers' import type { CompileDirectivesOpts } from '../src/compilers' +function makeFunctionIdUrlSafe(location: string): string { + return location + .replace(/[^a-zA-Z0-9-_]/g, '_') // Replace unsafe chars with underscore + .replace(/_{2,}/g, '_') // Collapse multiple underscores + .replace(/^_|_$/g, '') // Trim leading/trailing underscores + .replace(/_--/g, '--') // Clean up the joiner +} + +const generateFunctionId: CompileDirectivesOpts['generateFunctionId'] = ( + opts, +) => { + return makeFunctionIdUrlSafe(`${opts.filename}--${opts.functionName}`) +} + const clientConfig: Omit = { directive: 'use server', directiveLabel: 'Server function', root: './test-files', - filename: 'test.ts', + filename: './test-files/test.ts', getRuntimeCode: () => 'import { createClientRpc } from "my-rpc-lib-client"', + generateFunctionId, replacer: (opts) => `createClientRpc(${JSON.stringify(opts.functionId)})`, } @@ -16,8 +31,9 @@ const ssrConfig: Omit = { directive: 'use server', directiveLabel: 'Server function', root: './test-files', - filename: 'test.ts', + filename: './test-files/test.ts', getRuntimeCode: () => 'import { createSsrRpc } from "my-rpc-lib-server"', + generateFunctionId, replacer: (opts) => `createSsrRpc(${JSON.stringify(opts.functionId)})`, } @@ -25,8 +41,9 @@ const serverConfig: Omit = { directive: 'use server', directiveLabel: 'Server function', root: './test-files', - filename: 'test.ts', + filename: './test-files/test.ts', getRuntimeCode: () => 'import { createServerRpc } from "my-rpc-lib-server"', + generateFunctionId, replacer: (opts) => // On the server build, we need different code for the split function // vs any other server functions the split function may reference diff --git a/packages/server-functions-plugin/src/index.ts b/packages/server-functions-plugin/src/index.ts index 710fe0b2ba2..4edee08bd03 100644 --- a/packages/server-functions-plugin/src/index.ts +++ b/packages/server-functions-plugin/src/index.ts @@ -1,3 +1,4 @@ +import crypto from 'node:crypto' import { TanStackDirectiveFunctionsPlugin, TanStackDirectiveFunctionsPluginEnv, @@ -5,11 +6,16 @@ import { import type { DevEnvironment, Plugin, ViteDevServer } from 'vite' import type { DirectiveFn, + GenerateFunctionIdFn, ReplacerFn, } from '@tanstack/directive-functions-plugin' export type CreateRpcFn = (functionId: string, splitImportFn?: string) => any +export type GenerateFunctionIdFnOptional = ( + opts: Parameters[0], +) => string | undefined + export type ServerFnPluginOpts = { /** * The virtual import ID that will be used to import the server function manifest. @@ -17,6 +23,7 @@ export type ServerFnPluginOpts = { * and its modules. */ manifestVirtualImportId: string + generateFunctionId?: GenerateFunctionIdFnOptional client: ServerFnPluginEnvOpts ssr: ServerFnPluginEnvOpts server: ServerFnPluginEnvOpts @@ -25,6 +32,7 @@ export type ServerFnPluginOpts = { export type ServerFnPluginEnvOpts = { getRuntimeCode: () => string replacer: ReplacerFn + envName?: string } const debug = @@ -54,6 +62,13 @@ export function createTanStackServerFnPlugin(opts: ServerFnPluginOpts): { } }, }) + const generateFunctionId = buildGenerateFunctionId( + (generateFunctionIdOpts, next) => + next( + Boolean(viteDevServer), + opts.generateFunctionId?.(generateFunctionIdOpts), + ), + ) const directive = 'use server' const directiveLabel = 'Server Function' @@ -67,6 +82,7 @@ export function createTanStackServerFnPlugin(opts: ServerFnPluginOpts): { directive, directiveLabel, getRuntimeCode: opts.client.getRuntimeCode, + generateFunctionId, replacer: opts.client.replacer, onDirectiveFnsById, }), @@ -78,6 +94,7 @@ export function createTanStackServerFnPlugin(opts: ServerFnPluginOpts): { directive, directiveLabel, getRuntimeCode: opts.ssr.getRuntimeCode, + generateFunctionId, replacer: opts.ssr.replacer, onDirectiveFnsById, }), @@ -127,6 +144,7 @@ export function createTanStackServerFnPlugin(opts: ServerFnPluginOpts): { directive, directiveLabel, getRuntimeCode: opts.server.getRuntimeCode, + generateFunctionId, replacer: opts.server.replacer, onDirectiveFnsById, }), @@ -134,24 +152,7 @@ export function createTanStackServerFnPlugin(opts: ServerFnPluginOpts): { } } -export interface TanStackServerFnPluginEnvOpts { - /** - * The virtual import ID that will be used to import the server function manifest. - * This virtual import ID will be used in the server build to import the manifest - * and its modules. - */ - manifestVirtualImportId: string - client: { - envName?: string - getRuntimeCode: () => string - replacer: ReplacerFn - } - server: { - envName?: string - getRuntimeCode: () => string - replacer: ReplacerFn - } -} +export type TanStackServerFnPluginEnvOpts = Omit export function TanStackServerFnPluginEnv( _opts: TanStackServerFnPluginEnvOpts, @@ -189,6 +190,14 @@ export function TanStackServerFnPluginEnv( }, }) + const generateFunctionId = buildGenerateFunctionId( + (generateFunctionIdOpts, next) => + next( + Boolean(serverDevEnv), + opts.generateFunctionId?.(generateFunctionIdOpts), + ), + ) + const directive = 'use server' const directiveLabel = 'Server Function' @@ -199,6 +208,7 @@ export function TanStackServerFnPluginEnv( directive, directiveLabel, onDirectiveFnsById, + generateFunctionId, environments: { client: { envLabel: 'Client', @@ -262,6 +272,55 @@ function resolveViteId(id: string) { return `\0${id}` } +function makeFunctionIdUrlSafe(location: string): string { + return location + .replace(/[^a-zA-Z0-9-_]/g, '_') // Replace unsafe chars with underscore + .replace(/_{2,}/g, '_') // Collapse multiple underscores + .replace(/^_|_$/g, '') // Trim leading/trailing underscores + .replace(/_--/g, '--') // Clean up the joiner +} + +function buildGenerateFunctionId( + delegate: ( + opts: Parameters[0], + next: (dev: boolean, value?: string) => string, + ) => string, +): GenerateFunctionIdFn { + const entryIdToFunctionId = new Map() + const functionIds = new Set() + return (opts) => { + const entryId = `${opts.filename}--${opts.functionName}` + let functionId = entryIdToFunctionId.get(entryId) + if (functionId === undefined) { + functionId = delegate(opts, (dev, updatedFunctionId) => { + // If no value provided, then return the url-safe currentId on development + // and SHA256 using the currentId as seed on production + if (updatedFunctionId === undefined) { + if (dev) updatedFunctionId = makeFunctionIdUrlSafe(entryId) + else + updatedFunctionId = crypto + .createHash('sha256') + .update(entryId) + .digest('hex') + } + return updatedFunctionId + }) + // Deduplicate in case the generated id conflicts with an existing id + if (functionIds.has(functionId)) { + let deduplicatedId + let iteration = 0 + do { + deduplicatedId = `${functionId}_${++iteration}` + } while (functionIds.has(deduplicatedId)) + functionId = deduplicatedId + } + entryIdToFunctionId.set(entryId, functionId) + functionIds.add(functionId) + } + return functionId + } +} + function buildOnDirectiveFnsByIdCallback(opts: { invalidateModule: (resolvedId: string) => void directiveFnsById: Record diff --git a/packages/start-plugin-core/src/plugin.ts b/packages/start-plugin-core/src/plugin.ts index 070c82731b1..304389349d8 100644 --- a/packages/start-plugin-core/src/plugin.ts +++ b/packages/start-plugin-core/src/plugin.ts @@ -334,6 +334,7 @@ export function TanStackStartVitePluginCore( // This is the ID that will be available to look up and import // our server function manifest and resolve its module manifestVirtualImportId: VIRTUAL_MODULES.serverFnManifest, + generateFunctionId: startPluginOpts?.serverFns?.generateFunctionId, client: { getRuntimeCode: () => `import { createClientRpc } from '@tanstack/${corePluginOpts.framework}-start/client-rpc'`, diff --git a/packages/start-plugin-core/src/schema.ts b/packages/start-plugin-core/src/schema.ts index 2c0b3b6d9ae..39d471447da 100644 --- a/packages/start-plugin-core/src/schema.ts +++ b/packages/start-plugin-core/src/schema.ts @@ -159,6 +159,16 @@ const tanstackStartOptionsSchema = z serverFns: z .object({ base: z.string().optional().default('/_serverFn'), + generateFunctionId: z + .function() + .args( + z.object({ + filename: z.string(), + functionName: z.string(), + }), + ) + .returns(z.string().optional()) + .optional(), }) .optional() .default({}), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 303cefff7b6..4231a759934 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6643,6 +6643,9 @@ importers: babel-dead-code-elimination: specifier: ^1.0.10 version: 1.0.10 + pathe: + specifier: ^2.0.3 + version: 2.0.3 tiny-invariant: specifier: ^1.3.3 version: 1.3.3