Skip to content

fix: lazy-import @workos/authkit-session in authkit-loader#93

Merged
nicknisi merged 4 commits into
mainfrom
fix/issue-82-lazy-authkit-loader
Jun 16, 2026
Merged

fix: lazy-import @workos/authkit-session in authkit-loader#93
nicknisi merged 4 commits into
mainfrom
fix/issue-82-lazy-authkit-loader

Conversation

@nicknisi

Copy link
Copy Markdown
Member

Summary

  • Converts static imports in authkit-loader.ts to dynamic await import(), closing the last remaining static import path from the barrel to server-only dependencies
  • Upgrades example to Vite 8, TypeScript 6, @vitejs/plugin-react 6

Problem

authkit-loader.ts statically imports @workos/authkit-session, which chains to @workos-inc/nodeeventemitter3. This module is re-exported from the barrel (server/index.ts), placing it in the client module graph. Under certain Vite configurations, the dep optimizer pre-bundles this for the client, causing:

SyntaxError: The requested module 'eventemitter3/index.js'
does not provide an export named 'default'

Fix

Convert all @workos/authkit-session and ./storage.js imports in authkit-loader.ts to dynamic await import(). This matches the lazy pattern already used in middleware.ts, actions.ts, and server-functions.ts. The functions were already async, so there is no API change.

Test plan

Fixes #82

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

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.

Devin Review found 1 potential issue.

View 3 additional findings in Devin Review.

Open in Devin Review

Comment thread src/server/authkit-loader.ts Outdated
Comment on lines 14 to 19
if (!authkitInstance) {
const { createAuthService } = await import('@workos/authkit-session');
const { TanStackStartCookieSessionStorage } = await import('./storage.js');
authkitInstance = createAuthService({
sessionStorageFactory: (config) => new TanStackStartCookieSessionStorage(config),
});

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.

🟡 Race condition in getAuthkit() singleton due to async gap between check and set

The conversion from static imports to dynamic await import() inside getAuthkit() introduces a race condition in the singleton initialization. Previously, the check (if (!authkitInstance)) and the assignment (authkitInstance = createAuthService(...)) were synchronous — no other code could interleave. Now, the two await import(...) calls at lines 15–16 yield execution back to the event loop, so concurrent callers can pass the !authkitInstance guard before the first caller sets the variable. This results in multiple AuthService instances being created, with the last one overwriting authkitInstance while earlier callers hold references to discarded instances.

The standard fix is to cache the initialization promise rather than the resolved result, ensuring all concurrent callers share the same initialization.

(Refers to lines 14-21)

Prompt for agents
The singleton pattern in getAuthkit() is broken by the introduction of async dynamic imports between the guard check and the assignment. In the original code, createAuthService and TanStackStartCookieSessionStorage were statically imported, so the if-check and assignment to authkitInstance were synchronous and atomic within a single microtask. Now, the two await import() calls create yield points where other callers can enter the same block.

Fix: Replace the instance cache with a promise cache. Instead of caching authkitInstance (the resolved AuthService), cache the promise of creating it. This way, all concurrent callers await the same promise.

In src/server/authkit-loader.ts, change:
  let authkitInstance: AuthService<Request, Response> | undefined;
to:
  let authkitPromise: Promise<AuthService<Request, Response>> | undefined;

And change getAuthkit() to:
  export function getAuthkit(): Promise<AuthService<Request, Response>> {
    if (!authkitPromise) {
      authkitPromise = (async () => {
        const { createAuthService } = await import('@workos/authkit-session');
        const { TanStackStartCookieSessionStorage } = await import('./storage.js');
        return createAuthService({
          sessionStorageFactory: (config) => new TanStackStartCookieSessionStorage(config),
        });
      })();
    }
    return authkitPromise;
  }

This ensures the promise is captured synchronously (no yield between the check and the assignment), and all callers share the same initialization.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@greptile-apps

greptile-apps Bot commented May 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes the eventemitter3 SyntaxError (#82) by converting all static imports of @workos/authkit-session and ./storage.js in authkit-loader.ts to dynamic await import(), preventing server-only dependencies from entering the client bundle via the barrel export. It also upgrades the example app to Vite 8, TypeScript 6, and @vitejs/plugin-react 6.

  • Singleton pattern hardened: authkitInstance (resolved value) is replaced with authkitInstancePromise (the promise itself), so concurrent callers share one initialization and the previously reported race condition is resolved. A .catch() handler resets the promise to undefined on failure, enabling transparent retry.
  • Tests extended: authkit.spec.ts gains per-test module isolation via vi.resetModules() and two new cases that explicitly verify deduplication of concurrent calls and retry after a failed initialization.
  • Example-only dependency bumps: Vite 7→8, TypeScript 5→6, @vitejs/plugin-react 5→6 apply only to example/; the published library's peer deps are unchanged.

Confidence Score: 5/5

Safe to merge — the change is narrowly scoped, correctly addresses the client-bundle contamination, and the reworked singleton is race-free with retry support proven by new tests.

The promise is stored before any await, so concurrent callers always share one in-flight initialization. The error path resets the singleton and re-throws, letting the next caller retry cleanly. Both behaviours are explicitly exercised by the updated test suite. The example dependency upgrades are isolated to example/ and do not affect the published package.

No files require special attention.

Important Files Changed

Filename Overview
src/server/authkit-loader.ts Converts static imports to dynamic await import() to break the server-only dep chain; replaces the resolved-value singleton with a promise-based singleton that properly handles concurrent callers and retry on failure.
src/server/authkit.spec.ts Completely rewritten tests: uses vi.resetModules() + dynamic imports per-test for isolation, and adds two new regression cases covering concurrent-caller deduplication and retry after initialization failure.
example/package.json Bumps example-only dev dependencies: Vite 7→8, TypeScript 5→6, @vitejs/plugin-react 5→6. Does not affect the published library.
pnpm-lock.yaml Lock file regenerated to reflect example workspace upgrades; rolldown replaces rollup as Vite 8's bundler, and Babel JSX transform plugins are dropped in favour of the new @vitejs/plugin-react 6 approach.

Reviews (4): Last reviewed commit: "fix: reset cached authkit promise on ini..." | Re-trigger Greptile

Comment thread src/server/authkit-loader.ts Outdated
@50BytesOfJohn

Copy link
Copy Markdown

Hello, any ETA on this one?

@nicknisi nicknisi force-pushed the fix/issue-82-lazy-authkit-loader branch from 1bea0f7 to 74bcc1c Compare June 15, 2026 16:48
@nicknisi nicknisi requested a review from gjtorikian June 15, 2026 18:08
nicknisi added 4 commits June 16, 2026 12:34
Reproduces #82 — on Vite 8 the dep optimizer pre-bundles
@workos/authkit-session (including eventemitter3) for the client because
authkit-loader.ts statically imports it and is re-exported from the
barrel.
… client bundle leak

authkit-loader.ts statically imported @workos/authkit-session, which
pulled @workos-inc/node → eventemitter3 into the client module graph
via the barrel re-export in server/index.ts. On Vite 8 the dep
optimizer eagerly pre-bundles this for the client, causing:

  SyntaxError: The requested module 'eventemitter3/index.js' does not
  provide an export named 'default'

Convert all @workos/authkit-session and ./storage.js imports in
authkit-loader.ts to dynamic await import(), matching the lazy pattern
already used in middleware.ts, actions.ts, and server-functions.ts.
The functions were already async so there is no API change.

Also adds CSRF middleware to the example per TanStack Start's new
requirement.

Fixes #82
The Promise singleton introduced in 74bcc1c caches the initialization
promise. If createAuthService() rejected, the rejected promise stayed
cached permanently, so every subsequent getAuthkit() call returned the
same poisoned promise and the app could not recover without a restart.

Attach a .catch that clears authkitInstancePromise on failure and
re-throws, so the current caller still sees the error while the next
call re-attempts initialization. Add a test covering retry-after-failure.
@nicknisi nicknisi force-pushed the fix/issue-82-lazy-authkit-loader branch from b0abc01 to 6c070ce Compare June 16, 2026 18:37
@nicknisi nicknisi merged commit c66a2bc into main Jun 16, 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

3 participants