TanStack / TanStack/router

start-plugin-core: the dev server-fn id validator's recovery can never succeed — it transforms under the one flag that skips the compile

Open
#8,447 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

information needed
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
  1. serverFnsById is written from exactly one place — context.onServerFnsById in start-compiler/handleCreateServerFn.ts, which is reachable only from StartCompiler.compile().
  2. The transform that calls compile() (tanstack-start-core::server-fn:<env>) is declared with filter: { id: { exclude: new RegExp(${SERVER_FN_LOOKUP}$), include: TRANSFORM_ID_REGEX } }. The recovery's id ends in SERVER_FN_LOOKUP, so compile() never runs for it.
  3. The only plugin that does run on that id is tanstack-start-core:capture-server-fn-module-lookup, whose handler calls compiler.ingestModule({ code, id }). ingestModule is parseAst + extractModuleInfo: it populates moduleCache, never serverFnsById.

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 handleServerActiongetServerFnById, 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.