Skip to content

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

Open
nicknisi wants to merge 2 commits into
mainfrom
fix/issue-82-lazy-authkit-loader
Open

fix: lazy-import @workos/authkit-session in authkit-loader#93
nicknisi wants to merge 2 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

nicknisi added 2 commits May 18, 2026 14:20
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
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 found 1 potential issue.

View 3 additional findings in Devin Review.

Open in Devin Review

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

greptile-apps Bot commented May 18, 2026

Greptile Summary

This PR eliminates the last static import of @workos/authkit-session (and its transitive chain to eventemitter3) from authkit-loader.ts by converting them to await import(), matching the lazy pattern already in middleware.ts and actions.ts. The example is also updated to Vite 8, TypeScript 6, and newer TanStack/@vitejs/plugin-react versions, and gains a CSRF middleware scoped to serverFn handlers.

  • src/server/authkit-loader.ts: all @workos/authkit-session and ./storage.js imports converted to dynamic await import(), with the singleton guarded by a module-level variable.
  • example/src/start.ts: createCsrfMiddleware added before authkitMiddleware in the request pipeline.
  • example/package.json / package.json: dependency version bumps for Vite 8, TypeScript 6, and TanStack 1.168–1.170.

Confidence Score: 3/5

The core bundling fix is correct, but getAuthkit now has an async gap in its singleton guard that allows concurrent callers to each create a separate AuthService instance before the variable is populated.

The dynamic-import conversion in authkit-loader.ts introduces two await suspension points inside the if (!authkitInstance) guard. In the original static-import version the assignment was synchronous and therefore race-free; the new version is not. Under concurrent server load, each in-flight caller constructs its own AuthService. The fix is straightforward — store the initialisation promise rather than the resolved value — but until it lands this defect affects the primary singleton relied on by every auth operation in the package.

src/server/authkit-loader.ts — the singleton initialisation race is the only change that introduces new runtime behaviour risk.

Important Files Changed

Filename Overview
src/server/authkit-loader.ts Static imports converted to dynamic await import() to fix client-bundle contamination; introduces a TOCTOU race in the getAuthkit singleton — concurrent callers can each create separate AuthService instances before the guard variable is set.
example/src/start.ts Adds createCsrfMiddleware scoped to serverFn handlers and inserts it before authkitMiddleware in the request pipeline; straightforward example update.
example/package.json Bumps example deps: Vite 7→8, TypeScript 5→6, @vitejs/plugin-react 5→6, TanStack packages to latest minor versions.
package.json Root devDependencies: TanStack packages bumped from ^1.154.8 to ^1.168.x1.170.x range.
pnpm-lock.yaml Lockfile regenerated to reflect all dependency version bumps; no unexpected additions.

Sequence Diagram

sequenceDiagram
    participant Client as Client Bundle
    participant Barrel as src/index.ts
    participant Loader as authkit-loader.ts
    participant Session as @workos/authkit-session (server-only)

    Note over Client,Session: Before PR — static import chain
    Client->>Barrel: import getAuthkit
    Barrel->>Loader: static import
    Loader->>Session: static import (pulls eventemitter3 into client)

    Note over Client,Session: After PR — lazy import chain
    Client->>Barrel: import getAuthkit
    Barrel->>Loader: static import (safe — no top-level server code)
    Loader-->>Session: await import() only at call-time (server only)
Loading

Reviews (1): Last reviewed commit: "fix: lazy-import @workos/authkit-session..." | Re-trigger Greptile

Comment on lines 11 to 22
@@ -23,10 +22,12 @@ export async function getAuthkit(): Promise<AuthService<Request, Response>> {
}
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.

P1 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.

Suggested change
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;
}

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

1 participant