fix: eliminate server-side bundle leak via lazy handler bodies#75
fix: eliminate server-side bundle leak via lazy handler bodies#75
Conversation
Slim `actions.ts` and `server-functions.ts` so each `createServerFn(...).handler(...)` body is a thin shell that dynamic-imports its real logic from a sibling `*-bodies.ts` file. The TanStack Start compiler rewrites the slimmed handlers to `createClientRpc(id)` stubs at consumer build time, and the dynamic-import expression is dropped from the client graph — no static path reaches `@workos/authkit-session` or `@workos-inc/node`. Also: - Convert three client-side value imports of types to `import type` - Add oxlint `no-restricted-imports` override with `allowTypeImports: true` - Add `scripts/check-bundle-leak.sh` + `build:check` script + CI step - Wire `build:check` into `prepublishOnly` chain - Add CLAUDE.md documenting the lazy-bodies pattern and load-bearing assumptions Resolves #72.
Greptile SummaryThis PR fixes issue #72 ( Confidence Score: 5/5Safe to merge — the refactoring is functionally equivalent and all paths are covered by existing tests. No P0 or P1 issues found. The architectural change is well-reasoned, behaviorally equivalent to the original, and backed by a thorough test plan. The single P2 finding (missing empty-JS guard in the leak-check script) does not affect production correctness. scripts/check-bundle-leak.sh — minor false-negative risk when build output is empty. Important Files Changed
|
Co-Authored-By: nick.nisi@workos.com <nick@nisi.org>
| return { user: null }; | ||
| } | ||
|
|
||
| return { |
There was a problem hiding this comment.
This appears to be the same shape as sanitizeAuthForClient in action-bodies.ts. Is there a way to abstract them?
There was a problem hiding this comment.
They're close but intentionally different — sanitizeAuthForClient (in action-bodies.ts) strips accessToken for client-facing actions, while getAuthFromContext (here) keeps it for server-side use. The shared fields could be extracted into a helper in auth-helpers.ts that both files already import from, e.g.:
// auth-helpers.ts
export function mapAuthToBaseInfo(auth: AuthResult<User>) {
return {
user: auth.user,
sessionId: auth.sessionId!,
organizationId: auth.claims?.org_id,
role: auth.claims?.role,
roles: auth.claims?.roles,
permissions: auth.claims?.permissions,
entitlements: auth.claims?.entitlements,
featureFlags: auth.claims?.feature_flags,
impersonator: auth.impersonator,
};
}Then getAuthFromContext adds accessToken and sanitizeAuthForClient uses it as-is. That also fixes the any type in sanitizeAuthForClient. Want me to make that change?
There was a problem hiding this comment.
Done — extracted mapAuthToBaseInfo into auth-helpers.ts in ff52dee. Both action-bodies.ts and server-fn-bodies.ts now use it; the any type on the old sanitizeAuthForClient is also gone. All 203 tests pass.
| @@ -0,0 +1,214 @@ | |||
| # CLAUDE.md | |||
There was a problem hiding this comment.
Probably low risk but we can add an .npmignore or update files in package.json to exclude this from bundling to npm.
There was a problem hiding this comment.
Already covered — the files field in package.json restricts the npm tarball to dist/, LICENSE, and README.md, so CLAUDE.md won't be published.
Consolidate the duplicated auth-to-user-info field mapping from action-bodies.ts (sanitizeAuthForClient) and server-fn-bodies.ts (getAuthFromContext) into a shared mapAuthToBaseInfo helper in auth-helpers.ts. Also eliminates the 'any' type in sanitizeAuthForClient. Co-Authored-By: nick.nisi@workos.com <nick@nisi.org>
Summary
Fixes #72 —
SyntaxError: eventemitter3 does not provide an export named 'default'when any client route mountsAuthKitProviderin Vite dev.The root cause: client files statically import
actions.ts/server-functions.ts, which statically importauth-helpers.ts→authkit-loader.ts→@workos/authkit-session→@workos-inc/node. Vite's dev server eagerly walks that graph, dragging the entire WorkOS Node SDK (and itseventemitter3CJS dep) into the browser bundle.What changed
createServerFn(...).handler(...)is now a thin shell thatawait import()s its real logic fromaction-bodies.ts/server-fn-bodies.ts. The TanStack Start compiler rewrites these shells tocreateClientRpc(id)on the client side, so the dynamic import never fires in the browser.AuthKitProvider.tsx,impersonation.tsx,types.ts) so types are on separateimport typelines..oxlintrc.jsonnow blocks static value imports of server-only modules inactions.ts/server-functions.ts(withallowTypeImports: true).scripts/check-bundle-leak.shgreps the example's built client bundle for server-side fingerprints (@workos-inc/node,iron-session,FeatureFlagsRuntimeClient, etc.). Wired into CI andprepublishOnly.Load-bearing assumption
The fix relies on TanStack Start's compiler plugin transforming this SDK's installed
dist/**/*.jsfiles. The plugin's transform filter (/\.[cm]?[tj]sx?($|\?)/) has nonode_modulesexclusion as of@tanstack/start-plugin-core@1.154.8. Verified by inspecting the served module viacurlagainst the example dev server — all 8 handler declarations are rewritten tocreateClientRpc(id)stubs with zero handler-body references remaining.If a future TanStack/Vite release changes this,
pnpm run build:checkwill catch it.Test plan
pnpm typecheck— passespnpm lint— 0 warnings, 0 errors (oxlint guard active)pnpm test— 203 tests pass (17 files, no test modifications needed)pnpm build— passescd example && pnpm build— no Node-only externalization warningspnpm run build:check— no server-side fingerprints in client bundleactions.jsandserver-functions.jscontaincreateClientRpcstubs, zero leaked handler-body refscd example && pnpm dev→ load route withAuthKitProvider→ no eventemitter3 SyntaxErrorimport { getRawAuthFromContext } from './auth-helpers.js'inactions.ts→ lint fails with expected messageimport type { AuthResult } from './auth-helpers.js'→ lint passes