Skip to content

⚠️ fix: Keep Session Module Server-only - #100

Merged
danny-avila merged 2 commits into
mainfrom
fix-server-only-refresh-session
Jul 30, 2026
Merged

⚠️ fix: Keep Session Module Server-only#100
danny-avila merged 2 commits into
mainfrom
fix-server-only-refresh-session

Conversation

@dustinhealy

@dustinhealy dustinhealy commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

The production build has been failing since #99 merged (see the docker-publish workflow run on main). TanStack Start's import-protection plugin rejects the client bundle:

[import-protection] Import denied in client environment
Denied by specifier pattern: @tanstack/react-start/server
Importer: src/server/utils/refresh.ts

refresh.ts calls getRequestHeader from @tanstack/react-start/server as a plain function, and this file is reachable from a client-bundled route (login.tsx -> @/server -> scopes.ts -> utils/api.ts -> refresh.ts) even though every real caller is server-only. The plugin can't prove that from a bare import alone and rejects the build. Fixing that surfaces the same issue one level deeper: refresh.ts also imports session.ts, which calls useSession the same unwrapped way.

Fix

First commit wraps both readTenantHeader and useAppSession in createServerOnlyFn, the pattern the compiler's own error message points to.

That fix alone still leaves a real bug: createServerOnlyFn only replaces the wrapped function's body in the client bundle, it doesn't touch the rest of the module's top-level statements. session.ts computed SESSION_SECRET validation and cookie settings, and threw, at module scope, so that code still ran the instant the module was pulled into the client bundle transitively, including inside the browser's own JS runtime, even with useAppSession itself correctly stubbed out. Bundlers don't tree-shake a module with side effects just because its exports go unused, so this threw unconditionally in the browser, where process.env is always empty, regardless of whether the server itself has SESSION_SECRET set.

Second commit moves the throwing validation and env reads into the useAppSession closure, and turns SESSION_CONFIG (a plain top-level const) into a lazily-computed getSessionConfig(), both wrapped in createServerOnlyFn. Nothing in this module now executes merely by being imported.

Testing

Fresh clone, fresh bun install, from-scratch bun run build (both client and SSR passes succeed, including with SESSION_SECRET unset), confirmed the error string no longer appears anywhere in the client bundle output, tsc --noEmit clean, eslint clean, full test suite (799 tests) passing with SESSION_SECRET set, and a real browser (Playwright) hit against a locally built production server showing zero console errors.

The production build fails: TanStack Start's import-protection plugin
rejects the client bundle because server/utils/refresh.ts imports
getRequestHeader from @tanstack/react-start/server as a plain function
call, and this file is reachable from a client-bundled route
(login.tsx -> @/server -> scopes.ts -> utils/api.ts -> refresh.ts)
even though every real caller is server-only. The plugin can't prove
that from a bare import alone.

Fixing that surfaces the same problem one level deeper: refresh.ts
also imports session.ts, which calls useSession the same unwrapped
way. The CI workflow that gates pull requests only runs lint, tsc, and
vitest, never a full production build, so nothing before now caught
this.

Wraps both in createServerOnlyFn, the pattern the compiler's own error
message points to. Verified with a clean install and a from-scratch
production build (both client and SSR passes), plus tsc, eslint, and
the full test suite.
createServerOnlyFn only replaces the wrapped function body in the client
bundle; it doesn't touch the rest of the module's top-level statements.
session.ts computed SESSION_SECRET validation and cookie settings, and
threw, at module scope, so that code ran the instant the module was
pulled into the client bundle transitively (even though the wrapped
useAppSession call itself was correctly stubbed out). Bundlers don't
tree-shake a module with side effects just because its exports go
unused, so this threw unconditionally inside the browser's own JS
runtime, where process.env is always empty, regardless of whether the
server actually has SESSION_SECRET set.

Moves the throwing validation and the cookie-secure/base-path env reads
into the useAppSession closure, and turns SESSION_CONFIG (a plain
top-level const) into a lazily-computed getSessionConfig(), both wrapped
in createServerOnlyFn. Nothing in this module now executes merely by
being imported.
@dustinhealy dustinhealy changed the title fix: mark session and tenant-header reads server-only fix: keep the session module server-only, not just its useSession call Jul 30, 2026
@dustinhealy dustinhealy changed the title fix: keep the session module server-only, not just its useSession call ⚠️ fix: Keep Session Module Server-only Jul 30, 2026
@danny-avila

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

Reviewed commit: ae94d58c7e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@danny-avila
danny-avila merged commit 18023c9 into main Jul 30, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants