Skip to content

fix: eliminate server-side bundle leak via lazy handler bodies#75

Merged
nicknisi merged 3 commits intomainfrom
fix/lazy-handler-bodies
May 1, 2026
Merged

fix: eliminate server-side bundle leak via lazy handler bodies#75
nicknisi merged 3 commits intomainfrom
fix/lazy-handler-bodies

Conversation

@nicknisi
Copy link
Copy Markdown
Member

@nicknisi nicknisi commented Apr 30, 2026

Summary

Fixes #72SyntaxError: eventemitter3 does not provide an export named 'default' when any client route mounts AuthKitProvider in Vite dev.

The root cause: client files statically import actions.ts / server-functions.ts, which statically import auth-helpers.tsauthkit-loader.ts@workos/authkit-session@workos-inc/node. Vite's dev server eagerly walks that graph, dragging the entire WorkOS Node SDK (and its eventemitter3 CJS dep) into the browser bundle.

What changed

  • Lazy handler bodies: each createServerFn(...).handler(...) is now a thin shell that await import()s its real logic from action-bodies.ts / server-fn-bodies.ts. The TanStack Start compiler rewrites these shells to createClientRpc(id) on the client side, so the dynamic import never fires in the browser.
  • Type-only imports: split three client-side co-imports of types + values (AuthKitProvider.tsx, impersonation.tsx, types.ts) so types are on separate import type lines.
  • oxlint regression guard: .oxlintrc.json now blocks static value imports of server-only modules in actions.ts / server-functions.ts (with allowTypeImports: true).
  • Bundle-leak CI check: scripts/check-bundle-leak.sh greps the example's built client bundle for server-side fingerprints (@workos-inc/node, iron-session, FeatureFlagsRuntimeClient, etc.). Wired into CI and prepublishOnly.
  • CLAUDE.md: documents the lazy-bodies pattern, load-bearing assumption (TanStack compiler must transform installed dist), and re-verification steps.

Load-bearing assumption

The fix relies on TanStack Start's compiler plugin transforming this SDK's installed dist/**/*.js files. The plugin's transform filter (/\.[cm]?[tj]sx?($|\?)/) has no node_modules exclusion as of @tanstack/start-plugin-core@1.154.8. Verified by inspecting the served module via curl against the example dev server — all 8 handler declarations are rewritten to createClientRpc(id) stubs with zero handler-body references remaining.

If a future TanStack/Vite release changes this, pnpm run build:check will catch it.

Test plan

  • pnpm typecheck — passes
  • pnpm lint — 0 warnings, 0 errors (oxlint guard active)
  • pnpm test — 203 tests pass (17 files, no test modifications needed)
  • pnpm build — passes
  • cd example && pnpm build — no Node-only externalization warnings
  • pnpm run build:check — no server-side fingerprints in client bundle
  • Pre-flight verification: served actions.js and server-functions.js contain createClientRpc stubs, zero leaked handler-body refs
  • Manual AuthKitProvider /client entry pulls @workos-inc/node into the dev browser bundle, breaking Vite dev server #72 repro: cd example && pnpm dev → load route with AuthKitProviderno eventemitter3 SyntaxError
  • Full auth flow: sign in → protected route → refresh → sign out — all working
  • oxlint negative test: deliberate import { getRawAuthFromContext } from './auth-helpers.js' in actions.ts → lint fails with expected message
  • oxlint positive test: import type { AuthResult } from './auth-helpers.js' → lint passes

Open in Devin Review

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-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 30, 2026

Greptile Summary

This PR fixes issue #72 (eventemitter3 SyntaxError in Vite dev) by introducing a "lazy handler bodies" pattern: actions.ts and server-functions.ts are now thin shells whose handler bodies live in sibling action-bodies.ts / server-fn-bodies.ts files and are loaded only via await import(...). Because TanStack Start's compiler rewrites every .handler(fn) call to a createClientRpc(id) stub on the client side, the dynamic import is dead-code-eliminated before it can drag @workos-inc/node or iron-session into the browser bundle. Client-side type-only imports (import type) are split throughout, and a new oxlint no-restricted-imports rule plus a CI bundle-leak grep script enforce the boundary going forward.

Confidence Score: 5/5

Safe 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

Filename Overview
src/server/actions.ts Refactored to thin shell handlers using dynamic imports into action-bodies.ts; static imports now type-only, satisfying the oxlint guard.
src/server/server-functions.ts Refactored to thin shell handlers using dynamic imports into server-fn-bodies.ts; plan-object pattern cleanly separates redirect logic from body computation.
src/server/action-bodies.ts New bodies file containing the real handler logic for actions.ts; correctly imports from auth-helpers.js and keeps server-only deps out of the static client graph.
src/server/server-fn-bodies.ts New bodies file for server-functions.ts; contains PKCE cookie forwarding, sign-out plan, and organization-switch plan logic; all server-only imports are confined here.
src/server/auth-helpers.ts Added mapAuthToBaseInfo helper to deduplicate the claims-to-UserInfo mapping shared by both bodies files.
scripts/check-bundle-leak.sh New CI regression guard; minor: no check that JS files actually exist before grepping, could silently pass if build output is empty.
.oxlintrc.json Adds no-restricted-imports override for actions.ts and server-functions.ts with allowTypeImports:true; correctly blocks static value imports of server-only modules.
.github/workflows/ci.yml Added Example Build step that builds the example app then runs check-bundle-leak.sh to enforce the no-server-leak invariant in CI.
src/server/actions.spec.ts Added mapAuthToBaseInfo mock to vi.mock('./auth-helpers') factory so the new body functions under test resolve correctly.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    subgraph Client["Browser / Client Bundle"]
        AP["AuthKitProvider.tsx"]
        IMP["impersonation.tsx"]
        TYPES["client/types.ts"]
    end

    subgraph Shells["Server Shells (compiled → createClientRpc stubs)"]
        ACT["actions.ts\n(thin shells, type-only imports)"]
        SFN["server-functions.ts\n(thin shells, type-only imports)"]
    end

    subgraph Bodies["Server Bodies (never reaches client)"]
        AB["action-bodies.ts\n(static server imports)"]
        SFB["server-fn-bodies.ts\n(static server imports)"]
    end

    subgraph ServerOnly["Server-only Modules"]
        AH["auth-helpers.ts"]
        AL["authkit-loader.ts"]
        WOS["@workos/authkit-session\n@workos-inc/node"]
    end

    AP -->|"import value + import type"| ACT
    AP -->|"import value + import type"| SFN
    IMP -->|"import value + import type"| ACT
    TYPES -->|"import type only"| SFN

    ACT -->|"await import() at runtime\n(dead-coded on client)"| AB
    SFN -->|"await import() at runtime\n(dead-coded on client)"| SFB

    AB --> AH
    SFB --> AH
    SFB --> AL
    AH --> WOS
    AL --> WOS

    style Client fill:#dbeafe,stroke:#3b82f6
    style Shells fill:#fef9c3,stroke:#eab308
    style Bodies fill:#dcfce7,stroke:#16a34a
    style ServerOnly fill:#fee2e2,stroke:#dc2626
Loading

Reviews (3): Last reviewed commit: "refactor: Extract shared mapAuthToBaseIn..." | Re-trigger Greptile

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 4 additional findings.

Open in Devin Review

Co-Authored-By: nick.nisi@workos.com <nick@nisi.org>
@nicknisi nicknisi requested a review from gjtorikian April 30, 2026 14:21
Comment thread src/server/server-fn-bodies.ts Outdated
return { user: null };
}

return {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This appears to be the same shape as sanitizeAuthForClient in action-bodies.ts. Is there a way to abstract them?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread CLAUDE.md
@@ -0,0 +1,214 @@
# CLAUDE.md
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably low risk but we can add an .npmignore or update files in package.json to exclude this from bundling to npm.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@nicknisi nicknisi merged commit df41d22 into main May 1, 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

Development

Successfully merging this pull request may close these issues.

AuthKitProvider /client entry pulls @workos-inc/node into the dev browser bundle, breaking Vite dev server

2 participants