Skip to content

fix: eliminate middleware bundle leak via lazy dynamic import#85

Merged
nicknisi merged 4 commits into
mainfrom
fix/middleware-bundle-leak
May 15, 2026
Merged

fix: eliminate middleware bundle leak via lazy dynamic import#85
nicknisi merged 4 commits into
mainfrom
fix/middleware-bundle-leak

Conversation

@nicknisi
Copy link
Copy Markdown
Member

@nicknisi nicknisi commented May 14, 2026

Summary

Fixes #82SyntaxError: eventemitter3 does not provide an export named 'default' when authkitMiddleware() is added to start.ts in Vite dev.

Same class of leak as #72 (fixed in #75), but in a different code path:

  • fix: eliminate server-side bundle leak via lazy handler bodies #75 fixed: actions.ts / server-functions.tscreateServerFn handlers get rewritten to createClientRpc stubs by the TanStack compiler, so dynamic imports in handler bodies are dead-code-eliminated from the client graph.
  • This PR fixes: middleware.tscreateMiddleware().server() callbacks get no such rewrite. The static import of authkit-loader.ts pulled @workos/authkit-session@workos-inc/nodeeventemitter3 (CJS) into the browser module graph during Vite dev.

What changed

  • Lazy middleware body: middleware.ts is now a thin shell that await import()s middleware-body.ts inside the .server() callback. The dynamic import is unreachable from the client graph.
  • oxlint guard: Added middleware.ts to the no-restricted-imports override and ./middleware-body* to the forbidden patterns.
  • Bundle fingerprint: Added eventemitter3 to check-bundle-leak.sh.
  • Test split: Shell delegation tests in middleware.spec.ts, logic tests in middleware-body.spec.ts.

Test plan

  • pnpm test — 219 tests pass (18 files)
  • pnpm build — passes (includes typecheck)
  • pnpm lint — 0 warnings, 0 errors
  • pnpm run build:check — no server-side fingerprints in client bundle
  • oxlint negative test: adding import { getAuthkit } from './authkit-loader.js' to middleware.ts → lint error

Open in Devin Review

nicknisi added 2 commits May 14, 2026 11:14
`authkitMiddleware()` statically imported `authkit-loader.ts`, which
pulled `@workos/authkit-session` → `@workos-inc/node` → `eventemitter3`
into the client module graph during Vite dev. Unlike `createServerFn`
handlers (which the TanStack compiler rewrites to `createClientRpc`
stubs), `createMiddleware().server()` callbacks get no such rewrite.

Apply the same lazy-body pattern from #75: middleware.ts is now a thin
shell that `await import()`s middleware-body.ts inside the `.server()`
callback. The dynamic import is dead-code-eliminated from the client
graph.

Also:
- Add middleware.ts to oxlint no-restricted-imports override
- Add eventemitter3 to bundle-leak fingerprints
- Split tests: shell delegation in middleware.spec.ts, logic in
  middleware-body.spec.ts

Resolves #82.
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 14, 2026

Greptile Summary

This PR fixes a Vite dev-mode SyntaxError caused by server-only CJS modules (eventemitter3 via @workos-inc/node) leaking into the browser bundle through middleware.ts. The fix mirrors the pattern already applied to actions.ts and server-functions.ts (#75): the middleware's .server() callback now does a lazy await import('./middleware-body.js') so the server-side import graph is unreachable by Vite's client bundler.

  • middleware.ts is now a thin shell; all logic is extracted to middleware-body.ts, which is loaded via a dynamic import inside the .server() callback.
  • middleware-body.ts also improves the refreshed-session cookie handling by switching from headers.get('Set-Cookie') (which concatenates multiple values into one string) to headers.getSetCookie() (which returns an array), fixing a pre-existing multi-cookie bug.
  • Lint guards (oxlintrc.json) and bundle-leak fingerprints (check-bundle-leak.sh) are updated to catch regressions; tests are split accordingly between middleware.spec.ts (delegation) and middleware-body.spec.ts (logic).

Confidence Score: 5/5

Safe to merge — the lazy dynamic import correctly severs the server-only import chain from the Vite client graph, and all existing tests pass with the refactored structure.

The change is a straightforward extraction of middleware logic into a lazily-loaded module. The getSetCookie() improvement over the old headers.get('Set-Cookie') is a correctness fix for multi-cookie responses. The previously flagged mock shape issue in the session-refresh test has been addressed in the new middleware-body.spec.ts. No regressions or new defects are introduced.

No files require special attention.

Important Files Changed

Filename Overview
src/server/middleware.ts Reduced to a thin shell that delegates to middleware-body.ts via dynamic import — the core fix for the bundle leak.
src/server/middleware-body.ts Extracted middleware logic with improved Set-Cookie handling via getSetCookie(); configValidated singleton works correctly across requests.
src/server/middleware-body.spec.ts New spec file with comprehensive logic tests; saveSession mock shape is now correct (uses { response: new Response(...) }) compared to the old broken mock in the deleted middleware.spec.ts tests.
src/server/middleware.spec.ts Slimmed to two delegation tests — verifies that the .server() callback invokes middlewareBody with the correct args and options.
.oxlintrc.json middleware.ts added to the no-restricted-imports override; middleware-body* added to the forbidden import patterns to prevent future regressions.
scripts/check-bundle-leak.sh eventemitter3 added to the list of bundle-leak fingerprints checked by the script.
src/server/types.ts AuthKitMiddlewareOptions interface moved here from middleware.ts; re-exported from middleware.ts to preserve public API.
.github/ISSUE_TEMPLATE/bug_report.md Version labels corrected from authkit-nextjs/Next.js to authkit-tanstack-start/Tanstack Start.

Sequence Diagram

sequenceDiagram
    participant Vite as Vite Client Bundle
    participant MW as middleware.ts
    participant MWB as middleware-body.ts
    participant AL as authkit-loader.ts
    participant WOS as @workos-inc/node

    Note over Vite,WOS: Before this PR (bundle leak)
    Vite->>MW: static import
    MW->>AL: static import
    AL->>WOS: static import (pulls eventemitter3 CJS)
    Note over Vite: ❌ eventemitter3 in browser bundle

    Note over Vite,WOS: After this PR (fixed)
    Vite->>MW: static import (thin shell only)
    Note over MW: .server() callback → unreachable by client
    MW-->>MWB: await import() [runtime, server-only]
    MWB->>AL: static import
    AL->>WOS: static import
    Note over Vite: ✅ eventemitter3 stays server-side
Loading

Reviews (3): Last reviewed commit: "fix: address PR review feedback" | Re-trigger Greptile

Comment thread .oxlintrc.json Outdated
Comment on lines +127 to +145
const mockResponse = new Response('OK', { status: 200 });

const args = {
request: mockRequest,
next: vi.fn(async () => ({ response: mockResponse })),
};

await middlewareBody(args);

expect(mockAuthkit.saveSession).toHaveBeenCalledWith(undefined, refreshedData);
});

