start-plugin-core: the dev server-fn id validator's recovery can never succeed — it transforms under the one flag that skips the compile
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 15.1k
- Forks
- 1.9k
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 143
Description
Summary
In dev, a request to a server function whose source file this dev server has not compiled yet fails with 500 Invalid server function ID. The validator has a recovery path meant for exactly this case, and the recovery cannot succeed: it transforms the source file under ?server-fn-module-lookup, which is the one id the compiling transform explicitly excludes.
Versions: @tanstack/start-plugin-core@1.171.38, @tanstack/react-start@1.168.48, vite@8.2.2, Node 24, Windows 11. I re-read the validator in main today and it is unchanged.
Where
packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts, plugin tanstack-start-core:validate-server-fn-id:
// Trigger transform of the source file in this environment,
// which will compile createServerFn calls and populate
// serverFnsById as a side effect.
if (this.environment.mode !== 'dev') { /* … */ }
await this.environment.transformRequest(`${absPath}?${SERVER_FN_LOOKUP}`)
// Re-check after lazy compilation
if (serverFnsById[fnId]) {
return `export {}`
}
Why it cannot work
serverFnsByIdis written from exactly one place —context.onServerFnsByIdinstart-compiler/handleCreateServerFn.ts, which is reachable only fromStartCompiler.compile().- The transform that calls
compile()(tanstack-start-core::server-fn:<env>) is declared withfilter: { id: { exclude: new RegExp(${SERVER_FN_LOOKUP}$), include: TRANSFORM_ID_REGEX } }. The recovery's id ends inSERVER_FN_LOOKUP, socompile()never runs for it. - The only plugin that does run on that id is
tanstack-start-core:capture-server-fn-module-lookup, whose handler callscompiler.ingestModule({ code, id }).ingestModuleisparseAst+extractModuleInfo: it populatesmoduleCache, neverserverFnsById.
So the re-check after the transform is always false and the handler falls through to this.error('Invalid server function ID: …').
mergeServerFnsById only ever merges, so a miss can only mean "this file has never been compiled in this dev server's lifetime" — precisely the case the comment above the recovery names ("cold restart with cached client").
Reproduction (no browser, no HTTP request)
Boot a dev server in middlewareMode, render nothing, and ask the validator for an id whose file has not been compiled:
import { createServer } from 'vite'
const fnId = Buffer.from(JSON.stringify({
file: '/src/path/to/your.functions.ts?tss-serverfn-split',
export: 'yourFn_createServerFn_handler',
})).toString('base64url')
const server = await createServer({ server: { middlewareMode: true, hmr: false } })
await server.environments.ssr.transformRequest(
`\0virtual:tanstack-start-validate-server-fn-id?id=${fnId}`,
)
// → Error: Invalid server function ID: <fnId>
(Note the \0virtual:… form rather than /@id/__x00__virtual:… — /@id/ is unwrapped by the dev HTTP middleware, which transformRequest does not go through.)
Control, same unpatched server, same process: transform the clean source path first and then ask, and it resolves:
await server.environments.ssr.transformRequest('/abs/path/to/your.functions.ts')
// now the validator accepts the same id
The only difference between the two runs is which transform ran, which also shows the shape of the fix.
In the real app this surfaced as a 500 on a POST to a server function, through handleServerAction → getServerFnById, on a dev server that had been serving for a while but had not compiled that particular module.
Suggested fix
Transform the clean id — the one the compiling transform accepts:
await this.environment.transformRequest(`${absPath}?${SERVER_FN_LOOKUP}`)
+if (serverFnsById[fnId]) return `export {}`
+
+await this.environment.transformRequest(absPath)
if (serverFnsById[fnId]) return `export {}`
Or simply replace the lookup transform with the clean one — compile() does everything ingestModule does and registers the id as well.
I verified this locally as a pnpm patch. The reproduction above goes from throwing to resolving, an id with a non-existent export is still refused (so the guard still guards), and the ?tss-serverfn-split provider module that getServerFnById imports right afterwards compiles and exports the handler.
Happy to open a PR if the shape above is what you'd want.
Secondary, same handler
if (this.environment.mode !== 'dev') this.error(…) sits inside the try { … } catch {}. this.error throws, the bare catch swallows it, and the specific "unknown environment mode" message is replaced by the generic Invalid server function ID. Probably worth hoisting above the try.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts at tanstack-start-core:validate-server-fn-id, then trace the server-fn transform and handleCreateServerFn.ts. Run the middlewareMode reproduction for an uncompiled module and verify that validation succeeds afterward while an unknown export remains rejected; also check the secondary environment-mode error behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript, vite
- Domain
- backend, devtools
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 78/100