Skip to content

Commit 0ef0cd7

Browse files
authored
Merge pull request #1345 from objectstack-ai/copilot/fix-registration-error
2 parents 2413331 + de239ef commit 0ef0cd7

3 files changed

Lines changed: 245 additions & 6 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
Fix WebContainer (StackBlitz) sign-up / sign-in failing with
6+
`INTERNAL_SERVER_ERROR: No request state found. Please make sure you are
7+
calling this function within a `runWithRequestState` callback.`
8+
9+
WebContainer reports itself as Node.js but its `node:async_hooks`
10+
implementation does not propagate `AsyncLocalStorage` context across
11+
`await` boundaries. As a result, better-auth's `runWithRequestState`
12+
wrap installed by `handleRequest` was lost as soon as the inner
13+
`customSession` → `getSession()` call chain awaited anything, and every
14+
endpoint that reads request state (e.g. `should-session-refresh`,
15+
`oauth`) threw "No request state found".
16+
17+
`AuthManager` now detects WebContainer and pre-populates better-auth's
18+
global `requestStateAsyncStorage` slot with a synchronous polyfill
19+
before better-auth instantiates its own. The polyfill correctly
20+
propagates the store through awaited promises within a single
21+
`run()` call, which is sufficient for WebContainer's single-flight
22+
dev server. Production environments (real Node, Bun, edge runtimes)
23+
continue to use the native `AsyncLocalStorage` and are unaffected.

‎packages/plugins/plugin-auth/src/auth-manager.test.ts‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1032,4 +1032,112 @@ describe('AuthManager', () => {
10321032
});
10331033
});
10341034
});
1035+
1036+
describe('WebContainer request-state polyfill', () => {
1037+
const sym = Symbol.for('better-auth:global');
1038+
1039+
beforeEach(() => {
1040+
// Reset better-auth global between tests so installation logic re-runs.
1041+
delete (globalThis as any)[sym];
1042+
});
1043+
1044+
afterEach(() => {
1045+
delete (globalThis as any)[sym];
1046+
delete (globalThis as any).process?.env?.STACKBLITZ;
1047+
});
1048+
1049+
it('does NOT install a polyfill outside WebContainer', () => {
1050+
// Default test env (Node + Vitest) is not WebContainer.
1051+
delete (globalThis as any).process?.env?.STACKBLITZ;
1052+
const proc = (globalThis as any).process;
1053+
const prevShell = proc?.env?.SHELL;
1054+
const prevWc = proc?.versions?.webcontainer;
1055+
if (proc?.env) delete proc.env.SHELL;
1056+
if (proc?.versions) delete proc.versions.webcontainer;
1057+
1058+
try {
1059+
new AuthManager({
1060+
secret: 'test-secret-at-least-32-chars-long',
1061+
baseUrl: 'http://localhost:3000',
1062+
});
1063+
expect((globalThis as any)[sym]?.context?.requestStateAsyncStorage).toBeUndefined();
1064+
} finally {
1065+
if (proc?.env && prevShell !== undefined) proc.env.SHELL = prevShell;
1066+
if (proc?.versions && prevWc !== undefined) proc.versions.webcontainer = prevWc;
1067+
}
1068+
});
1069+
1070+
it('installs a synchronous polyfill when WebContainer is detected', () => {
1071+
(globalThis as any).process = (globalThis as any).process || {};
1072+
(globalThis as any).process.env = (globalThis as any).process.env || {};
1073+
(globalThis as any).process.env.STACKBLITZ = '1';
1074+
1075+
new AuthManager({
1076+
secret: 'test-secret-at-least-32-chars-long',
1077+
baseUrl: 'http://localhost:3000',
1078+
});
1079+
1080+
const als = (globalThis as any)[sym]?.context?.requestStateAsyncStorage;
1081+
expect(als).toBeDefined();
1082+
expect(typeof als.run).toBe('function');
1083+
expect(typeof als.getStore).toBe('function');
1084+
});
1085+
1086+
it('polyfill propagates store across awaits within a single run() call', async () => {
1087+
(globalThis as any).process = (globalThis as any).process || {};
1088+
(globalThis as any).process.env = (globalThis as any).process.env || {};
1089+
(globalThis as any).process.env.STACKBLITZ = '1';
1090+
1091+
new AuthManager({
1092+
secret: 'test-secret-at-least-32-chars-long',
1093+
baseUrl: 'http://localhost:3000',
1094+
});
1095+
1096+
const als = (globalThis as any)[sym].context.requestStateAsyncStorage;
1097+
const store = new WeakMap();
1098+
1099+
const inner = async () => {
1100+
await Promise.resolve();
1101+
await Promise.resolve();
1102+
return als.getStore();
1103+
};
1104+
1105+
const result = await als.run(store, () => inner());
1106+
expect(result).toBe(store);
1107+
});
1108+
1109+
it('polyfill restores previous store after run() settles, even on error', async () => {
1110+
(globalThis as any).process = (globalThis as any).process || {};
1111+
(globalThis as any).process.env = (globalThis as any).process.env || {};
1112+
(globalThis as any).process.env.STACKBLITZ = '1';
1113+
1114+
new AuthManager({
1115+
secret: 'test-secret-at-least-32-chars-long',
1116+
baseUrl: 'http://localhost:3000',
1117+
});
1118+
1119+
const als = (globalThis as any)[sym].context.requestStateAsyncStorage;
1120+
expect(als.getStore()).toBeUndefined();
1121+
1122+
const outer = new WeakMap();
1123+
const inner = new WeakMap();
1124+
await als.run(outer, async () => {
1125+
expect(als.getStore()).toBe(outer);
1126+
await als.run(inner, async () => {
1127+
await Promise.resolve();
1128+
expect(als.getStore()).toBe(inner);
1129+
});
1130+
expect(als.getStore()).toBe(outer);
1131+
});
1132+
expect(als.getStore()).toBeUndefined();
1133+
1134+
await expect(
1135+
als.run(outer, async () => {
1136+
await Promise.resolve();
1137+
throw new Error('boom');
1138+
}),
1139+
).rejects.toThrow('boom');
1140+
expect(als.getStore()).toBeUndefined();
1141+
});
1142+
});
10351143
});

‎packages/plugins/plugin-auth/src/auth-manager.ts‎

Lines changed: 114 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)