it('provides correct context shape to downstream handlers', async () => {
const mockAuth = { user: { id: 'user_123' }, sessionId: 'session_123' };
mockAuthkit.withAuth.mockResolvedValue({
auth: mockAuth,
refreshedSessionData: null,
});

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.

P2 saveSession mock shape doesn't match what the production code reads

saveSession is mocked to resolve with { headers: { 'Set-Cookie': '...' } }, but middlewareBody destructures { response: sessionResponse } from that return value (line 34 of middleware-body.ts). Since response is absent from the mock, sessionResponse is always undefined, sessionResponse?.headers.get('Set-Cookie') returns undefined, and the cookie is never appended — so this test never actually verifies that the refreshed-session Set-Cookie reaches the final response. The mock should resolve with { response: new Response('', { headers: { 'Set-Cookie': 'wos-session=new_value; Path=/' } }) } to exercise that path.

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.

Yep. The mock here should be

mockAuthkit.saveSession.mockResolvedValue({
  response: new Response('', {
    headers: { 'Set-Cookie': 'wos-session=new_value; Path=/' },
  }),
});

and then later

  const result = await middlewareBody(args);
  expect(result.response.headers.get('Set-Cookie')).toContain('wos-session=new_value');

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 3b923a1 — corrected the mock to return { response: new Response(...) } and added an assertion that Set-Cookie reaches the final response.

@nicknisi nicknisi requested a review from gjtorikian May 14, 2026 17:36
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: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Comment on lines +127 to +145
const mockResponse = new Response('OK', { status: 200 });

const args = {
request: mockRequest,
next: vi.fn(async () => ({ response: mockResponse })),
};

await middlewareBody(args);

expect(mockAuthkit.saveSession).toHaveBeenCalledWith(undefined, refreshedData);
});

it('provides correct context shape to downstream handlers', async () => {
const mockAuth = { user: { id: 'user_123' }, sessionId: 'session_123' };
mockAuthkit.withAuth.mockResolvedValue({
auth: mockAuth,
refreshedSessionData: null,
});

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.

Yep. The mock here should be

mockAuthkit.saveSession.mockResolvedValue({
  response: new Response('', {
    headers: { 'Set-Cookie': 'wos-session=new_value; Path=/' },
  }),
});

and then later

  const result = await middlewareBody(args);
  expect(result.response.headers.get('Set-Cookie')).toContain('wos-session=new_value');

Comment thread src/server/middleware-body.ts Outdated
@@ -0,0 +1,61 @@
import { getAuthkit, validateConfig, getConfig } from './authkit-loader.js';
import type { AuthKitMiddlewareOptions } from './middleware.js';
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.

AuthKitMiddlewareOptions should be pulled out into a separate file.

middleware-body.ts imports AuthKitMiddlewareOptions from ./middleware.js, while middleware.ts dynamically imports middleware-body.ts on its L38. It's a fragile setup that could re-leak this issue in the future.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Moved AuthKitMiddlewareOptions to src/server/types.ts. middleware.ts re-exports it for public API compatibility, middleware-body.ts imports directly from types — no bidirectional dependency.

Comment thread src/server/middleware-body.ts Outdated
if (refreshedSessionData) {
const { response: sessionResponse } = await authkit.saveSession(undefined, refreshedSessionData);
const setCookieHeader = sessionResponse?.headers.get('Set-Cookie');
if (setCookieHeader) {
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.

Set-Cookie acts a little differently than regular headers. Headers.get() uses header values can be safely comma-joined when there are multiple entries; but Set-Cookie values can legitimately contain commas.

The spec-defined accessor for this is getSetCookie(), which takes this quirk into consideration and returns each cookie as its own array entry. This code should change to:

Suggested change
if (setCookieHeader) {
for (const cookie of sessionResponse?.headers.getSetCookie() ?? []) {
pendingHeaders.append('Set-Cookie', cookie);
}

Appending each cookie individually preserves them as separate Set-Cookie headers on the wire.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Switched to getSetCookie() — iterates each cookie individually via for...of and appends separately.

- Move AuthKitMiddlewareOptions to shared types.ts to break
  bidirectional dependency between middleware.ts and middleware-body.ts
- Use getSetCookie() instead of get('Set-Cookie') to correctly handle
  cookies containing commas
- Fix saveSession mock shape to match production code (destructures
  { response } not { headers }) and assert Set-Cookie reaches response
- Update oxlint error message to include middleware-body.ts as target
@nicknisi nicknisi merged commit b9a9592 into main May 15, 2026
5 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.

Adding authkitMiddleware() causes the Vite dev client to crash

2 participants