fix(auth): handle Astro v6 removal of locals.runtime.env in OAuth routes#763
fix(auth): handle Astro v6 removal of locals.runtime.env in OAuth routes#763kamine81 wants to merge 13 commits into
Conversation
@astrojs/cloudflare v13+ (Astro v6) removed locals.runtime.env, causing OAuth login to fail silently with a generic error redirect. Resolution order for env vars: 1. locals.runtime.env — Astro v5 + @astrojs/cloudflare 2. cloudflare:workers — Astro v6 + @astrojs/cloudflare 3. import.meta.env — Node.js / Vite dev server fallback Adds unit tests for all three resolution paths and the error cases.
🦋 Changeset detectedLatest commit: 680d02f The changes in this PR will be included in the next version bump. This PR includes changesets to release 14 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
Rollup statically resolves string-literal dynamic imports at build time. Using a variable (`const cfWorkersModId = "cloudflare:workers"`) defers resolution to runtime, avoiding build failures in Node.js fixture builds.
|
This PR has been inactive for 14 days. It will be closed automatically in 7 days if there is no further activity. If you're still working on this, please push an update or leave a comment. |
Bring the branch up to date with upstream/main. The only conflicts were in the OAuth routes ([provider].ts, [provider]/callback.ts); resolve them by taking the upstream version so this commit is a clean main integration. The Astro v6 locals.runtime.env fix is re-applied in the following commit.
@astrojs/cloudflare v13+ (Astro v6) removed locals.runtime.env, so the OAuth initiation and callback routes threw when reading env. Resolve env through a fallback chain: locals.runtime.env (Astro v5) -> cloudflare:workers (Astro v6) -> import.meta.env (Node). The cloudflare:workers import is a literal dynamic import; the core build preserves it as a runtime-only import, so no static-analysis workaround is needed. Matches the production-validated patch in kamine81/my-emdash-site#100.
The callback route ([provider]/callback.ts) received the same locals.runtime.env -> cloudflare:workers -> import.meta.env fallback as the initiation route, but had no test. Add a unit test mirroring the provider-route suite, including the regression case where the Astro v6 locals.runtime.env getter throws and must be caught (error=invalid_state, not error=oauth_error).
…solve
The literal `import("cloudflare:workers")` in the OAuth routes broke
non-Cloudflare (Node adapter) template builds: Rollup statically
resolves the specifier at build time and fails because the module only
exists on workerd. Build the module id at runtime
(`["cloudflare", "workers"].join(":")` + @vite-ignore) so neither this
package's bundler nor the downstream Astro/Rollup template build
resolves it statically.
On Cloudflare the dynamic import is left as a runtime expression and
resolves natively on workerd (verified: the adapter externalizes via a
`/^cloudflare:/` resolveId filter, and the marketing-cloudflare build
keeps the runtime import intact). On Node it throws and is caught,
falling through to import.meta.env.
There was a problem hiding this comment.
Approach judgment
This is the right fix for the right problem. @astrojs/cloudflare v13+ (Astro v6) removed locals.runtime.env, and the OAuth routes were the only place in core still relying on it. Using a runtime-computed module specifier for the cloudflare:workers dynamic import is a pragmatic way to keep Rollup from statically resolving it on Node builds, and the resolution order (v5 locals → v6 cloudflare:workers → Vite/Node import.meta.env) is sensible.
What I checked
- Diff, both changed route files, and the new unit tests.
- Grepped the tree for other
locals.runtime.envusages — only these two OAuth routes are affected. - Checked the changeset, which is correctly scoped as a patch.
- Verified no SQL, UI, i18n, or authorization concerns (no user-facing strings, no DB schema changes, no new queries against content tables).
Headline conclusion
The code is correct and the tests cover the Node-side fallback paths well. I have three non-blocking suggestions:
- Duplicated env-resolution block — the same ~23-line try/catch/import fallback is copy-pasted in both
[provider].tsand[provider]/callback.ts(on top of the pre-existing duplication ofgetOAuthConfigandenvString). A shared helper would prevent drift. - Silent error swallowing — all three catch blocks in the resolution chain swallow errors with no logging. A warning log would help distinguish an expected fallback from a real runtime problem.
- No test for actual
cloudflare:workersresolution — the Astro v6 test only works becauseimport("cloudflare:workers")also throws in the Node test environment; it never asserts that the module resolves and itsenvexport is used when actually present on Workers.
None of these block merge, but addressing (1) in particular would improve maintainability.
| // Get OAuth providers from environment. | ||
| // Resolution order: | ||
| // 1. locals.runtime.env — Astro v5 + @astrojs/cloudflare | ||
| // 2. cloudflare:workers — Astro v6 + @astrojs/cloudflare (locals.runtime.env was removed) | ||
| // 3. import.meta.env — Node.js / Vite dev server fallback | ||
| let env: Record<string, unknown>; | ||
| try { | ||
| // eslint-disable-next-line typescript/no-unsafe-type-assertion -- locals.runtime is injected by the Cloudflare adapter at runtime; not declared on App.Locals since the adapter is optional | ||
| const runtimeLocals = locals as unknown as { runtime?: { env?: Record<string, unknown> } }; | ||
| // eslint-disable-next-line typescript/no-unsafe-type-assertion -- import.meta.env is typed as ImportMetaEnv but we need Record<string, unknown> for getOAuthConfig | ||
| env = runtimeLocals.runtime?.env ?? (import.meta.env as Record<string, unknown>); | ||
| } catch { | ||
| // Astro v6: locals.runtime.env accessor throws — import from cloudflare:workers instead. | ||
| // The module id is held in a variable so Rollup cannot statically resolve it: in the | ||
| // Node template builds the specifier does not exist, and a literal import would fail | ||
| // the build. It resolves at runtime only on Cloudflare Workers. | ||
| try { | ||
| // Built at runtime (not a string literal) so neither this package's bundler nor | ||
| // the downstream Astro/Rollup template build statically resolves "cloudflare:workers". | ||
| const cfWorkersModId = ["cloudflare", "workers"].join(":"); | ||
| const { env: cfEnv } = await import(/* @vite-ignore */ cfWorkersModId); | ||
| // eslint-disable-next-line typescript/no-unsafe-type-assertion -- cloudflare:workers env is typed as Cloudflare.Env; cast to generic record for getOAuthConfig | ||
| env = cfEnv as Record<string, unknown>; | ||
| } catch { | ||
| // Not running on Cloudflare Workers — fall back to Vite's import.meta.env |
There was a problem hiding this comment.
[suggestion] The exact same environment-resolution fallback is copy-pasted verbatim in [provider]/callback.ts. Together with the pre-existing duplication of getOAuthConfig and envString across both files, this means any future runtime change needs edits in two places. Consider extracting a shared helper (e.g. #auth/oauth-env.ts) so the resolution order lives in one place.
| // Get OAuth providers from environment. | |
| // Resolution order: | |
| // 1. locals.runtime.env — Astro v5 + @astrojs/cloudflare | |
| // 2. cloudflare:workers — Astro v6 + @astrojs/cloudflare (locals.runtime.env was removed) | |
| // 3. import.meta.env — Node.js / Vite dev server fallback | |
| let env: Record<string, unknown>; | |
| try { | |
| // eslint-disable-next-line typescript/no-unsafe-type-assertion -- locals.runtime is injected by the Cloudflare adapter at runtime; not declared on App.Locals since the adapter is optional | |
| const runtimeLocals = locals as unknown as { runtime?: { env?: Record<string, unknown> } }; | |
| // eslint-disable-next-line typescript/no-unsafe-type-assertion -- import.meta.env is typed as ImportMetaEnv but we need Record<string, unknown> for getOAuthConfig | |
| env = runtimeLocals.runtime?.env ?? (import.meta.env as Record<string, unknown>); | |
| } catch { | |
| // Astro v6: locals.runtime.env accessor throws — import from cloudflare:workers instead. | |
| // The module id is held in a variable so Rollup cannot statically resolve it: in the | |
| // Node template builds the specifier does not exist, and a literal import would fail | |
| // the build. It resolves at runtime only on Cloudflare Workers. | |
| try { | |
| // Built at runtime (not a string literal) so neither this package's bundler nor | |
| // the downstream Astro/Rollup template build statically resolves "cloudflare:workers". | |
| const cfWorkersModId = ["cloudflare", "workers"].join(":"); | |
| const { env: cfEnv } = await import(/* @vite-ignore */ cfWorkersModId); | |
| // eslint-disable-next-line typescript/no-unsafe-type-assertion -- cloudflare:workers env is typed as Cloudflare.Env; cast to generic record for getOAuthConfig | |
| env = cfEnv as Record<string, unknown>; | |
| } catch { | |
| // Not running on Cloudflare Workers — fall back to Vite's import.meta.env | |
| // Example extraction in a shared module: | |
| export async function resolveOAuthEnv( | |
| locals: App.Locals, | |
| ): Promise<Record<string, unknown>> { | |
| try { | |
| const runtimeLocals = locals as unknown as { runtime?: { env?: Record<string, unknown> } }; | |
| return runtimeLocals.runtime?.env ?? (import.meta.env as Record<string, unknown>); | |
| } catch { | |
| try { | |
| const cfWorkersModId = ["cloudflare", "workers"].join(":"); | |
| const { env: cfEnv } = await import(/* @vite-ignore */ cfWorkersModId); | |
| return cfEnv as Record<string, unknown>; | |
| } catch { | |
| return import.meta.env as Record<string, unknown>; | |
| } | |
| } | |
| } |
There was a problem hiding this comment.
Done in f80880f. Extracted the resolution chain into #auth/oauth-env.ts (resolveOAuthEnv(locals)), now called by both [provider].ts and [provider]/callback.ts, so the order lives in one place.
| const runtimeLocals = locals as unknown as { runtime?: { env?: Record<string, unknown> } }; | ||
| // eslint-disable-next-line typescript/no-unsafe-type-assertion -- import.meta.env is typed as ImportMetaEnv but we need Record<string, unknown> for getOAuthConfig | ||
| env = runtimeLocals.runtime?.env ?? (import.meta.env as Record<string, unknown>); | ||
| } catch { |
There was a problem hiding this comment.
[suggestion] The catch blocks in the env-resolution chain swallow every error silently. If locals.runtime.env throws for an unexpected reason, or if the cloudflare:workers import fails due to a real runtime issue rather than "not on Cloudflare", there is no diagnostic. Consider adding a console.warn so operators can tell the difference between an intentional fallback and a real problem:
| } catch { | |
| } catch (error) { | |
| console.warn("[oauth] locals.runtime.env unavailable, trying cloudflare:workers", error); | |
| try { |
There was a problem hiding this comment.
Done in f80880f. The shared helper now logs console.warn("[oauth] locals.runtime.env unavailable, trying cloudflare:workers", error) before falling through, so an intentional Astro v6 fallback is distinguishable from a real runtime failure.
| }); | ||
|
|
||
| it("falls back gracefully when locals.runtime.env throws (Astro v6 Cloudflare)", async () => { | ||
| vi.stubEnv("EMDASH_OAUTH_GITHUB_CLIENT_ID", "test-client-id"); |
There was a problem hiding this comment.
[suggestion] This test title says "Astro v6 Cloudflare", but it never actually exercises a successful cloudflare:workers resolution. In the Node test environment import("cloudflare:workers") throws, so the code falls through to import.meta.env. Consider mocking the module so the test also asserts that the env export from cloudflare:workers is used when available:
| vi.stubEnv("EMDASH_OAUTH_GITHUB_CLIENT_ID", "test-client-id"); | |
| it("falls back gracefully when locals.runtime.env throws (Astro v6 Cloudflare)", async () => { | |
| // In Node tests the dynamic import of cloudflare:workers throws, | |
| // so we verify the triple-fallback chain: locals.runtime.env → | |
| // cloudflare:workers (throws) → import.meta.env. A successful | |
| // cloudflare:workers path can be verified in integration tests. |
There was a problem hiding this comment.
Done in f80880f. Added a comment clarifying that in Node the cloudflare:workers import throws, so this test exercises the triple-fallback chain (locals.runtime.env → cloudflare:workers → import.meta.env); the successful cloudflare:workers path is left to integration tests.
Address PR review suggestions on emdash-cms#763: - Extract the duplicated env-resolution fallback chain (locals.runtime.env -> cloudflare:workers -> import.meta.env) into #auth/oauth-env.js so the resolution order lives in one place and the OAuth start/callback routes cannot drift. - Log a console.warn when locals.runtime.env is unavailable so operators can distinguish an intentional Astro v6 fallback from a real runtime problem. - Clarify the Astro v6 fallback test with a comment explaining it exercises the triple-fallback chain (cloudflare:workers throws in Node).
What does this PR do?
Fixes GitHub/Google OAuth login when using
@astrojs/cloudflarev13+ (Astro v6).The
@astrojs/cloudflareadapter removedAstro.locals.runtime.envin Astro v6, replacing it withimport { env } from "cloudflare:workers". The OAuth initiation ([provider].ts) and callback ([provider]/callback.ts) routes were still reading fromlocals.runtime.env, which now throws at runtime — causing every OAuth login attempt to silently redirect to a genericoauth_errorpage.Resolution order after this fix:
locals.runtime.env— Astro v5 +@astrojs/cloudflare(unchanged behaviour)cloudflare:workers— Astro v6 +@astrojs/cloudflare. New fallback via a dynamicimport()whose module specifier is built at runtime (["cloudflare", "workers"].join(":")), so it is not statically analyzable.import.meta.env— Node.js / Vite dev server (unchanged fallback)Why the specifier is computed: A literal
import("cloudflare:workers")is statically resolved by Rollup at build time. On Node-adapter templates (templates/marketing,templates/blog, …) there is no plugin to externalize it, so the template build fails with "Rollup failed to resolve import cloudflare:workers". Computing the specifier at runtime keeps it out of static analysis:@astrojs/cloudflareexternalizes via aresolveIdfilter{ id: /^cloudflare:/ }(a build-time regex on the specifier string). A computed specifier never hits that filter, so Rollup leaves theimport()as a runtime expression and workerd resolvescloudflare:workersnatively. (Verified: themarketing-cloudflaretemplate build keeps the runtime import intact indist/server/chunks/.)import.meta.env.Related issues: #464, #249
Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runpnpm locale:extracthas been run (if applicable)AI-generated code disclosure
Screenshots / test output