@@ -26,6 +26,113 @@ import {
2626 buildAdminPluginSchema ,
2727} from './auth-schema-config.js' ;
2828
29+ /**
30+ * Detect WebContainer (StackBlitz) environment.
31+ *
32+ * WebContainer reports itself as Node.js but runs inside a browser. Several
33+ * Node APIs are polyfilled with significant behavioural differences — most
34+ * notably `node:async_hooks.AsyncLocalStorage`, whose `run()` does NOT
35+ * propagate the store across `await` boundaries the way Node's native
36+ * implementation does.
37+ */
38+ function isWebContainerRuntime ( ) : boolean {
39+ if ( typeof globalThis === 'undefined' ) return false ;
40+ const proc = ( globalThis as any ) . process ;
41+ return (
42+ Boolean ( proc ?. versions ?. webcontainer ) ||
43+ Boolean ( proc ?. env ?. SHELL ?. includes ?.( 'jsh' ) ) ||
44+ Boolean ( proc ?. env ?. STACKBLITZ )
45+ ) ;
46+ }
47+
48+ /**
49+ * Synchronous AsyncLocalStorage polyfill compatible with better-auth's
50+ * `requestStateAsyncStorage` slot.
51+ *
52+ * Behaviour:
53+ * - `run(store, fn)` sets the current store synchronously before invoking
54+ * `fn` and restores the previous store after `fn` (and any promise it
55+ * returns) settles.
56+ * - `getStore()` returns the current store.
57+ *
58+ * Why a polyfill is needed in WebContainer:
59+ * - WebContainer's `node:async_hooks` does not propagate ALS context through
60+ * `await`, so better-auth's `runWithRequestState(map, () => handler(req))`
61+ * wrap loses the store as soon as the call chain awaits anything (e.g.
62+ * the inner `customSession` → `getSession()` call). All endpoints that
63+ * read request-state via `defineRequestState()` then throw
64+ * "No request state found".
65+ *
66+ * Single-flight caveat:
67+ * - This polyfill is process-global, not async-context-local. In a real
68+ * server it could leak state across concurrent requests. That risk is
69+ * acceptable here because:
70+ * 1) It is only installed when WebContainer is detected (dev / preview
71+ * sandboxes that handle one request at a time).
72+ * 2) Each request still wraps the entire handler in `runWithRequestState`
73+ * with a fresh WeakMap, so the in-flight request always sees its own
74+ * store as long as nothing else mutates the slot mid-flight.
75+ */
76+ class WebContainerRequestStateAsyncLocalStorage < T > {
77+ private current : T | undefined = undefined ;
78+
79+ run < R > ( store : T , fn : ( ) => R ) : R {
80+ const prev = this . current ;
81+ this . current = store ;
82+ try {
83+ const result = fn ( ) as unknown ;
84+ if ( result && typeof ( result as Promise < unknown > ) . then === 'function' ) {
85+ return ( result as Promise < unknown > ) . finally ( ( ) => {
86+ this . current = prev ;
87+ } ) as unknown as R ;
88+ }
89+ this . current = prev ;
90+ return result as R ;
91+ } catch ( err ) {
92+ this . current = prev ;
93+ throw err ;
94+ }
95+ }
96+
97+ getStore ( ) : T | undefined {
98+ return this . current ;
99+ }
100+ }
101+
102+ /**
103+ * Pre-populate better-auth's global `requestStateAsyncStorage` slot with the
104+ * synchronous polyfill when running inside WebContainer.
105+ *
106+ * Better-auth caches its AsyncLocalStorage instance on
107+ * `globalThis[Symbol.for('better-auth:global')].context.requestStateAsyncStorage`
108+ * the first time `ensureAsyncStorage()` runs (see
109+ * `@better-auth/core/dist/context/request-state.mjs`). By seeding that slot
110+ * BEFORE any better-auth code touches it, every call to
111+ * `runWithRequestState` / `getCurrentRequestState` — including the
112+ * `@better-auth/core` copy that `plugin-auth` imports directly and the copy
113+ * bundled with `better-auth` itself — share the same working polyfill.
114+ *
115+ * Outside WebContainer this is a no-op so production deployments keep
116+ * Node's native AsyncLocalStorage.
117+ */
118+ function installWebContainerRequestStatePolyfill ( ) : void {
119+ if ( ! isWebContainerRuntime ( ) ) return ;
120+ const sym = Symbol . for ( 'better-auth:global' ) ;
121+ const g = globalThis as any ;
122+ if ( ! g [ sym ] ) {
123+ g [ sym ] = { version : '0.0.0-polyfill' , epoch : 0 , context : { } } ;
124+ }
125+ if ( ! g [ sym ] . context ) g [ sym ] . context = { } ;
126+ if ( ! g [ sym ] . context . requestStateAsyncStorage ) {
127+ g [ sym ] . context . requestStateAsyncStorage = new WebContainerRequestStateAsyncLocalStorage ( ) ;
128+ // eslint-disable-next-line no-console
129+ console . warn (
130+ '[AuthManager] WebContainer detected: installed synchronous request-state polyfill ' +
131+ '(node:async_hooks AsyncLocalStorage does not propagate context across await in WebContainer).' ,
132+ ) ;
133+ }
134+ }
135+
29136/**
30137 * Extended options for AuthManager
31138 */
@@ -130,6 +237,12 @@ export class AuthManager {
130237 constructor ( config : AuthManagerOptions ) {
131238 this . config = config ;
132239
240+ // WebContainer (StackBlitz) compatibility — install a synchronous
241+ // AsyncLocalStorage polyfill for better-auth's request-state global
242+ // BEFORE better-auth ever instantiates its own. See the helper for the
243+ // full rationale.
244+ installWebContainerRequestStatePolyfill ( ) ;
245+
133246 // Use provided auth instance
134247 if ( config . authInstance ) {
135248 this . auth = config . authInstance ;
@@ -384,12 +497,7 @@ export class AuthManager {
384497 private async resolvePasswordHasher ( ) : Promise <
385498 { hash : ( password : string ) => Promise < string > ; verify : ( args : { hash : string ; password : string } ) => Promise < boolean > } | undefined
386499 > {
387- const isWebContainer =
388- typeof globalThis !== 'undefined' &&
389- ( Boolean ( ( globalThis as any ) . process ?. versions ?. webcontainer ) ||
390- Boolean ( ( globalThis as any ) . process ?. env ?. SHELL ?. includes ?.( 'jsh' ) ) ||
391- Boolean ( ( globalThis as any ) . process ?. env ?. STACKBLITZ ) ) ;
392- if ( ! isWebContainer ) return undefined ;
500+ if ( ! isWebContainerRuntime ( ) ) return undefined ;
393501 try {
394502 const { scryptAsync } = await import ( '@noble/hashes/scrypt.js' ) ;
395503 const PARAMS = { N : 16384 , r : 16 , p : 1 , dkLen : 64 , maxmem : 128 * 16384 * 16 * 2 } as const ;
0 commit comments