Skip to content

fix(context): share AsyncLocalStorage across ESM+CJS variants via globalThis#1779

Open
bellcoTech wants to merge 1 commit into
cedarjs:mainfrom
bellcoTech:fix-context-dual-package-store
Open

fix(context): share AsyncLocalStorage across ESM+CJS variants via globalThis#1779
bellcoTech wants to merge 1 commit into
cedarjs:mainfrom
bellcoTech:fix-context-dual-package-store

Conversation

@bellcoTech
Copy link
Copy Markdown
Contributor

@cedarjs/context ships dual ESM+CJS and Node resolves the variant per caller. In a mixed-variant load (e.g. an ESM user-api package alongside cedar tooling that loads @cedarjs/api-server's CJS entry), each variant is a separate module instance with its own module-scoped state.

The AsyncLocalStorage singleton previously lived in a module-scoped let, so each variant had its own store. The framework's request handler writes context.currentUser into the variant it loaded; user code reading context.currentUser sees a different variant's empty store. Every requireAuth-gated query then fails with "You do not have permission to do that" even though getCurrentUser ran successfully — the user written to the framework's CJS store is invisible to user code reading from the ESM store (or vice versa).

Puts the singleton on globalThis under a Symbol.for key so both variants resolve to the same slot and share one store. Key is namespaced (__cedarjs_context_storage__).

No behaviour change for single-variant projects — they had one module instance and one store before, they have one module instance and one store after, just on globalThis instead of a module-scoped let.

The package doesn't have a test suite today, so I haven't added one. Happy to wire up vitest and add coverage if you'd like.

…balThis

`@cedarjs/context` ships dual ESM+CJS and Node resolves the variant per
caller. In a mixed-variant load (e.g. an ESM user-api package alongside
cedar tooling that loads `@cedarjs/api-server`'s CJS entry), each
variant is a separate module instance with its own module-scoped state.

The `AsyncLocalStorage` singleton previously lived in a module-scoped
`let`, so each variant had its own store. The framework's request
handler would write `context.currentUser` into the variant it loaded
while user code reading `context.currentUser` saw the other variant's
empty store — a textbook dual-package hazard. Concretely this surfaces
as every `requireAuth`-gated query failing with "You do not have
permission to do that" even after `getCurrentUser` runs successfully,
because the user written to the framework's CJS store is invisible to
the user code reading from the ESM store (or vice versa).

Stashing the singleton on `globalThis` under a `Symbol.for` key bridges
the two variants: both resolve the same key to the same object and so
share the store regardless of which variant either loaded. The key is
namespaced (`__cedarjs_context_storage__`) to avoid any chance of
collision.

No behaviour change for single-variant projects — they previously got
one module instance and one store, and they still get one module
instance and one store, just rooted on `globalThis` instead of in a
module-scoped `let`. The fix is purely defensive against the
dual-package case.
@netlify
Copy link
Copy Markdown

netlify Bot commented May 14, 2026

👷 Deploy request for cedarjs pending review.

Visit the deploys page to approve it

Name Link
🔨 Latest commit 65c96a6

@github-actions github-actions Bot added this to the next-release-patch milestone May 14, 2026
@bellcoTech
Copy link
Copy Markdown
Contributor Author

@Tobbe i've done this PR and the other PR for a couple of friction points I had getting to 4.2. This one I couldn't get around, but the other I could so not as important.
I still have an issue deploying 4.2 to Vercel, I'll add an issue as I can't figure out a workaround yet.

@nx-cloud
Copy link
Copy Markdown

nx-cloud Bot commented May 14, 2026

🤖 Nx Cloud AI Fix

Ensure the fix-ci command is configured to always run in your CI pipeline to get automatic fixes in future runs. For more information, please see https://nx.dev/ci/features/self-healing-ci


View your CI Pipeline Execution ↗ for commit 65c96a6

Command Status Duration Result
nx run-many -t build:pack --exclude create-ceda... ✅ Succeeded 2s View ↗
nx run-many -t test --minWorkers=1 --maxWorkers=4 ✅ Succeeded 1m 56s View ↗
nx run-many -t build ✅ Succeeded 5s View ↗
nx run-many -t test:types ✅ Succeeded 7s View ↗

☁️ Nx Cloud last updated this comment at 2026-05-14 13:42:46 UTC

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 14, 2026

Greptile Summary

This PR fixes the dual-package hazard in @cedarjs/context by moving the AsyncLocalStorage singleton from a module-scoped let to globalThis under a Symbol.for key, ensuring the ESM and CJS build variants share the same store instance. The fix is the correct and idiomatic solution for this class of problem.

  • packages/context/src/store.ts: Replaces let CONTEXT_STORAGE with a Symbol.for-keyed globalThis slot guarded by a lazy-init check; adds a ContextStorageGlobal helper type so the symbol-keyed property is typed.
  • The docs/implementation-docs/2026-03-26-cedarjs-project-overview.md description of the context package remains factually correct after this change \u2014 no doc updates are needed.

Confidence Score: 3/5

The runtime fix is correct, but the TypeScript type of the return value may expose callers to a compile error under strict null checks before this can land cleanly.

The ContextStorageGlobal type marks the [STORAGE_KEY] property as optional (?:), so g[STORAGE_KEY] carries an | undefined type. TypeScript control-flow narrowing does not reliably strip undefined from computed symbol-indexed properties on mutable globals, meaning getAsyncStoreInstance() likely infers an AsyncLocalStorage | undefined return type. All three call-sites in context.ts chain .getStore() directly on the return value, which would be a compile error that needs resolving before the PR is merge-ready.

packages/context/src/store.ts — specifically the return statement of getAsyncStoreInstance and whether tsc passes with strict null checks enabled

Important Files Changed

Filename Overview
packages/context/src/store.ts Moves the AsyncLocalStorage singleton from a module-scoped let to globalThis via Symbol.for to solve the ESM+CJS dual-package hazard; return type may include undefined under strict null checks due to optional property typing.

Reviews (1): Last reviewed commit: "fix(context): share AsyncLocalStorage ac..." | Re-trigger Greptile

Comment on lines +39 to 40
return g[STORAGE_KEY]
}
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 The return expression g[STORAGE_KEY] is typed as AsyncLocalStorage<Map<string, GlobalContext>> | undefined because the ContextStorageGlobal type marks the property optional with ?:. With strict: true (and thus strictNullChecks) in the repo's base tsconfig, TypeScript's control flow analysis does not reliably narrow computed symbol-indexed properties on mutable objects like globalThis across an if-assignment boundary — so the inferred return type of getAsyncStoreInstance likely includes undefined. Every caller in context.ts chains .getStore() directly onto the return value without a null check, which would be a compile-time error under strict null checks. A non-null assertion on the return makes the invariant explicit and preserves the original AsyncLocalStorage (never-undefined) return type.

Suggested change
return g[STORAGE_KEY]
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return g[STORAGE_KEY]!
}

