Skip to content

Start: server function URL computed at module init from unreplaced process.env.TSS_SERVER_FN_BASE, yielding a relative RPC URL in dev #8201

Description

@nielsvdpol

Which project does this relate to?

Start

Describe the bug

createClientRpc() computes a server function's request URL at module evaluation time (packages/start-client-core/src/client-rpc/createClientRpc.ts):

export function createClientRpc(functionId: string) {
  const url = process.env.TSS_SERVER_FN_BASE + functionId
  // …
}

TSS_SERVER_FN_BASE is supplied as a Vite define (defineReplaceEnv() in start-plugin-core). During vite dev that define is not applied to files inside node_modules — the dev-served module still contains the literal process.env.TSS_SERVER_FN_BASE, and the value exists only because Vite's /@vite/env shim assigns globalThis.process.env at runtime. The URL is therefore only correct if /@vite/env has executed before the generated server-function module evaluates.

When it hasn't, url becomes the string "undefined" + functionId, which is a relative URL. The fetch then resolves against the current document path — e.g. /posts/1/undefinedsrc_utils_posts_tsx--fetchPost_createServerFn_handler — never matches TSS_SERVER_FN_BASE in createStartHandler, falls through to the router, and since serverFnFetcher sends accept: application/x-ndjson, application/json the router answers:

500 {"error":"Only HTML requests are supported here"}

serverFnFetcher rethrows that as new Error(await response.text()), so app authors see a bare Error whose message is raw JSON, thrown from whichever component happens to call a server function first — with nothing pointing at the real cause.

Two things make it look random rather than reproducible:

  • it depends on module evaluation order, which shifts with dep-optimization state, HMR and import graph changes;
  • a relative URL still resolves correctly on / (undefined…/undefined… is wrong but at the root there is no prefix to inherit), while on /foo/bar it inherits the path. So the same app "works" on one page and fails on another.

Complete minimal reproducer

https://stackblitz.com/github/tanstack/router/tree/main/examples/react/start-basic

No custom code is needed — the example's own server functions (src/utils/posts.tsx) are enough. Any Start app with a server function shows it.

Steps to Reproduce the Bug

  1. Start the dev server for the example above.

  2. Confirm the define is not statically applied in dev — request the dev-served dependency module:

    curl -s 'http://localhost:3000/@fs/<abs-path>/node_modules/@tanstack/start-client-core/dist/esm/client-rpc/createClientRpc.js' | grep TSS_SERVER_FN_BASE
    const url = process.env.TSS_SERVER_FN_BASE + functionId;   // unreplaced

    In the browser, process.env.TSS_SERVER_FN_BASE is a runtime property (Object.keys(globalThis.process.env)TSS_CLIENT_OUTPUT_DIR, TSS_DEV_SERVER, TSS_ROUTER_BASEPATH, TSS_SERVER_FN_BASE, TSS_SHELL), i.e. it comes from the shim, not from a build-time replacement.

  3. Force the ordering that the shim normally hides. On any non-root route, in the browser console:

    delete process.env.TSS_SERVER_FN_BASE
    const m = await import('/src/utils/posts.tsx?forcereeval=1')   // fresh module instance
    await m.fetchPost({ data: '1' })
    Error: {"error":"Only HTML requests are supported here"}
    Network: GET /posts/undefinedsrc_utils_posts_tsx--fetchPost_createServerFn_handler?payload=…
    

Step 3 forces deterministically what module ordering otherwise decides by chance; the timing dependence is exactly why this surfaces intermittently in real apps rather than always.

Expected behavior

A server function's request URL shouldn't depend on module evaluation order in dev — it should be absolute in every case.

A few directions (not prescriptive):

  • compute url lazily inside clientFn, so the read happens at call time, long after the dev shims are installed (url is also exposed on the returned object, so a getter would preserve that);
  • or fall back to the configured default when the define is missing, instead of string-concatenating undefined;
  • or read import.meta.env.TSS_SERVER_FN_BASEdefineReplaceEnv() already defines both spellings, and Vite provides import.meta.env per module in dev.

Separately, and regardless of which fix is chosen: a request that reaches the router with a non-HTML Accept currently reports as 500, which sent me looking for a server fault. See #7913 for that side of it.

Screenshots or Videos

No response

Platform

  • Router / Start Version: @tanstack/react-start 1.133.3 — the relevant line is unchanged in @tanstack/start-client-core 1.170.27, and defineReplaceEnv / the dev client-entry injection are unchanged too, so this is not fixed by upgrading
  • OS: macOS (darwin 25.6.0)
  • Browser: Chrome / Chromium
  • Bundler: rolldown-vite (aliased as vite), Vite 7 dev server
  • Also present with @cloudflare/vite-plugin as the SSR environment, though that is not needed to reproduce

Additional context

Workaround for app authors until this is fixed — seed the value from an inline classic script in <head>, which runs while the head is parsed and therefore before any module script exists:

<script>globalThis.process ??= {}; globalThis.process.env ??= {}; globalThis.process.env.TSS_SERVER_FN_BASE ??= "/_serverFn/";</script>

??= yields to the real value whenever the shim wins the race anyway, and the snippet can be gated to dev so production keeps using the statically replaced define.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions