Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b26da8a
add manual server fn id option and literal extraction
LadyBluenotes Jun 25, 2026
f9c10d6
add manual server fn ids with reservation and validation
LadyBluenotes Jun 25, 2026
da14d36
stabalize manual server fn ids across chains and shared compilers
LadyBluenotes Jun 25, 2026
b0be51f
deduplicate generated custom IDs and enforce manual ID constraints in…
LadyBluenotes Jun 25, 2026
6e015f7
add tests to preserve static manual IDs with unrelated spreads and co…
LadyBluenotes Jun 25, 2026
9a32289
dedupe generated server fn ids against known ids and reject computed …
LadyBluenotes Jun 25, 2026
a8a2739
add test to reuse canonical known server fn IDs without suffixing
LadyBluenotes Jun 25, 2026
e81f6d6
docs
LadyBluenotes Jun 25, 2026
6bc295e
clarify behavior of manual IDs in server functions and update documen…
LadyBluenotes Jun 25, 2026
20c9157
add validation to prevent duplicate manual server function IDs and en…
LadyBluenotes Jun 25, 2026
deb212f
ci: apply automated fixes
autofix-ci[bot] Jun 25, 2026
cb51cfb
remove redundant tests for template literal manual IDs in createServerFn
LadyBluenotes Jun 25, 2026
d59e3c8
add validation to ensure static string literals or constant bindings …
LadyBluenotes Jun 25, 2026
7bd7dd2
refactor tests for createServerFn to use parameterized test cases for…
LadyBluenotes Jun 25, 2026
7947990
cleanup tests
LadyBluenotes Jun 25, 2026
f75b7bd
Merge branch 'stable-serverfn-ids-2' of https://github.com/TanStack/r…
LadyBluenotes Jun 25, 2026
b96a0c6
ci: apply automated fixes
autofix-ci[bot] Jun 25, 2026
87fa40a
Merge branch 'main' into stable-serverfn-ids-2
LadyBluenotes Jun 25, 2026
230cdcc
fix linting error
LadyBluenotes Jun 25, 2026
148dfab
Merge branch 'stable-serverfn-ids-2' of https://github.com/TanStack/r…
LadyBluenotes Jun 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/start-client-core/src/createServerFn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export interface ServerFnOptions<
> {
method?: TMethod
strict?: TStrict
id?: string
}

export type ServerFnStrictInput<TStrict extends ServerFnStrict> =
Expand Down Expand Up @@ -502,6 +503,7 @@ export type ServerFnBaseOptions<
> = {
method: TMethod
strict?: TStrict
id?: string
middleware?: Constrain<
TMiddlewares,
ReadonlyArray<AnyFunctionMiddleware | AnyRequestMiddleware>
Expand Down
2 changes: 2 additions & 0 deletions packages/start-client-core/src/tests/createServerFn.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
122 changes: 98 additions & 24 deletions packages/start-plugin-core/src/start-compiler/compiler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import crypto from 'node:crypto'
import * as t from '@babel/types'
import path from 'pathe'
import {
deadCodeElimination,
extractModuleInfoFromAst,
Expand Down Expand Up @@ -514,6 +515,9 @@ export class StartCompiler {
string,
Map<string, ExportResolution | null>
>()
private reservedManualFunctionIds = new Set<string>()
private reservedManualFunctionIdOwners = new Map<string, string>()
private reservedManualFunctionIdsByFilename = new Map<string, Set<string>>()
// 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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -672,6 +671,63 @@ 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)

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}`)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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<string>()
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'
}
Expand Down Expand Up @@ -871,6 +927,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)
}
Expand All @@ -895,6 +952,22 @@ 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<string>,
): Promise<Set<string>> {
Expand Down Expand Up @@ -1368,6 +1441,7 @@ export class StartCompiler {
warn: warnFn,

generateFunctionId: (opts) => this.generateFunctionId(opts),
reserveFunctionId: (opts) => this.reserveFunctionId(opts),
getKnownServerFns: this.options.getKnownServerFns,
serverFnProviderModuleDirectives:
this.options.serverFnProviderModuleDirectives,
Expand Down
Loading
Loading