diff --git a/docs/start/framework/react/guide/server-functions.md b/docs/start/framework/react/guide/server-functions.md index 903e5d7b86..bb724068d8 100644 --- a/docs/start/framework/react/guide/server-functions.md +++ b/docs/start/framework/react/guide/server-functions.md @@ -414,6 +414,55 @@ Server functions can return Server Components - server-rendered React components Handle request cancellation with `AbortSignal` for long-running operations. +### Manual function IDs + +TanStack Start gives every server function an internal ID. By default, that ID is generated from the source file and extracted function name. + +Add `.id()` when a function needs to keep the same identity across file moves or variable renames: + +```tsx +import { createServerFn } from '@tanstack/react-start' + +export const getUser = createServerFn({ method: 'GET' }) + .id('get-user') + .handler(async () => { + return { id: '123' } + }) +``` + +The value must be a static string literal before `.handler()`. It can contain letters, numbers, `_`, and `-`. Manual IDs must be unique. Duplicate manual IDs fail at compile time. If a generated or plugin-generated ID collides with one, the generated ID gets a suffix such as `_1`. + +> [!WARNING] +> IDs are public. They can show up in browser network requests, logs, proxies, and analytics, so keep them short and non-sensitive. Do not include secrets, private file paths, tenant IDs, user IDs, or sensitive business data. + +An ID only locates the function. It does not authenticate the caller, authorize the action, validate input, or provide CSRF protection. Put those checks in middleware or in the handler. + +A per-function `.id()` takes precedence over `serverFns.generateFunctionId` for that server function. + +#### Manual IDs with factories + +Set the ID on the server function produced by the factory, not on the reusable factory itself: + +```tsx +const authedServerFn = createServerFn().middleware([authMiddleware]) + +export const getUser = authedServerFn() + .id('get-user') + .handler(async () => { + return { id: '123' } + }) + +export const updateUser = authedServerFn({ method: 'POST' }) + .id('update-user') + .handler(async () => { + return { ok: true } + }) +``` + +Factory calls do not inherit IDs so each function produced by the factory should declare its own ID. + +Keep the same `.id()` value to preserve function identity across file moves and variable renames. Keep the same `method` to preserve call compatibility. + ### Function ID generation for production build 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. diff --git a/packages/start-client-core/src/createServerFn.ts b/packages/start-client-core/src/createServerFn.ts index bfa48039cf..f78a5acc3c 100644 --- a/packages/start-client-core/src/createServerFn.ts +++ b/packages/start-client-core/src/createServerFn.ts @@ -97,6 +97,13 @@ export const createServerFn: CreateServerFn = (options, __opts) => { const res: ServerFnBuilder = { options: resolvedOptions, + id: (id) => { + const newOptions = { + ...resolvedOptions, + id, + } + return createServerFn(undefined, newOptions) as any + }, middleware: (middleware) => { // multiple calls to `middleware()` merge the middlewares with the previously supplied ones // this is primarily useful for letting users create their own abstractions on top of `createServerFn` @@ -216,8 +223,10 @@ export const createServerFn: CreateServerFn = (options, __opts) => { }, } as ServerFnBuilder const fun = (options?: ServerFnOptions) => { + const inheritedOptions = { ...resolvedOptions } + delete inheritedOptions.id const newOptions = { - ...resolvedOptions, + ...inheritedOptions, ...options, } return createServerFn(undefined, newOptions) @@ -500,6 +509,7 @@ export type ServerFnBaseOptions< TInputValidator = unknown, TStrict extends ServerFnStrict = true, > = { + id?: string method: TMethod strict?: TStrict middleware?: Constrain< @@ -586,6 +596,135 @@ export type AppendMiddlewares = : TMiddlewares : TNewMiddlewares +export interface ServerFnId< + TRegister, + TMethod extends Method, + TMiddlewares, + TInputValidator, + TStrict extends ServerFnStrict, +> { + id: ( + id: string, + ) => ServerFnAfterId< + TRegister, + TMethod, + TMiddlewares, + TInputValidator, + TStrict + > +} + +export interface ServerFnAfterId< + TRegister, + TMethod extends Method, + TMiddlewares, + TInputValidator, + TStrict extends ServerFnStrict, +> + extends + ServerFnWithTypes< + TRegister, + TMethod, + TMiddlewares, + TInputValidator, + undefined, + TStrict + >, + ServerFnMiddlewareAfterId< + TRegister, + TMethod, + TMiddlewares, + TInputValidator, + TStrict + >, + ServerFnValidatorAfterId, + ServerFnHandler< + TRegister, + TMethod, + TMiddlewares, + TInputValidator, + TStrict + > {} + +export interface ServerFnMiddlewareAfterId< + TRegister, + TMethod extends Method, + TMiddlewares, + TInputValidator, + TStrict extends ServerFnStrict, +> { + middleware: ( + middlewares: Constrain< + TNewMiddlewares, + ReadonlyArray + >, + ) => ServerFnAfterId< + TRegister, + TMethod, + AppendMiddlewares, + TInputValidator, + TStrict + > +} + +export type ValidatorFnAfterId< + TRegister, + TMethod extends Method, + TMiddlewares, + TStrict extends ServerFnStrict, +> = ( + validator: ConstrainValidator, +) => ServerFnAfterIdAfterValidator< + TRegister, + TMethod, + TMiddlewares, + TInputValidator, + TStrict +> + +export interface ServerFnValidatorAfterId< + TRegister, + TMethod extends Method, + TMiddlewares, + TStrict extends ServerFnStrict, +> { + validator: ValidatorFnAfterId + // TODO remove upon stable + /** @deprecated Use `validator` instead. */ + inputValidator: ValidatorFnAfterId +} + +export interface ServerFnAfterIdAfterValidator< + TRegister, + TMethod extends Method, + TMiddlewares, + TInputValidator, + TStrict extends ServerFnStrict, +> + extends + ServerFnWithTypes< + TRegister, + TMethod, + TMiddlewares, + TInputValidator, + undefined, + TStrict + >, + ServerFnMiddlewareAfterId< + TRegister, + TMethod, + TMiddlewares, + TInputValidator, + TStrict + >, + ServerFnHandler< + TRegister, + TMethod, + TMiddlewares, + TInputValidator, + TStrict + > {} + export interface ServerFnMiddleware< TRegister, TMethod extends Method, @@ -625,6 +764,7 @@ export interface ServerFnAfterMiddleware< >, ServerFnMiddleware, ServerFnValidator, + ServerFnId, ServerFnHandler< TRegister, TMethod, @@ -696,13 +836,8 @@ export interface ServerFnAfterValidator< TInputValidator, TStrict >, - ServerFnHandler< - TRegister, - TMethod, - TMiddlewares, - TInputValidator, - TStrict - > {} + ServerFnHandler, + ServerFnId {} export interface ServerFnAfterTyper< TRegister, @@ -764,6 +899,7 @@ export interface ServerFnBuilder< >, ServerFnMiddleware, ServerFnValidator, + ServerFnId, ServerFnHandler { < TNewMethod extends Method = TMethod, 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..85bca19272 100644 --- a/packages/start-client-core/src/tests/createServerFn.test-d.ts +++ b/packages/start-client-core/src/tests/createServerFn.test-d.ts @@ -578,6 +578,68 @@ test('createServerFn strict false factory preserves strictness', () => { >() }) +test('createServerFn id can be set before handler', () => { + const builder = createServerFn().id('get-user') + + expectTypeOf(builder).toHaveProperty('handler') + expectTypeOf(builder).toHaveProperty('middleware') + expectTypeOf(builder).toHaveProperty('validator') + expectTypeOf(builder).not.toHaveProperty('id') + + const fn = builder.handler(() => ({})) + + expectTypeOf(fn).not.toHaveProperty('id') + + const builderAfterMiddleware = createServerFn() + .id('list-users') + .middleware([]) + + expectTypeOf(builderAfterMiddleware).toHaveProperty('handler') + expectTypeOf(builderAfterMiddleware).not.toHaveProperty('id') + + const builderWithIdAfterMiddleware = createServerFn() + .middleware([]) + .id('update-user') + + expectTypeOf(builderWithIdAfterMiddleware).toHaveProperty('handler') + expectTypeOf(builderWithIdAfterMiddleware).not.toHaveProperty('id') + + const builderWithIdAfterValidator = createServerFn() + .validator((input: { id: string }) => input) + .id('delete-user') + + expectTypeOf(builderWithIdAfterValidator).toHaveProperty('handler') + expectTypeOf(builderWithIdAfterValidator).not.toHaveProperty('id') +}) + +test('createServerFn factory children set their own ids', () => { + const middleware = createMiddleware({ type: 'function' }).server( + ({ next }) => { + return next({ context: { user: 'alice' } as const }) + }, + ) + + const createAuthedServerFn = createServerFn({ method: 'POST' }).middleware([ + middleware, + ]) + + const builder = createAuthedServerFn().id('save-user') + + expectTypeOf(builder).toHaveProperty('handler') + expectTypeOf(builder).not.toHaveProperty('id') + + builder.handler((options) => { + expectTypeOf(options).toEqualTypeOf<{ + context: { + readonly user: 'alice' + } + data: undefined + method: 'POST' + serverFnMeta: ServerFnMeta + }>() + }) +}) + test('createServerFn strict input false can validate function', () => { const fn = createServerFn({ strict: { input: false } }) .validator((input: { func: () => 'input' }) => ({ diff --git a/packages/start-plugin-core/src/start-compiler/compiler.ts b/packages/start-plugin-core/src/start-compiler/compiler.ts index d6b36fe6e8..b2434f3843 100644 --- a/packages/start-plugin-core/src/start-compiler/compiler.ts +++ b/packages/start-plugin-core/src/start-compiler/compiler.ts @@ -44,6 +44,12 @@ type StartCompilerAstPlugin = StartCompilerPlugin & { transformAst: NonNullable } +type GenerateFunctionIdOpts = { + filename: string + functionName: string + extractedFilename: string +} + export type BuiltInLookupKind = | 'ServerFn' | 'Middleware' @@ -522,6 +528,7 @@ export class StartCompiler { // For generating unique function IDs in production builds private entryIdToFunctionId = new Map() private functionIds = new Set() + private reservedFunctionIdOwners = new Map() constructor( private options: { @@ -606,72 +613,117 @@ export class StartCompiler { * In dev mode, uses a base64-encoded JSON with file path and export name. * In build mode, uses SHA256 hash or custom generator. */ - private generateFunctionId(opts: { - filename: string - functionName: string - 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') - } - - // Production build: use custom generator or hash + private generateFunctionId(opts: GenerateFunctionIdOpts): string { const entryId = `${opts.filename}--${opts.functionName}` let functionId = this.entryIdToFunctionId.get(entryId) if (functionId === undefined) { - const knownFn = Object.values(this.options.getKnownServerFns()).find( - (serverFn) => - serverFn.functionName === opts.functionName && - serverFn.extractedFilename === opts.extractedFilename, - ) + const knownFn = this.getKnownServerFn(opts) if (knownFn) { functionId = knownFn.functionId + } else if (this.mode === 'dev') { + functionId = this.generateDevFunctionId(opts) + } else { + functionId = this.generateBuildFunctionId(entryId, opts) } - if (this.options.generateFunctionId) { - functionId ??= this.options.generateFunctionId({ - filename: opts.filename, - functionName: opts.functionName, - }) - } - 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)) { - let deduplicatedId - let iteration = 0 - do { - deduplicatedId = `${functionId}_${++iteration}` - } while (this.functionIds.has(deduplicatedId)) - functionId = deduplicatedId - } this.entryIdToFunctionId.set(entryId, functionId) this.functionIds.add(functionId) } return functionId } + private getKnownServerFn(opts: GenerateFunctionIdOpts): ServerFn | undefined { + return Object.values(this.options.getKnownServerFns()).find( + (serverFn) => + serverFn.functionName === opts.functionName && + serverFn.extractedFilename === opts.extractedFilename, + ) + } + + private isFunctionIdAvailable( + functionId: string, + opts: GenerateFunctionIdOpts, + ): boolean { + if (this.functionIds.has(functionId)) { + return false + } + + const knownFn = this.options.getKnownServerFns()[functionId] + return ( + !knownFn || + (knownFn.functionName === opts.functionName && + knownFn.extractedFilename === opts.extractedFilename) + ) + } + + private hasKnownFunctionId(functionId: string): boolean { + return !!this.options.getKnownServerFns()[functionId] + } + + private generateDevFunctionId(opts: GenerateFunctionIdOpts): string { + 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 createFunctionId = (collision?: number) => + Buffer.from( + JSON.stringify({ + file, + export: opts.functionName, + ...(collision ? { collision } : undefined), + }), + 'utf8', + ).toString('base64url') + + let functionId = createFunctionId() + let iteration = 0 + while (!this.isFunctionIdAvailable(functionId, opts)) { + functionId = createFunctionId(++iteration) + } + return functionId + } + + private generateBuildFunctionId( + entryId: string, + opts: GenerateFunctionIdOpts, + ): string { + let functionId = this.options.generateFunctionId?.({ + filename: opts.filename, + functionName: opts.functionName, + }) + if (!functionId) { + functionId = crypto.createHash('sha256').update(entryId).digest('hex') + } + + const baseFunctionId = functionId + let iteration = 0 + while (!this.isFunctionIdAvailable(functionId, opts)) { + functionId = `${baseFunctionId}_${++iteration}` + } + return functionId + } + + private reserveFunctionId(functionId: string, moduleId: string): boolean { + if ( + this.functionIds.has(functionId) || + this.hasKnownFunctionId(functionId) + ) { + return false + } + + this.functionIds.add(functionId) + this.reservedFunctionIdOwners.set(functionId, cleanId(moduleId)) + return true + } + private get mode(): 'dev' | 'build' { return this.options.mode ?? 'dev' } @@ -868,6 +920,13 @@ export class StartCompiler { return deletedModuleIds } + for (const [functionId, moduleId] of this.reservedFunctionIdOwners) { + if (normalizedIds.has(moduleId)) { + this.reservedFunctionIdOwners.delete(functionId) + this.functionIds.delete(functionId) + } + } + for (const moduleId of Array.from(this.moduleCache.keys())) { const normalizedModuleId = cleanId(moduleId) if (normalizedIds.has(normalizedModuleId)) { @@ -1295,6 +1354,7 @@ export class StartCompiler { // Collect method chain paths by walking DOWN from root through the chain const methodChain: MethodChainPaths = { + id: null, middleware: null, validator: null, // TODO remove upon stable @@ -1319,6 +1379,10 @@ export class StartCompiler { if (t.isIdentifier(callee.property)) { const name = callee.property.name as keyof MethodChainPaths if (name in methodChain) { + if (kind === 'ServerFn' && name === 'id' && methodChain.id) { + throw new Error('createServerFn().id() can only be called once') + } + // Get first argument path const args = currentPath.get('arguments') const firstArgPath = @@ -1368,6 +1432,8 @@ export class StartCompiler { warn: warnFn, generateFunctionId: (opts) => this.generateFunctionId(opts), + reserveFunctionId: (functionId) => + this.reserveFunctionId(functionId, id), 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..8334656230 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_REGEX = /^[A-Za-z0-9_-]+$/ const providerHmrAcceptTemplate = babel.template.statements( ` @@ -155,6 +156,34 @@ function buildServerFnMetaObject( ]) } +function getManualServerFnId( + context: CompilationContext, + id: MethodCallInfo | null, +): string | undefined { + if (!id) { + return undefined + } + + const idArg = id.callPath.node.arguments[0] + if (!idArg || !t.isStringLiteral(idArg)) { + throw codeFrameError( + context.code, + id.callPath.node.loc!, + 'createServerFn().id() must be called with a string literal', + ) + } + + if (!MANUAL_SERVER_FN_ID_REGEX.test(idArg.value)) { + throw codeFrameError( + context.code, + idArg.loc!, + 'createServerFn().id() must be a non-empty URL-safe path segment using only letters, numbers, "_", or "-"', + ) + } + + return idArg.value +} + /** * Generates the RPC stub expression for provider files. * Uses pre-compiled template for performance. @@ -239,10 +268,19 @@ export function handleCreateServerFn( const knownFns = context.getKnownServerFns() const cleanedContextId = cleanId(context.id) + const candidateInfos: Array<{ + candidate: RewriteCandidate + candidateVariableDeclarator: NonNullable< + ReturnType + > + existingVariableName: string + functionName: string + manualFunctionId: string | undefined + variableDeclarator: t.VariableDeclarator + }> = [] + for (const candidate of candidates) { const { path: candidatePath, methodChain } = candidate - const { validator, inputValidator, handler } = methodChain - const candidateVariableDeclarator = getVariableDeclaratorForExpressionPath( candidatePath as babel.NodePath, ) @@ -271,12 +309,82 @@ export function handleCreateServerFn( } functionNameSet.add(functionName) - // Generate function ID using pre-computed relative filename - const functionId = context.generateFunctionId({ - filename: relativeFilename, + const manualFunctionId = getManualServerFnId(context, methodChain.id) + + candidateInfos.push({ + candidate, + candidateVariableDeclarator, + existingVariableName, functionName, - extractedFilename, + manualFunctionId, + variableDeclarator, }) + } + + const manualFunctionIds = new Map() + if (!isProviderFile) { + for (const { + functionName, + manualFunctionId, + variableDeclarator, + } of candidateInfos) { + if (!manualFunctionId) { + continue + } + + const existingManualFunctionName = manualFunctionIds.get(manualFunctionId) + if ( + existingManualFunctionName && + existingManualFunctionName !== functionName + ) { + throw codeFrameError( + context.code, + variableDeclarator.loc!, + `Duplicate manual server function id: ${manualFunctionId}`, + ) + } + manualFunctionIds.set(manualFunctionId, functionName) + + const existingFn = knownFns[manualFunctionId] + const isSameKnownFn = + existingFn?.functionName === functionName && + existingFn.extractedFilename === extractedFilename + + if (existingFn && !isSameKnownFn) { + throw codeFrameError( + context.code, + variableDeclarator.loc!, + `Duplicate manual server function id: ${manualFunctionId}`, + ) + } + + if (!isSameKnownFn && !context.reserveFunctionId(manualFunctionId)) { + throw codeFrameError( + context.code, + variableDeclarator.loc!, + `Duplicate manual server function id: ${manualFunctionId}`, + ) + } + } + } + + for (const candidateInfo of candidateInfos) { + const { + candidate, + candidateVariableDeclarator, + existingVariableName, + functionName, + manualFunctionId, + } = candidateInfo + const { path: candidatePath, methodChain } = candidate + const { id, validator, inputValidator, handler } = methodChain + const functionId = + manualFunctionId ?? + context.generateFunctionId({ + filename: relativeFilename, + functionName, + extractedFilename, + }) // Check if this function was already discovered by the client build const knownFn = knownFns[functionId] @@ -294,6 +402,10 @@ export function handleCreateServerFn( const canonicalExtractedFilename = knownFn?.extractedFilename ?? extractedFilename + if (id) { + stripMethodCall(id.callPath) + } + // TODO remove upon stable if (inputValidator) { warnInputValidatorDeprecation(context, inputValidator) diff --git a/packages/start-plugin-core/src/start-compiler/types.ts b/packages/start-plugin-core/src/start-compiler/types.ts index 1371e7a973..dbd6806c09 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 function ID so later generated IDs can deduplicate against it. */ + reserveFunctionId: (id: string) => boolean /** Get known server functions from previous builds (e.g., client build) */ getKnownServerFns: () => Record /** Module-level directives to add to extracted server function provider files. */ @@ -47,6 +49,7 @@ export interface MethodCallInfo { * This avoids needing to traverse the AST again in handlers. */ export interface MethodChainPaths { + id: MethodCallInfo | null middleware: MethodCallInfo | null validator: MethodCallInfo | null // TODO remove upon stable diff --git a/packages/start-plugin-core/src/vite/start-compiler-plugin/module-specifier.ts b/packages/start-plugin-core/src/vite/start-compiler-plugin/module-specifier.ts index 1324894f68..f3f6f15871 100644 --- a/packages/start-plugin-core/src/vite/start-compiler-plugin/module-specifier.ts +++ b/packages/start-plugin-core/src/vite/start-compiler-plugin/module-specifier.ts @@ -1,4 +1,12 @@ -import type { DevServerFnModuleSpecifierEncoder } from '../../start-compiler/types' +import type { + DevServerFnModuleSpecifierEncoder, + ServerFn, +} from '../../start-compiler/types' + +export type ViteDevServerFnImport = { + file: string + export: string +} export function createViteDevServerFnModuleSpecifierEncoder( root: string, @@ -39,3 +47,37 @@ export function decodeViteDevServerModuleSpecifier( return queryIndex === -1 ? sourceFile : sourceFile.slice(0, queryIndex) } + +export function getViteDevServerFnImport( + id: string, + serverFnsById: Record, + encodeModuleSpecifier: DevServerFnModuleSpecifierEncoder, +): ViteDevServerFnImport { + const registeredServerFn = serverFnsById[id] + if (registeredServerFn) { + return { + file: encodeModuleSpecifier({ + extractedFilename: registeredServerFn.extractedFilename, + root: '', + }), + export: registeredServerFn.functionName, + } + } + + try { + const decoded = JSON.parse(Buffer.from(id, 'base64url').toString('utf8')) + if ( + typeof decoded.file === 'string' && + typeof decoded.export === 'string' + ) { + return { + file: decoded.file, + export: decoded.export, + } + } + } catch { + // Manual IDs are not encoded module references. + } + + throw new Error(`Invalid server function ID: ${id}`) +} diff --git a/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts b/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts index 6309b76eb2..40e4a35f7f 100644 --- a/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts +++ b/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts @@ -24,6 +24,7 @@ import { resolveViteId } from '../../utils' import { createViteDevServerFnModuleSpecifierEncoder, decodeViteDevServerModuleSpecifier, + getViteDevServerFnImport, } from './module-specifier' import { mergeHotUpdateModules } from './hot-update' import type { @@ -156,6 +157,25 @@ function invalidateCompilerVirtualModules( }) } +function deleteServerFnsByModuleIds( + serverFnsById: Record, + ids: Iterable, +): void { + const moduleIds = new Set(Array.from(ids, cleanId)) + if (moduleIds.size === 0) { + return + } + + for (const [functionId, serverFn] of Object.entries(serverFnsById)) { + if ( + moduleIds.has(cleanId(serverFn.filename)) || + moduleIds.has(cleanId(serverFn.extractedFilename)) + ) { + delete serverFnsById[functionId] + } + } +} + function getServerFnProviderIds(ids: Iterable) { const providerIds = new Set() @@ -189,9 +209,7 @@ function getDevServerFnValidatorModule(): string { return ` export async function getServerFnById(id, _access) { const validateIdImport = ${JSON.stringify(validateServerFnIdVirtualModule)} + '?id=' + id - await import(/* @vite-ignore */ '/@id/__x00__' + validateIdImport) - const decoded = Buffer.from(id, 'base64url').toString('utf8') - const devServerFn = JSON.parse(decoded) + const { devServerFn } = await import(/* @vite-ignore */ '/@id/__x00__' + validateIdImport) const mod = await import(/* @vite-ignore */ devServerFn.file) return mod[devServerFn.export] } @@ -447,6 +465,8 @@ export function startCompilerPlugin( compiler.invalidateModules(seenImporters) } + deleteServerFnsByModuleIds(serverFnsById, idsToInvalidate) + invalidateModuleNodes(this.environment, importerModulesToInvalidate) invalidateServerFnLookupModules(this.environment, idsToInvalidate) const compilerVirtualModules = invalidateCompilerVirtualModules( @@ -551,7 +571,13 @@ export function startCompilerPlugin( const parsed = parseIdQuery(id) const fnId = parsed.query.id if (fnId && serverFnsById[fnId]) { - return `export {}` + return `export const devServerFn = ${JSON.stringify( + getViteDevServerFnImport( + fnId, + serverFnsById, + createViteDevServerFnModuleSpecifierEncoder(root), + ), + )}` } // ID not yet registered — the source file may not have been @@ -592,7 +618,13 @@ export function startCompilerPlugin( // Re-check after lazy compilation if (serverFnsById[fnId]) { - return `export {}` + return `export const devServerFn = ${JSON.stringify( + getViteDevServerFnImport( + fnId, + serverFnsById, + createViteDevServerFnModuleSpecifierEncoder(root), + ), + )}` } } } diff --git a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts index c4009657c4..d2b4ad6214 100644 --- a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts +++ b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts @@ -118,6 +118,447 @@ describe('createServerFn compiles correctly', async () => { `) }) + test('should compile server functions with manual ids', async () => { + const code = ` + import { createServerFn } from '@tanstack/react-start' + const myServerFn = createServerFn() + .middleware([]) + .id('get-user') + .handler(() => 'server')` + + const compiledResultClient = await compile({ + code, + env: 'client', + isProviderFile: false, + mode: 'build', + }) + + const compiledResultServerCaller = await compile({ + code, + env: 'server', + isProviderFile: false, + mode: 'build', + }) + + const compiledResultServerProvider = await compile({ + code, + env: 'server', + isProviderFile: true, + mode: 'build', + }) + + expect(compiledResultClient!.code).toContain('createClientRpc("get-user")') + expect(compiledResultClient!.code).not.toContain('.id(') + + expect(compiledResultServerCaller!.code).toContain( + 'createSsrRpc("get-user")', + ) + expect(compiledResultServerCaller!.code).not.toContain('.id(') + + expect(compiledResultServerProvider!.code).toContain('id: "get-user"') + expect(compiledResultServerProvider!.code).not.toContain('.id(') + }) + + test('should register server functions by manual ids', async () => { + const serverFnsById: Record< + string, + { + functionName: string + functionId: string + extractedFilename: string + filename: string + isClientReferenced?: boolean + } + > = {} + + const compiler = new StartCompiler({ + env: 'client', + ...getDefaultTestOptions('client'), + mode: 'build', + loadModule: async () => {}, + lookupKinds: new Set(['ServerFn']), + lookupConfigurations: [ + { + libName: '@tanstack/react-start', + rootExport: 'createServerFn', + kind: 'Root', + }, + ], + resolveId: async (id) => id, + getKnownServerFns: () => ({}), + onServerFnsById: (discovered) => { + Object.assign(serverFnsById, discovered) + }, + }) + + await compiler.compile({ + code: ` + import { createServerFn } from '@tanstack/react-start' + const myServerFn = createServerFn() + .id('get-user') + .handler(() => 'server') + `, + id: '/test/src/test.ts', + }) + + expect(serverFnsById['get-user']).toMatchObject({ + functionId: 'get-user', + functionName: 'myServerFn_createServerFn_handler', + filename: '/test/src/test.ts', + isClientReferenced: true, + }) + }) + + test('should reject dynamic manual server function ids', async () => { + const code = ` + import { createServerFn } from '@tanstack/react-start' + const id = 'get-user' + const myServerFn = createServerFn() + .id(id) + .handler(() => 'server')` + + await expect( + compile({ + code, + env: 'client', + isProviderFile: false, + mode: 'build', + }), + ).rejects.toThrow( + 'createServerFn().id() must be called with a string literal', + ) + }) + + test('should reject invalid manual server function ids', async () => { + const code = ` + import { createServerFn } from '@tanstack/react-start' + const myServerFn = createServerFn() + .id('users/get') + .handler(() => 'server')` + + await expect( + compile({ + code, + env: 'client', + isProviderFile: false, + mode: 'build', + }), + ).rejects.toThrow( + 'createServerFn().id() must be a non-empty URL-safe path segment', + ) + }) + + test('should reject duplicate manual server function ids', async () => { + const code = ` + import { createServerFn } from '@tanstack/react-start' + const getUser = createServerFn() + .id('user-action') + .handler(() => 'get') + const updateUser = createServerFn() + .id('user-action') + .handler(() => 'update')` + + await expect( + compile({ + code, + env: 'client', + isProviderFile: false, + mode: 'build', + }), + ).rejects.toThrow('Duplicate manual server function id: user-action') + }) + + test('should dedupe generated ids that collide with manual ids', async () => { + const serverFnsById: Record< + string, + { + functionName: string + functionId: string + extractedFilename: string + filename: string + isClientReferenced?: boolean + } + > = {} + + const compiler = new StartCompiler({ + env: 'client', + ...getDefaultTestOptions('client'), + mode: 'build', + loadModule: async () => {}, + lookupKinds: new Set(['ServerFn']), + lookupConfigurations: [ + { + libName: '@tanstack/react-start', + rootExport: 'createServerFn', + kind: 'Root', + }, + ], + resolveId: async (id) => id, + generateFunctionId: ({ functionName }) => + functionName === 'generatedFn_createServerFn_handler' + ? 'user-action' + : undefined, + getKnownServerFns: () => ({}), + onServerFnsById: (discovered) => { + Object.assign(serverFnsById, discovered) + }, + }) + + const result = await compiler.compile({ + code: ` + import { createServerFn } from '@tanstack/react-start' + const manualFn = createServerFn() + .id('user-action') + .handler(() => 'manual') + const generatedFn = createServerFn() + .handler(() => 'generated') + `, + id: '/test/src/test.ts', + }) + + expect(result!.code).toContain('createClientRpc("user-action")') + expect(result!.code).toContain('createClientRpc("user-action_1")') + expect(Object.keys(serverFnsById).sort()).toEqual([ + 'user-action', + 'user-action_1', + ]) + }) + + test('should dedupe generated ids that collide with later manual ids', async () => { + const serverFnsById: Record< + string, + { + functionName: string + functionId: string + extractedFilename: string + filename: string + isClientReferenced?: boolean + } + > = {} + + const compiler = new StartCompiler({ + env: 'client', + ...getDefaultTestOptions('client'), + mode: 'build', + loadModule: async () => {}, + lookupKinds: new Set(['ServerFn']), + lookupConfigurations: [ + { + libName: '@tanstack/react-start', + rootExport: 'createServerFn', + kind: 'Root', + }, + ], + resolveId: async (id) => id, + generateFunctionId: ({ functionName }) => + functionName === 'generatedFn_createServerFn_handler' + ? 'user-action' + : undefined, + getKnownServerFns: () => ({}), + onServerFnsById: (discovered) => { + Object.assign(serverFnsById, discovered) + }, + }) + + const result = await compiler.compile({ + code: ` + import { createServerFn } from '@tanstack/react-start' + const generatedFn = createServerFn() + .handler(() => 'generated') + const manualFn = createServerFn() + .id('user-action') + .handler(() => 'manual') + `, + id: '/test/src/test.ts', + }) + + expect(result!.code).toContain('createClientRpc("user-action_1")') + expect(result!.code).toContain('createClientRpc("user-action")') + expect(Object.keys(serverFnsById).sort()).toEqual([ + 'user-action', + 'user-action_1', + ]) + }) + + test('should release manual id reservations when a module is invalidated', async () => { + const compiler = new StartCompiler({ + env: 'client', + ...getDefaultTestOptions('client'), + mode: 'build', + loadModule: async () => {}, + lookupKinds: new Set(['ServerFn']), + lookupConfigurations: [ + { + libName: '@tanstack/react-start', + rootExport: 'createServerFn', + kind: 'Root', + }, + ], + resolveId: async (id) => id, + getKnownServerFns: () => ({}), + }) + + await compiler.compile({ + code: ` + import { createServerFn } from '@tanstack/react-start' + const firstFn = createServerFn() + .id('user-action') + .handler(() => 'first') + `, + id: '/test/src/first.ts', + }) + + compiler.invalidateModule('/test/src/first.ts') + + const result = await compiler.compile({ + code: ` + import { createServerFn } from '@tanstack/react-start' + const secondFn = createServerFn() + .id('user-action') + .handler(() => 'second') + `, + id: '/test/src/second.ts', + }) + + expect(result!.code).toContain('createClientRpc("user-action")') + }) + + test('should dedupe generated ids that collide with known ids', async () => { + const serverFnsById: Record< + string, + { + functionName: string + functionId: string + extractedFilename: string + filename: string + isClientReferenced?: boolean + } + > = { + 'known-action': { + functionName: 'knownFn_createServerFn_handler', + functionId: 'known-action', + extractedFilename: '/test/src/known.ts?tss-serverfn-split', + filename: '/test/src/known.ts', + isClientReferenced: true, + }, + } + + const compiler = new StartCompiler({ + env: 'client', + ...getDefaultTestOptions('client'), + mode: 'build', + loadModule: async () => {}, + lookupKinds: new Set(['ServerFn']), + lookupConfigurations: [ + { + libName: '@tanstack/react-start', + rootExport: 'createServerFn', + kind: 'Root', + }, + ], + resolveId: async (id) => id, + generateFunctionId: ({ functionName }) => + functionName === 'generatedFn_createServerFn_handler' + ? 'known-action' + : undefined, + getKnownServerFns: () => serverFnsById, + onServerFnsById: (discovered) => { + Object.assign(serverFnsById, discovered) + }, + }) + + const result = await compiler.compile({ + code: ` + import { createServerFn } from '@tanstack/react-start' + const generatedFn = createServerFn() + .handler(() => 'generated') + `, + id: '/test/src/generated.ts', + }) + + expect(result!.code).toContain('createClientRpc("known-action_1")') + expect(serverFnsById['known-action']).toMatchObject({ + functionName: 'knownFn_createServerFn_handler', + functionId: 'known-action', + }) + expect(serverFnsById['known-action_1']).toMatchObject({ + functionName: 'generatedFn_createServerFn_handler', + functionId: 'known-action_1', + }) + }) + + test('should dedupe dev generated ids that collide with manual ids', async () => { + const devServerFnPayload = { + file: '/test/src/test.ts?tss-serverfn-split', + export: 'generatedFn_createServerFn_handler', + } + const manualFunctionId = Buffer.from( + JSON.stringify(devServerFnPayload), + 'utf8', + ).toString('base64url') + const serverFnsById: Record< + string, + { + functionName: string + functionId: string + extractedFilename: string + filename: string + isClientReferenced?: boolean + } + > = {} + + 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, + devServerFnModuleSpecifierEncoder: ({ extractedFilename }) => + extractedFilename, + getKnownServerFns: () => ({}), + onServerFnsById: (discovered) => { + Object.assign(serverFnsById, discovered) + }, + }) + + const result = await compiler.compile({ + code: ` + import { createServerFn } from '@tanstack/react-start' + const manualFn = createServerFn() + .id('${manualFunctionId}') + .handler(() => 'manual') + const generatedFn = createServerFn() + .handler(() => 'generated') + `, + id: '/test/src/test.ts', + }) + + const functionIds = Object.keys(serverFnsById).sort() + const generatedFunctionId = functionIds.find( + (functionId) => functionId !== manualFunctionId, + ) + const generatedPayload = JSON.parse( + Buffer.from(generatedFunctionId!, 'base64url').toString('utf8'), + ) + + expect(result!.code).toContain(`createClientRpc("${manualFunctionId}")`) + expect(result!.code).toContain(`createClientRpc("${generatedFunctionId}")`) + expect(functionIds).toHaveLength(2) + expect(functionIds).toContain(manualFunctionId) + expect(generatedPayload).toEqual({ + ...devServerFnPayload, + collision: 1, + }) + }) + // TODO remove upon stable test('should warn for deprecated inputValidator method', async () => { const warn = vi.fn() diff --git a/packages/start-plugin-core/tests/vite/start-compiler-utils.test.ts b/packages/start-plugin-core/tests/vite/start-compiler-utils.test.ts index bf028a483e..b22318734d 100644 --- a/packages/start-plugin-core/tests/vite/start-compiler-utils.test.ts +++ b/packages/start-plugin-core/tests/vite/start-compiler-utils.test.ts @@ -2,11 +2,63 @@ import { describe, expect, test } from 'vitest' import { createViteDevServerFnModuleSpecifierEncoder, decodeViteDevServerModuleSpecifier, + getViteDevServerFnImport, } from '../../src/vite/start-compiler-plugin/module-specifier' import { mergeHotUpdateModules } from '../../src/vite/start-compiler-plugin/hot-update' import type { EnvironmentModuleNode } from 'vite' describe('Vite dev server module specifiers', () => { + test('resolves registered server function ids before decoding them', () => { + const generatedIdShapedManualId = Buffer.from( + JSON.stringify({ + file: '/src/routes/wrong.tsx?tss-serverfn-split', + export: 'wrong_createServerFn_handler', + }), + 'utf8', + ).toString('base64url') + + expect( + getViteDevServerFnImport( + generatedIdShapedManualId, + { + [generatedIdShapedManualId]: { + functionName: 'getUser_createServerFn_handler', + functionId: generatedIdShapedManualId, + filename: '/repo/app/src/routes/users.tsx', + extractedFilename: + '/repo/app/src/routes/users.tsx?tss-serverfn-split', + isClientReferenced: true, + }, + }, + createViteDevServerFnModuleSpecifierEncoder('/repo/app'), + ), + ).toEqual({ + file: '/src/routes/users.tsx?tss-serverfn-split', + export: 'getUser_createServerFn_handler', + }) + }) + + test('falls back to decoding generated dev server function ids', () => { + const encodedId = Buffer.from( + JSON.stringify({ + file: '/src/routes/users.tsx?tss-serverfn-split', + export: 'getUser_createServerFn_handler', + }), + 'utf8', + ).toString('base64url') + + expect( + getViteDevServerFnImport( + encodedId, + {}, + createViteDevServerFnModuleSpecifierEncoder('/repo/app'), + ), + ).toEqual({ + file: '/src/routes/users.tsx?tss-serverfn-split', + export: 'getUser_createServerFn_handler', + }) + }) + test('encodes app files as root-relative dev server paths', () => { const encode = createViteDevServerFnModuleSpecifierEncoder('/repo/app')