fix: lazy-import @workos/authkit-session in authkit-loader#93
Conversation
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
| if (!authkitInstance) { | ||
| const { createAuthService } = await import('@workos/authkit-session'); | ||
| const { TanStackStartCookieSessionStorage } = await import('./storage.js'); | ||
| authkitInstance = createAuthService({ | ||
| sessionStorageFactory: (config) => new TanStackStartCookieSessionStorage(config), | ||
| }); |
There was a problem hiding this comment.
🟡 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
Greptile SummaryThis PR eliminates the last static import of
Confidence Score: 3/5The core bundling fix is correct, but The dynamic-import conversion in src/server/authkit-loader.ts — the singleton initialisation race is the only change that introduces new runtime behaviour risk. Important Files Changed
|
| @@ -23,10 +22,12 @@ export async function getAuthkit(): Promise<AuthService<Request, Response>> { | |||
| } | |||
There was a problem hiding this comment.
Concurrent-initialization race in
getAuthkit singleton
Converting to dynamic imports introduced two await suspension points between the !authkitInstance guard and the assignment. If two requests arrive before the instance is ready, both pass the guard, each independently awaits the imports, and each calls createAuthService(...). The last write wins for future callers, but the two in-flight callers receive different objects. The old static-import version had no await between guard and assignment, so it was inherently race-free.
Store the promise instead of the resolved value so concurrent callers await the same work.
| let authkitInstancePromise: Promise<AuthService<Request, Response>> | undefined; | |
| export async function getAuthkit(): Promise<AuthService<Request, Response>> { | |
| if (!authkitInstancePromise) { | |
| authkitInstancePromise = (async () => { | |
| const { createAuthService } = await import('@workos/authkit-session'); | |
| const { TanStackStartCookieSessionStorage } = await import('./storage.js'); | |
| return createAuthService({ | |
| sessionStorageFactory: (config) => new TanStackStartCookieSessionStorage(config), | |
| }); | |
| })(); | |
| } | |
| return authkitInstancePromise; | |
| } |
Summary
authkit-loader.tsto dynamicawait import(), closing the last remaining static import path from the barrel to server-only dependencies@vitejs/plugin-react6Problem
authkit-loader.tsstatically imports@workos/authkit-session, which chains to@workos-inc/node→eventemitter3. 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:Fix
Convert all
@workos/authkit-sessionand./storage.jsimports inauthkit-loader.tsto dynamicawait import(). This matches the lazy pattern already used inmiddleware.ts,actions.ts, andserver-functions.ts. The functions were alreadyasync, so there is no API change.Test plan
pnpm buildpassespnpm test— 219 tests passpnpm run build:check— no server-side fingerprints in client bundleFixes #82