diff --git a/docs/start/framework/react/guide/server-functions.md b/docs/start/framework/react/guide/server-functions.md index 903e5d7b86..02f6825fc0 100644 --- a/docs/start/framework/react/guide/server-functions.md +++ b/docs/start/framework/react/guide/server-functions.md @@ -157,6 +157,71 @@ The build process replaces server function implementations with RPC stubs in cli > const { getUser } = await import('~/utils/users.functions') > ``` +## Manual Server Function IDs + +By default, TanStack Start generates a function ID for each server function. You can optionally provide a manual ID when you want a stable, explicit identifier under your control. + +Use manual IDs when you need predictable identifiers across refactors, clearer diagnostics, or explicit naming conventions. + +```tsx +import { createServerFn } from '@tanstack/react-start' + +export const getUser = createServerFn({ method: 'GET', id: 'get-user' }) + .validator((data: { userId: string }) => data) + .handler(async ({ data }) => { + return findUserById(data.userId) + }) +``` + +### Rules for Manual IDs + +Manual IDs must follow these rules: + +1. Allowed characters are letters, numbers, underscore, and dash. +2. The ID must match this pattern: `[a-zA-Z0-9_-]+`. +3. The ID must be statically analyzable: + - String literal is allowed. + - Constant string binding is allowed. + - Computed ID keys are not supported. + +```tsx +// ✅ literal string ID +createServerFn({ id: 'create-user' }).handler(async () => { + return { ok: true } +}) + +// ✅ constant string binding ID +const userLookupId = 'lookup-user' +createServerFn({ id: userLookupId }).handler(async () => { + return { ok: true } +}) + +// ❌ computed ID key is not supported +const key = 'id' +createServerFn({ [key]: 'create-user' }).handler(async () => { + return { ok: true } +}) +``` + +### Automatic and Manual IDs + +Automatic IDs are the default and work for most apps. Provide a manual `id` only when you want a fixed, recognizable identifier (for example, stable values in logs or analytics). + +Switching between automatic and manual IDs does not change how you call a server function. The call signature, arguments, and return value stay the same, but the server function lookup ID and request URL change to use the manual ID. + +Manual IDs are exact reservations. If a generated or custom `generateFunctionId` value collides with a manual ID, the generated value is suffixed instead. If two manual IDs collide, compilation fails. + +### Security Caveats + +Manual IDs improve stability and readability, but they are not a security feature. + +1. Do not treat function IDs as secrets. +2. Do not rely on manual IDs for authorization. +3. Authorize every server function in middleware or inside the handler. +4. Keep same-origin and CSRF protections enabled for server function endpoints. + +Changing from automatic IDs to manual IDs does not change your security boundary. Your security boundary remains the server-side authorization and request protection checks. + ## Parameters & Validation Server functions accept a single `data` parameter. Since they cross the network boundary, validation ensures type safety and runtime correctness. diff --git a/packages/start-client-core/src/createServerFn.ts b/packages/start-client-core/src/createServerFn.ts index bfa48039cf..a74424ff35 100644 --- a/packages/start-client-core/src/createServerFn.ts +++ b/packages/start-client-core/src/createServerFn.ts @@ -37,6 +37,7 @@ export interface ServerFnOptions< > { method?: TMethod strict?: TStrict + id?: string } export type ServerFnStrictInput = @@ -502,6 +503,7 @@ export type ServerFnBaseOptions< > = { method: TMethod strict?: TStrict + id?: string middleware?: Constrain< TMiddlewares, ReadonlyArray diff --git a/packages/start-client-core/src/tests/createServerFn.test-d.ts b/packages/start-client-core/src/tests/createServerFn.test-d.ts index bfe96dfc5e..b8c7559668 100644 --- a/packages/start-client-core/src/tests/createServerFn.test-d.ts +++ b/packages/start-client-core/src/tests/createServerFn.test-d.ts @@ -24,6 +24,8 @@ test('createServerFn without middleware', () => { // TODO remove upon stable expectTypeOf(createServerFn()).toHaveProperty('inputValidator') + expectTypeOf(createServerFn({ id: 'get-user' })).toHaveProperty('handler') + createServerFn({ method: 'GET' }).handler((options) => { expectTypeOf(options).toEqualTypeOf<{ context: undefined diff --git a/packages/start-plugin-core/src/start-compiler/compiler.ts b/packages/start-plugin-core/src/start-compiler/compiler.ts index d6b36fe6e8..86f02a8231 100644 --- a/packages/start-plugin-core/src/start-compiler/compiler.ts +++ b/packages/start-plugin-core/src/start-compiler/compiler.ts @@ -1,5 +1,6 @@ import crypto from 'node:crypto' import * as t from '@babel/types' +import path from 'pathe' import { deadCodeElimination, extractModuleInfoFromAst, @@ -514,6 +515,9 @@ export class StartCompiler { string, Map >() + private reservedManualFunctionIds = new Set() + private reservedManualFunctionIdOwners = new Map() + private reservedManualFunctionIdsByFilename = new Map>() // Fast lookup for direct imports from known libraries (e.g., '@tanstack/react-start') // Maps: libName → (exportName → Kind) // This allows O(1) resolution for the common case without async resolveId calls @@ -612,37 +616,22 @@ export class StartCompiler { extractedFilename: string }): string { if (this.mode === 'dev') { - // In dev, encode the file path and export name for direct lookup. - // Each bundler adapter supplies its own strategy for encoding - // module specifiers that work with its dev server runtime. - const encodeModuleSpecifier = - this.options.devServerFnModuleSpecifierEncoder - if (!encodeModuleSpecifier) { - throw new Error( - 'devServerFnModuleSpecifierEncoder is required in dev mode.', - ) - } - const file = encodeModuleSpecifier({ - extractedFilename: opts.extractedFilename, - root: this.options.root, - }) - - const serverFn = { - file, - export: opts.functionName, - } - return Buffer.from(JSON.stringify(serverFn), 'utf8').toString('base64url') + return this.generateDevFunctionId(opts) } // Production build: use custom generator or hash const entryId = `${opts.filename}--${opts.functionName}` let functionId = this.entryIdToFunctionId.get(entryId) if (functionId === undefined) { - const knownFn = Object.values(this.options.getKnownServerFns()).find( + const knownServerFns = this.options.getKnownServerFns() + const knownFn = Object.values(knownServerFns).find( (serverFn) => serverFn.functionName === opts.functionName && serverFn.extractedFilename === opts.extractedFilename, ) + const knownFunctionIds = new Set( + Object.values(knownServerFns).map((serverFn) => serverFn.functionId), + ) if (knownFn) { functionId = knownFn.functionId @@ -657,13 +646,23 @@ export class StartCompiler { if (!functionId) { functionId = crypto.createHash('sha256').update(entryId).digest('hex') } - // Deduplicate in case the generated id conflicts with an existing id - if (this.functionIds.has(functionId)) { + const isCanonicalKnownMatch = knownFn?.functionId === functionId + // Deduplicate generated/custom IDs so manual reservations stay exact + // without making the older generateFunctionId hook a breaking change. + if ( + this.functionIds.has(functionId) || + this.reservedManualFunctionIds.has(functionId) || + (!isCanonicalKnownMatch && knownFunctionIds.has(functionId)) + ) { let deduplicatedId let iteration = 0 do { deduplicatedId = `${functionId}_${++iteration}` - } while (this.functionIds.has(deduplicatedId)) + } while ( + this.functionIds.has(deduplicatedId) || + this.reservedManualFunctionIds.has(deduplicatedId) || + knownFunctionIds.has(deduplicatedId) + ) functionId = deduplicatedId } this.entryIdToFunctionId.set(entryId, functionId) @@ -672,6 +671,78 @@ export class StartCompiler { return functionId } + private reserveFunctionId(opts: { + filename: string + functionName: string + extractedFilename: string + functionId: string + }) { + const entryId = `${opts.filename}--${opts.functionName}` + const existingOwner = this.reservedManualFunctionIdOwners.get( + opts.functionId, + ) + const knownFn = this.options.getKnownServerFns()[opts.functionId] + + if ( + knownFn && + (knownFn.functionName !== opts.functionName || + knownFn.extractedFilename !== opts.extractedFilename) + ) { + throw new Error(`Duplicate server function id: ${opts.functionId}`) + } + + if (existingOwner && existingOwner !== entryId) { + throw new Error(`Duplicate server function id: ${opts.functionId}`) + } + + if (!existingOwner && this.functionIds.has(opts.functionId)) { + throw new Error(`Duplicate server function id: ${opts.functionId}`) + } + + if (!existingOwner) { + this.reservedManualFunctionIdOwners.set(opts.functionId, entryId) + this.reservedManualFunctionIds.add(opts.functionId) + + let reservedIds = this.reservedManualFunctionIdsByFilename.get( + opts.filename, + ) + if (!reservedIds) { + reservedIds = new Set() + this.reservedManualFunctionIdsByFilename.set(opts.filename, reservedIds) + } + reservedIds.add(opts.functionId) + } + + return this.mode === 'dev' + ? this.generateDevFunctionId(opts) + : opts.functionId + } + + private generateDevFunctionId(opts: { + functionName: string + extractedFilename: string + }): string { + // In dev, encode the file path and export name for direct lookup. + // Each bundler adapter supplies its own strategy for encoding + // module specifiers that work with its dev server runtime. + const encodeModuleSpecifier = this.options.devServerFnModuleSpecifierEncoder + if (!encodeModuleSpecifier) { + throw new Error( + 'devServerFnModuleSpecifierEncoder is required in dev mode.', + ) + } + const file = encodeModuleSpecifier({ + extractedFilename: opts.extractedFilename, + root: this.options.root, + }) + + const serverFn = { + file, + export: opts.functionName, + } + return Buffer.from(JSON.stringify(serverFn), 'utf8').toString('base64url') + } + private get mode(): 'dev' | 'build' { return this.options.mode ?? 'dev' } @@ -871,6 +942,7 @@ export class StartCompiler { for (const moduleId of Array.from(this.moduleCache.keys())) { const normalizedModuleId = cleanId(moduleId) if (normalizedIds.has(normalizedModuleId)) { + this.clearReservedManualFunctionIdsForModule(normalizedModuleId) this.moduleCache.delete(moduleId) deletedModuleIds.add(normalizedModuleId) } @@ -895,6 +967,21 @@ export class StartCompiler { return deletedModuleIds } + private clearReservedManualFunctionIdsForModule(moduleId: string): void { + const relativeFilename = path.relative(this.options.root, moduleId) + const reservedIds = + this.reservedManualFunctionIdsByFilename.get(relativeFilename) + if (!reservedIds) { + return + } + + for (const functionId of reservedIds) { + this.reservedManualFunctionIds.delete(functionId) + this.reservedManualFunctionIdOwners.delete(functionId) + } + this.reservedManualFunctionIdsByFilename.delete(relativeFilename) + } + public async getTransitiveImporters( ids: string | Iterable, ): Promise> { @@ -1368,6 +1455,7 @@ export class StartCompiler { warn: warnFn, generateFunctionId: (opts) => this.generateFunctionId(opts), + reserveFunctionId: (opts) => this.reserveFunctionId(opts), getKnownServerFns: this.options.getKnownServerFns, serverFnProviderModuleDirectives: this.options.serverFnProviderModuleDirectives, diff --git a/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts b/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts index 3d4a8a2542..db0cc937c0 100644 --- a/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts +++ b/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts @@ -13,6 +13,7 @@ import type { import type { CompileStartFrameworkOptions } from '../types' const TSS_SERVERFN_SPLIT_PARAM = 'tss-serverfn-split' +const MANUAL_SERVER_FN_ID_PATTERN = /^[a-zA-Z0-9_-]+$/ const providerHmrAcceptTemplate = babel.template.statements( ` @@ -139,6 +140,140 @@ function getEnvConfig( } } +function extractManualServerFnId( + candidatePath: babel.NodePath, + code: string, +): string | undefined { + const createServerFnCall = getCreateServerFnCallExpression(candidatePath) + if (!createServerFnCall) { + return undefined + } + + const [optionsArg] = createServerFnCall.arguments + if (!optionsArg || !t.isObjectExpression(optionsArg)) { + return undefined + } + + for (const prop of optionsArg.properties) { + if (!t.isObjectProperty(prop)) { + continue + } + + if (prop.computed) { + const keyValue = resolveStaticString(candidatePath, prop.key) + + if (keyValue === 'id') { + throw codeFrameError( + code, + prop.loc!, + 'createServerFn({ [key]: value }) is not supported for manual ids.', + ) + } + + continue + } + + const isIdKey = + (t.isIdentifier(prop.key) && prop.key.name === 'id') || + (t.isStringLiteral(prop.key) && prop.key.value === 'id') + + if (!isIdKey) { + continue + } + + if (!t.isExpression(prop.value) && !t.isPrivateName(prop.value)) { + throw codeFrameError( + code, + prop.loc!, + 'createServerFn({ id }) must use a static string literal or a constant string binding.', + ) + } + + const idValue = resolveStaticString(candidatePath, prop.value) + + if (idValue !== undefined) { + if (!MANUAL_SERVER_FN_ID_PATTERN.test(idValue)) { + throw codeFrameError( + code, + prop.value.loc!, + 'createServerFn({ id }) must use a URL-safe id: [a-zA-Z0-9_-]+', + ) + } + + return idValue + } + + throw codeFrameError( + code, + prop.loc!, + 'createServerFn({ id }) must use a static string literal or a constant string binding.', + ) + } + + return undefined +} + +function getCreateServerFnCallExpression( + candidatePath: babel.NodePath, +): t.CallExpression | undefined { + let currentCall: t.CallExpression = candidatePath.node + let sawMethodChain = false + + // Walk inward through the `createServerFn()...method()...` chain. + while ( + t.isMemberExpression(currentCall.callee) && + t.isCallExpression(currentCall.callee.object) + ) { + const innerCall = currentCall.callee.object + if (t.isIdentifier(innerCall.callee, { name: 'createServerFn' })) { + return innerCall + } + sawMethodChain = true + currentCall = innerCall + } + + return sawMethodChain ? currentCall : undefined +} + +function resolveStaticString( + candidatePath: babel.NodePath, + value: t.Expression | t.PrivateName, +): string | undefined { + if (t.isStringLiteral(value)) { + return value.value + } + + if (t.isTemplateLiteral(value) && value.expressions.length === 0) { + return value.quasis[0]?.value.cooked ?? undefined + } + + if (!t.isIdentifier(value)) { + return undefined + } + + const binding = candidatePath.scope.getBinding(value.name) + const bindingPath = binding?.path + const bindingInit = + bindingPath && bindingPath.isVariableDeclarator() + ? bindingPath.node.init + : undefined + + if (binding?.constant && bindingInit && t.isStringLiteral(bindingInit)) { + return bindingInit.value + } + + if ( + binding?.constant && + bindingInit && + t.isTemplateLiteral(bindingInit) && + bindingInit.expressions.length === 0 + ) { + return bindingInit.quasis[0]?.value.cooked ?? undefined + } + + return undefined +} + /** * Builds the serverFnMeta object literal AST node. * The object contains: { id, name, filename } @@ -271,12 +406,25 @@ export function handleCreateServerFn( } functionNameSet.add(functionName) - // Generate function ID using pre-computed relative filename - const functionId = context.generateFunctionId({ - filename: relativeFilename, - functionName, - extractedFilename, - }) + const manualFunctionId = extractManualServerFnId( + candidatePath, + context.code, + ) + + // Generate function ID using pre-computed relative filename unless the user supplied one. + const functionId = + manualFunctionId !== undefined + ? context.reserveFunctionId({ + filename: relativeFilename, + functionName, + extractedFilename, + functionId: manualFunctionId, + }) + : context.generateFunctionId({ + filename: relativeFilename, + functionName, + extractedFilename, + }) // Check if this function was already discovered by the client build const knownFn = knownFns[functionId] diff --git a/packages/start-plugin-core/src/start-compiler/types.ts b/packages/start-plugin-core/src/start-compiler/types.ts index 1371e7a973..c87ab2aa54 100644 --- a/packages/start-plugin-core/src/start-compiler/types.ts +++ b/packages/start-plugin-core/src/start-compiler/types.ts @@ -9,6 +9,8 @@ import type { StartCompilerTransformContext } from '../types' export interface CompilationContext extends StartCompilerTransformContext { /** Generate a unique function ID */ generateFunctionId: GenerateFunctionIdFn + /** Reserve a user-supplied function ID for the current module. */ + reserveFunctionId: ReserveFunctionIdFn /** Get known server functions from previous builds (e.g., client build) */ getKnownServerFns: () => Record /** Module-level directives to add to extracted server function provider files. */ @@ -95,6 +97,13 @@ export type GenerateFunctionIdFn = (opts: { extractedFilename: string }) => string +export type ReserveFunctionIdFn = (opts: { + filename: string + functionName: string + extractedFilename: string + functionId: string +}) => string + /** * Optional version that allows returning undefined to use default ID generation. */ diff --git a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts index c4009657c4..a553d278d0 100644 --- a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts +++ b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts @@ -2,6 +2,7 @@ import { readFile, readdir } from 'node:fs/promises' import path from 'node:path' import { describe, expect, test, vi } from 'vitest' import { StartCompiler } from '../../src/start-compiler/compiler' +import { createViteDevServerFnModuleSpecifierEncoder } from '../../src/vite/start-compiler-plugin/module-specifier' // Default test options for StartCompiler function getDefaultTestOptions(env: 'client' | 'server') { @@ -118,6 +119,155 @@ describe('createServerFn compiles correctly', async () => { `) }) + test('should use a literal manual id from createServerFn options', async () => { + const code = ` + import { createServerFn } from '@tanstack/react-start' + + export const getUser = createServerFn({ method: 'GET', id: 'get-user' }) + .handler(async () => ({ id: '123' })) + ` + + const compiledResultClient = await compile({ + code, + env: 'client', + isProviderFile: false, + mode: 'build', + }) + + const compiledResultServerProvider = await compile({ + code, + env: 'server', + isProviderFile: true, + mode: 'build', + }) + + expect(compiledResultClient!.code).toContain('createClientRpc("get-user")') + expect(compiledResultServerProvider!.code).toContain('id: "get-user"') + }) + + test.each([ + { + name: 'constant binding', + code: ` + import { createServerFn } from '@tanstack/react-start' + + const manualId = 'get-user' + + export const getUser = createServerFn({ method: 'GET', id: manualId }) + .handler(async () => ({ id: '123' })) + `, + }, + { + name: 'validator chain', + code: ` + import { createServerFn } from '@tanstack/react-start' + + export const getUser = createServerFn({ method: 'GET', id: 'get-user' }) + .validator((input: string) => input) + .handler(async ({ data }) => ({ id: data })) + `, + }, + { + name: 'unrelated spread', + code: ` + import { createServerFn } from '@tanstack/react-start' + + const baseOptions = { method: 'GET' as const } + + export const getUser = createServerFn({ ...baseOptions, id: 'get-user' }) + .handler(async () => ({ id: '123' })) + `, + }, + { + name: 'unrelated computed key', + code: ` + import { createServerFn } from '@tanstack/react-start' + + const key = 'method' + + export const getUser = createServerFn({ [key]: 'GET' as const, id: 'get-user' }) + .handler(async () => ({ id: '123' })) + `, + }, + ])('should use a manual id with $name', async ({ code }) => { + const compiledResultClient = await compile({ + code, + env: 'client', + isProviderFile: false, + mode: 'build', + }) + + expect(compiledResultClient!.code).toContain('createClientRpc("get-user")') + }) + + test.each([ + { + name: 'unrelated spread', + code: ` + import { createServerFn } from '@tanstack/react-start' + + const baseOptions = { method: 'GET' as const } + + export const getUser = createServerFn({ ...baseOptions }) + .handler(async () => ({ id: '123' })) + `, + }, + { + name: 'unrelated computed key', + code: ` + import { createServerFn } from '@tanstack/react-start' + + const key = 'method' + + export const getUser = createServerFn({ [key]: 'GET' as const }) + .handler(async () => ({ id: '123' })) + `, + }, + ])('should ignore $name when no manual id is present', async ({ code }) => { + const compiledResultClient = await compile({ + code, + env: 'client', + isProviderFile: false, + mode: 'build', + }) + + expect(compiledResultClient!.code).toContain('createClientRpc(') + }) + + test.each([ + { + name: 'literal computed id key', + code: ` + import { createServerFn } from '@tanstack/react-start' + + export const getUser = createServerFn({ ['id']: 'get-user' }) + .handler(async () => ({ id: '123' })) + `, + }, + { + name: 'constant computed id key', + code: ` + import { createServerFn } from '@tanstack/react-start' + + const key = \`id\` + + export const getUser = createServerFn({ [key]: 'get-user' }) + .handler(async () => ({ id: '123' })) + `, + }, + ])('should reject $name', async ({ code }) => { + await expect( + compile({ + code, + env: 'client', + isProviderFile: false, + mode: 'build', + }), + ).rejects.toThrow( + 'createServerFn({ [key]: value }) is not supported for manual ids.', + ) + }) + // TODO remove upon stable test('should warn for deprecated inputValidator method', async () => { const warn = vi.fn() @@ -507,6 +657,50 @@ describe('createServerFn compiles correctly', async () => { ) }) + test('should use a manual id from a local named re-export of createServerFn', async () => { + const code = ` + import { createFooServerFn } from './factory' + const myServerFn = createFooServerFn({ id: 'get-user' }).handler(async () => { + return 'hello' + })` + + const resolveIdMock = vi.fn(async (id: string) => id) + + const compiler = new StartCompiler({ + env: 'client', + ...getDefaultTestOptions('client'), + loadModule: async (id) => { + if (id === './factory') { + compiler.ingestModule({ + code: ` + export { createServerFn as createFooServerFn } from '@tanstack/react-start' + `, + id: './factory', + }) + } + }, + lookupKinds: new Set(['ServerFn']), + lookupConfigurations: [ + { + libName: '@tanstack/react-start', + rootExport: 'createServerFn', + kind: 'Root', + }, + ], + getKnownServerFns: () => ({}), + resolveId: resolveIdMock, + mode: 'build', + }) + + const result = await compiler.compile({ + code, + id: '/test/src/test.ts', + }) + + expect(result).not.toBeNull() + expect(result!.code).toContain('createClientRpc("get-user")') + }) + test('should resolve export-star re-export chains of createServerFn', async () => { const code = ` import { createFooServerFn } from './factory' @@ -571,20 +765,285 @@ describe('createServerFn compiles correctly', async () => { ) }) - test('reuses deduped custom IDs across compiler instances', async () => { - const serverFnsById: Record< - string, - { - functionName: string - functionId: string - extractedFilename: string - filename: string - isClientReferenced?: boolean - } - > = {} + test('dedupes generated custom IDs across compiler instances', async () => { + const compiler = new StartCompiler({ + env: 'server', + ...getDefaultTestOptions('server'), + mode: 'build', + loadModule: async () => {}, + lookupKinds: new Set(['ServerFn']), + lookupConfigurations: [ + { + libName: '@tanstack/react-start', + rootExport: 'createServerFn', + kind: 'Root', + }, + ], + resolveId: async (id) => id, + generateFunctionId: () => 'constant_id', + getKnownServerFns: () => ({}), + }) + + await compiler.compile({ + code: ` + import { createServerFn } from '@tanstack/react-start' + export const greetUser = createServerFn().handler(async () => 'first') + `, + id: '/test/src/submit-post-formdata.tsx', + }) + + const secondResult = await compiler.compile({ + code: ` + import { createServerFn } from '@tanstack/react-start' + export const greetUser = createServerFn().handler(async () => 'second') + `, + id: '/test/src/formdata-redirect/index.tsx', + }) + + expect(secondResult!.code).toContain('createSsrRpc("constant_id_1")') + }) + + test('dedupes generated IDs when known server fn IDs collide', async () => { + const knownServerFns = { + knownFn: { + functionId: 'constant_id', + filename: '/test/src/known-fn.tsx', + extractedFilename: '/test/src/known-fn.tsx', + functionName: 'knownFn', + }, + } + + const compiler = new StartCompiler({ + env: 'server', + ...getDefaultTestOptions('server'), + mode: 'build', + loadModule: async () => {}, + lookupKinds: new Set(['ServerFn']), + lookupConfigurations: [ + { + libName: '@tanstack/react-start', + rootExport: 'createServerFn', + kind: 'Root', + }, + ], + resolveId: async (id) => id, + generateFunctionId: () => 'constant_id', + getKnownServerFns: () => knownServerFns, + }) + + const result = await compiler.compile({ + code: ` + import { createServerFn } from '@tanstack/react-start' + export const greetUser = createServerFn().handler(async () => 'next') + `, + id: '/test/src/new-fn.tsx', + }) + + expect(result!.code).toContain('createSsrRpc("constant_id_1")') + }) + + test('reuses canonical known server fn IDs without suffixing', async () => { + const knownServerFns = { + knownFn: { + functionId: 'constant_id', + filename: '/test/src/new-fn.tsx', + extractedFilename: '/test/src/new-fn.tsx?tss-serverfn-split', + functionName: 'greetUser_createServerFn_handler', + }, + } + + const compiler = new StartCompiler({ + env: 'server', + ...getDefaultTestOptions('server'), + mode: 'build', + loadModule: async () => {}, + lookupKinds: new Set(['ServerFn']), + lookupConfigurations: [ + { + libName: '@tanstack/react-start', + rootExport: 'createServerFn', + kind: 'Root', + }, + ], + resolveId: async (id) => id, + generateFunctionId: () => 'constant_id', + getKnownServerFns: () => knownServerFns, + }) + + const result = await compiler.compile({ + code: ` + import { createServerFn } from '@tanstack/react-start' + export const greetUser = createServerFn().handler(async () => 'next') + `, + id: '/test/src/new-fn.tsx', + }) - function createCompiler() { - return new StartCompiler({ + expect(result!.code).toContain('createSsrRpc("constant_id")') + expect(result!.code).not.toContain('createSsrRpc("constant_id_1")') + }) + + test('dedupes generated IDs around reserved manual IDs', async () => { + const compiler = new StartCompiler({ + env: 'server', + ...getDefaultTestOptions('server'), + mode: 'build', + loadModule: async () => {}, + lookupKinds: new Set(['ServerFn']), + lookupConfigurations: [ + { + libName: '@tanstack/react-start', + rootExport: 'createServerFn', + kind: 'Root', + }, + ], + resolveId: async (id) => id, + generateFunctionId: () => 'get-user', + getKnownServerFns: () => ({}), + }) + + const manualResult = await compiler.compile({ + code: ` + import { createServerFn } from '@tanstack/react-start' + export const getUser = createServerFn({ id: 'get-user' }).handler(async () => 'manual') + `, + id: '/test/src/manual.tsx', + }) + + const generatedResult = await compiler.compile({ + code: ` + import { createServerFn } from '@tanstack/react-start' + export const getUser = createServerFn().handler(async () => 'generated') + `, + id: '/test/src/generated.tsx', + }) + + expect(manualResult!.code).toContain('createSsrRpc("get-user")') + expect(generatedResult!.code).toContain('createSsrRpc("get-user_1")') + }) + + test.each([ + { + name: 'in the same file', + compileDuplicate: (compiler: StartCompiler) => + compiler.compile({ + code: ` + import { createServerFn } from '@tanstack/react-start' + export const getUser = createServerFn({ id: 'get-user' }).handler(async () => 'first') + export const getOtherUser = createServerFn({ id: 'get-user' }).handler(async () => 'second') + `, + id: '/test/src/duplicates.tsx', + }), + }, + { + name: 'across files', + compileDuplicate: async (compiler: StartCompiler) => { + await compiler.compile({ + code: ` + import { createServerFn } from '@tanstack/react-start' + export const getUser = createServerFn({ id: 'get-user' }).handler(async () => 'first') + `, + id: '/test/src/get-user.tsx', + }) + + return compiler.compile({ + code: ` + import { createServerFn } from '@tanstack/react-start' + export const getOtherUser = createServerFn({ id: 'get-user' }).handler(async () => 'second') + `, + id: '/test/src/get-other-user.tsx', + }) + }, + }, + ])('rejects duplicate manual IDs $name', async ({ compileDuplicate }) => { + const compiler = new StartCompiler({ + env: 'server', + ...getDefaultTestOptions('server'), + mode: 'build', + loadModule: async () => {}, + lookupKinds: new Set(['ServerFn']), + lookupConfigurations: [ + { + libName: '@tanstack/react-start', + rootExport: 'createServerFn', + kind: 'Root', + }, + ], + resolveId: async (id) => id, + getKnownServerFns: () => ({}), + }) + + await expect(compileDuplicate(compiler)).rejects.toThrow( + 'Duplicate server function id: get-user', + ) + }) + + test('rejects a manual ID that collides with a generated ID', async () => { + const compiler = new StartCompiler({ + env: 'server', + ...getDefaultTestOptions('server'), + mode: 'build', + loadModule: async () => {}, + lookupKinds: new Set(['ServerFn']), + lookupConfigurations: [ + { + libName: '@tanstack/react-start', + rootExport: 'createServerFn', + kind: 'Root', + }, + ], + resolveId: async (id) => id, + generateFunctionId: () => 'get-user', + getKnownServerFns: () => ({}), + }) + + await compiler.compile({ + code: ` + import { createServerFn } from '@tanstack/react-start' + export const getUser = createServerFn().handler(async () => 'generated') + `, + id: '/test/src/generated.tsx', + }) + + await expect( + compiler.compile({ + code: ` + import { createServerFn } from '@tanstack/react-start' + export const getUser = createServerFn({ id: 'get-user' }).handler(async () => 'manual') + `, + id: '/test/src/manual.tsx', + }), + ).rejects.toThrow('Duplicate server function id: get-user') + }) + + test.each([ + { + name: 'reuses matching known metadata', + knownServerFns: { + 'get-user': { + functionId: 'get-user', + filename: '/test/src/get-user.tsx', + extractedFilename: '/test/src/get-user.tsx?tss-serverfn-split', + functionName: 'getUser_createServerFn_handler', + }, + }, + expectedCode: 'createSsrRpc("get-user")', + }, + { + name: 'rejects mismatched known metadata', + knownServerFns: { + 'get-user': { + functionId: 'get-user', + filename: '/test/src/other-user.tsx', + extractedFilename: '/test/src/other-user.tsx?tss-serverfn-split', + functionName: 'getOtherUser_createServerFn_handler', + }, + }, + expectedError: 'Duplicate server function id: get-user', + }, + ])( + '$name for manual ID', + async ({ knownServerFns, expectedCode, expectedError }) => { + const compiler = new StartCompiler({ env: 'server', ...getDefaultTestOptions('server'), mode: 'build', @@ -598,59 +1057,134 @@ describe('createServerFn compiles correctly', async () => { }, ], resolveId: async (id) => id, - generateFunctionId: ({ functionName }) => - functionName === 'greetUser_createServerFn_handler' - ? 'constant_id' - : undefined, - getKnownServerFns: () => serverFnsById, - onServerFnsById: (discovered) => { - Object.assign(serverFnsById, discovered) - }, + getKnownServerFns: () => knownServerFns, }) - } - const firstCompiler = createCompiler() - await firstCompiler.compile({ - code: ` + const compileResult = compiler.compile({ + code: ` import { createServerFn } from '@tanstack/react-start' - export const greetUser = createServerFn().handler(async () => 'first') + export const getUser = createServerFn({ id: 'get-user' }).handler(async () => 'manual') `, - id: '/test/src/submit-post-formdata.tsx', + id: '/test/src/get-user.tsx', + }) + + if (expectedError) { + await expect(compileResult).rejects.toThrow(expectedError) + } else { + const result = await compileResult + expect(result!.code).toContain(expectedCode) + } + }, + ) + + test('releases manual ids after module invalidation', async () => { + const compiler = new StartCompiler({ + env: 'server', + ...getDefaultTestOptions('server'), + mode: 'build', + loadModule: async () => {}, + lookupKinds: new Set(['ServerFn']), + lookupConfigurations: [ + { + libName: '@tanstack/react-start', + rootExport: 'createServerFn', + kind: 'Root', + }, + ], + resolveId: async (id) => id, + getKnownServerFns: () => ({}), }) - await firstCompiler.compile({ - code: ` - import { createServerFn } from '@tanstack/react-start' - export const greetUser = createServerFn().handler(async () => 'second') - `, - id: '/test/src/formdata-redirect/index.tsx', + const source = ` + import { createServerFn } from '@tanstack/react-start' + export const getUser = createServerFn({ id: 'get-user' }).handler(async () => 'first') + ` + + const firstResult = await compiler.compile({ + code: source, + id: '/test/src/submit-post-formdata.tsx', }) + expect(firstResult!.code).toContain('createSsrRpc("get-user")') expect( - Object.values(serverFnsById) - .map((serverFn) => serverFn.functionId) - .sort(), - ).toEqual(['constant_id', 'constant_id_1']) + compiler.invalidateModule('/test/src/submit-post-formdata.tsx'), + ).toBe(true) - const secondCompiler = createCompiler() - const firstResult = await secondCompiler.compile({ - code: ` - import { createServerFn } from '@tanstack/react-start' - export const greetUser = createServerFn().handler(async () => 'first') - `, + const secondResult = await compiler.compile({ + code: source, id: '/test/src/submit-post-formdata.tsx', }) - const secondResult = await secondCompiler.compile({ + expect(secondResult!.code).toContain('createSsrRpc("get-user")') + }) + + test('reuses a manual id across caller and provider compiles in one compiler', async () => { + const compiler = new StartCompiler({ + env: 'server', + ...getDefaultTestOptions('server'), + mode: 'build', + loadModule: async () => {}, + lookupKinds: new Set(['ServerFn']), + lookupConfigurations: [ + { + libName: '@tanstack/react-start', + rootExport: 'createServerFn', + kind: 'Root', + }, + ], + resolveId: async (id) => id, + getKnownServerFns: () => ({}), + }) + + const code = ` + import { createServerFn } from '@tanstack/react-start' + export const getUser = createServerFn({ id: 'get-user' }).handler(async () => 'first') + ` + + const callerResult = await compiler.compile({ + code, + id: '/test/src/example.tsx', + }) + + const providerResult = await compiler.compile({ + code, + id: '/test/src/example.tsx?tss-serverfn-split', + }) + + expect(callerResult!.code).toContain('createSsrRpc("get-user")') + expect(providerResult!.code).toContain('id: "get-user"') + }) + + test('keeps dev manual ids encoded for runtime lookup', async () => { + const compiler = new StartCompiler({ + env: 'client', + ...getDefaultTestOptions('client'), + mode: 'dev', + loadModule: async () => {}, + lookupKinds: new Set(['ServerFn']), + lookupConfigurations: [ + { + libName: '@tanstack/react-start', + rootExport: 'createServerFn', + kind: 'Root', + }, + ], + resolveId: async (id) => id, + getKnownServerFns: () => ({}), + devServerFnModuleSpecifierEncoder: + createViteDevServerFnModuleSpecifierEncoder('/test'), + }) + + const result = await compiler.compile({ code: ` import { createServerFn } from '@tanstack/react-start' - export const greetUser = createServerFn().handler(async () => 'second') + export const getUser = createServerFn({ id: 'get-user' }).handler(async () => 'first') `, - id: '/test/src/formdata-redirect/index.tsx', + id: '/test/src/example.tsx', }) - expect(firstResult!.code).toContain('createSsrRpc("constant_id"') - expect(secondResult!.code).toContain('createSsrRpc("constant_id_1"') + expect(result!.code).toContain('createClientRpc("') + expect(result!.code).not.toContain('createClientRpc("get-user")') }) test('should resolve createServerFn from the same binding as a known root export', async () => {