@Tobbe
Copy link
Copy Markdown
Member

Tobbe commented May 14, 2026

The context package has been a constant source of ESM+CJS interop issues :(

I thought I had them all fixed, but apparently not.

Can you please explain again how to reproduce the issue?

@bellcoTech
Copy link
Copy Markdown
Contributor Author

Hey @Tobbe

I think I've got a way to reproduce it — looks like context doesn't cross the CJS ↔ ESM line inside @cedarjs/context. Let me know if that lines up.

I first thought it was my own setup — I had installConfig.hoistingLimits: workspaces on the api workspace which gives you an extra copy of @cedarjs/context in api/node_modules. But I pulled that out and the bug was still there. I can also hit it on a brand new yarn create cedar-app --typescript --esm with nothing changed.

Attached a script that scaffolds a fresh ESM project and runs a quick test loading the ESM and CJS variants of @cedarjs/context in the same process. Checks if they share an AsyncLocalStorage. Stock 4.2 → Same instance? false. After patching store.js the same way as this PR → Same instance? true. This matches what I see in my project.

I think the reason a bare template doesn't show it: api/src/lib/auth.ts stubs isAuthenticated() => true and nothing actually reads context.currentUser. As soon as someone runs yarn cedar setup auth <provider> and gets a real requireAuth, it surfaces. Could be wrong about the exact path though — you'd know better than me which bits of cedar load context as CJS in an ESM project.

cedar-context-repro.sh

@Tobbe
Copy link
Copy Markdown
Member

Tobbe commented May 14, 2026

@bellcoTech Did start failing for you between 4.1 and 4.2? Does it only happen with ESM Cedar apps?

const esmStore = await import('@cedarjs/context/dist/store')
const cjsStore = require('@cedarjs/context/dist/store.js')

I understand that two different contexts will be loaded by that code. And I understand that your patch fixes it, and I understand why it fixes it.

But I still don't understand how this happens in an actual Cedar app. Did you really explicitly import @cedarjs/context like its done in your repro script?

@Tobbe
Copy link
Copy Markdown
Member

Tobbe commented May 14, 2026

I tried to have AI reproduce.
At first it created artificial reproductions that reproduced the issue, but were nothing like what would happen in prod.
After forcing it to actually build and actual Cedar app and reproduce, this is what it said:


Here's what I found after doing this properly with the actual Cedar server and curl:

The dual-package hazard doesn't manifest on Node 24 with the current dev server. I set up the exact scenario — CJS-root project, "type": "module" on the api package (so user functions build as ESM), and the api-server comes up via the cedar-server bin. The authenticated GraphQL mutation (createPost with @requireAuth) succeeded with the right cookie.

The reason: Node 24 supports require() of ES modules (the --experimental-require-module became default in Node 23). When the cedar-server bin (from @cedarjs/core) uses createRequire to load the api-server's dist/bin.js (which uses ESM import), Node 24 handles the interop and resolves sub-imports through the ESM module system.
So both the "CJS-loaded" framework code and the ESM user functions end up loading @cedarjs/context/dist/store.js (the ESM variant) — same files, same CONTEXT_STORAGE, no split.

The PR #1779 fix is still valuable, but it's a defensive measure for environments where CJS require() and ESM import() truly go through separate resolution paths — like older Node versions or specific deployment platforms (the PR author mentioned Vercel). The fix (stashing the singleton on globalThis under a Symbol.for key) correctly bridges that gap for any environment where the split does occur. I verified the mechanism is real —require('@cedarjs/context/dist/store') and import(...) on @cedarjs/context/dist/store.js do resolve to different files (dist/cjs/store.js vs dist/store.js) with separate module-scoped state.


It's not that I don't believe you ran into this issue (like I said, I've seen it plenty of times myself). It's just that I don't know how to reproduce properly with the current code

@bellcoTech
Copy link
Copy Markdown
Contributor Author

Sorry for the delay - timezones and all.
That's fine mate, I'm 100% learning myself so there is an element of speculation - not professing to know myself for sure either. I'd prefer to be challenged then get it wrong.
Let me dig further. I'm definitely getting the error without the patch, but also I've changed a lot with the upgrades, especially now trying to go full ESM.
I didn't notice the issue going to 4.1, but I didn't push to ESM as hard before those other patches came out.

@bellcoTech
Copy link
Copy Markdown
Contributor Author

bellcoTech commented May 15, 2026

Used AI to dig further. Here's what it came back with after a verified end-to-end reproduction in a vanilla cedar 4.2 project:


The variant split is controlled by cedar's CLI bin selection. From @cedarjs/cli/dist/commands/dev/devHandler.js around line 211:

if (workspace.includes("api")) {
  const isEsm = rootPackageJson.type === "module"
  const serverWatchCommand = isEsm ? `cedarjs-api-server-watch` : `cedar-api-server-watch`
  const cedarConfigPath = getConfigPath()
  jobs.push({
    name: "api",
    command: [...],
  })
}

The check is purely on the root package.json type field — the api workspace's own type is not considered. The two bin candidates resolve to different files in @cedarjs/api-server/package.json:

"bin": {
  "cedar-server":              "./dist/cjs/bin.js",
  "cedar-api-server-watch":    "./dist/cjs/watch.js",
  "cedarjs-server":            "./dist/bin.js",
  "cedarjs-api-server-watch":  "./dist/watch.js"
}

cedar- prefixed bins → ./dist/cjs/* (CJS); cedarjs- prefixed bins → ./dist/* (ESM).

When root type !== "module"
cedar dev spawns cedar-api-server-watch → loads ./dist/cjs/watch.js (CJS)
Framework chain (@cedarjs/api-server, @cedarjs/graphql-server, @cedarjs/context) resolves through require conditions → CJS variants
setContext({ currentUser }) writes into @cedarjs/context/dist/cjs/store.js's let CONTEXT_STORAGE
Meanwhile, with api/package.json type === "module":

User code in api/src/... (built to api/dist/...) loads as ESM
import { context } from '@cedarjs/context' resolves via the import condition → @cedarjs/context/dist/store.js (ESM)
Different physical file from dist/cjs/store.js, with its own let CONTEXT_STORAGE
context.currentUser reads from the ESM store → empty → ForbiddenError
Probe instrumentation confirmed end-to-end: writes from @cedarjs/graphql-server/dist/cjs/plugins/useRedwoodGlobalContextSetter.js (CJS), reads from api/src/lib/auth.ts@cedarjs/context/dist/store.js (ESM). 13 CJS writes vs 63 ESM reads per auth-gated query cycle.

When root type === "module"
cedar dev spawns cedarjs-api-server-watch./dist/watch.js (ESM)
Entire framework loads ESM, same ESM variant of @cedarjs/context everywhere
Single store, auth works without the patch
Reproduction (verified)

# 1. Scaffold a fresh ESM cedar 4.2 project
yarn create cedar-app --typescript --esm --no-git-init --no-install --yes /tmp/cedar-repro
cd /tmp/cedar-repro && yarn install

# 2. Wire up real auth (any provider — dbAuth is simplest, no external services)
yarn cedar setup auth dbAuth --force --no-webauthn --createUserModel --no-generateAuthPages
yarn cedar prisma migrate dev --name init
yarn cedar generate dbAuth --force --no-webauthn --username-label Email --password-label Password

# 3. Add a Post model + scaffold (auto-gated with @requireAuth)
cat >> api/db/schema.prisma <<'EOF'

model Post {
    id        Int      @id @default(autoincrement())
    title     String
    body      String
    createdAt DateTime @default(now())
}
EOF
yarn cedar prisma migrate dev --name add-post
yarn cedar generate scaffold post --force

# 4. THE TRIGGER: remove "type": "module" from the root package.json
#    (the esm-ts template ships with it; removing simulates a project where
#    root and api workspace types disagree)
node -e "const f='./package.json';const p=require(f);delete p.type;require('fs').writeFileSync(f,JSON.stringify(p,null,2)+'\n')"

# 5. Run dev, open in incognito (avoid any leftover auth cookies)
yarn cedar dev

Then in incognito:

http://localhost:8910/signup — sign up with any email + password
After auto-login, navigate to http://localhost:8910/posts
ForbiddenError / "You don't have permission to do that" — despite being logged in
Restore "type": "module" to root → cedar picks the ESM bin → auth works.

The earlier repro probably stayed single-variant because the test project had root type: module (the esm-ts template default), so cedar picked the ESM bin chain and there was no split to begin with. The CJS-bin path only activates when root and api workspace types disagree.

Scope
The bug surfaces specifically on cedar projects where root and api workspace type disagree — anyone partway through a CJS → ESM migration (e.g. 4.1 → 4.2 ESM). The patch in this PR unifies the variant store regardless of which bin cedar picks, so it's defensive coverage for that intermediate state.

Worth considering on cedar's side: a CLI warning when the root/api type mismatch is detected, or a docs note that the two should agree.


So it looks like I missed a step with the root package.json type assuming the API package.json would suffice. Took a bit to track down but this all lines up with my project and I was able to reproduce.

@Tobbe
Copy link
Copy Markdown
Member

Tobbe commented May 15, 2026

So it looks like I missed a step with the root package.json type assuming the API package.json would suffice.

Thank you! Yes, that'd do it. I only test for everything being CJS or everything being ESM (across all three package.json files). Not a mixture.

A warning somewhere in the cedar cli, like your AI agent suggests, makes a lot of sense

@bellcoTech
Copy link
Copy Markdown
Contributor Author

Ok great, I didn't completely waste your time 😅😅. Would you like me to do the warning? Or leave it for you?

@Tobbe
Copy link
Copy Markdown
Member

Tobbe commented May 15, 2026

Please, if you can spare the time, go ahead with the warning. Thanks!

@bellcoTech
Copy link
Copy Markdown
Contributor Author

Sure more than happy to

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants