diff --git a/.bumpy/fix-astro-cf-static-prerender.md b/.bumpy/fix-astro-cf-static-prerender.md new file mode 100644 index 000000000..1c7463cd9 --- /dev/null +++ b/.bumpy/fix-astro-cf-static-prerender.md @@ -0,0 +1,5 @@ +--- +"@varlock/vite-integration": patch +--- + +Fix Astro + Cloudflare static/prerendered builds: bake resolved env into the build-time prerender worker, and stop mis-injecting SSR init code into non-entry modules during builds (REQUIRE_TLA errors) diff --git a/framework-tests/frameworks/astro/astro-shared.ts b/framework-tests/frameworks/astro/astro-shared.ts index 1bc9aaf21..52d813d33 100644 --- a/framework-tests/frameworks/astro/astro-shared.ts +++ b/framework-tests/frameworks/astro/astro-shared.ts @@ -389,6 +389,9 @@ export function defineAstroTests(astroVersion: number, testDir: string, opts: { '@varlock/astro-integration': 'will-be-replaced', '@varlock/cloudflare-integration': 'will-be-replaced', '@astrojs/cloudflare': '^14', + '@astrojs/react': '^6', + react: '^19', + 'react-dom': '^19', wrangler: '^4', }, overrides: { @@ -404,6 +407,99 @@ export function defineAstroTests(astroVersion: number, testDir: string, opts: { beforeAll(() => cfEnv.setup(), 180_000); afterAll(() => cfEnv.teardown()); + // Regression test for https://github.com/dmno-dev/varlock/issues/893 — + // static output + CF adapter + a CJS dep (react-dom) in the prerender + // build must not get the TLA-containing varlock-ssr-init module injected + // into a require()-reachable module (Rolldown rejects it with REQUIRE_TLA). + cfEnv.describeScenario('static output with react component', { + command: 'astro build', + templateFiles: { + 'src/pages/index.astro': 'pages/react-page.astro', + 'src/components/ReactHello.tsx': 'components/ReactHello.tsx', + 'astro.config.mts': 'configs/astro.config.cloudflare-static.mts', + 'wrangler.jsonc': 'configs/wrangler.jsonc', + }, + expectSuccess: true, + fileAssertions: [ + { + description: 'env vars are injected into static output', + fileGlob: 'dist/**/*.html', + shouldContain: [ + // react inserts a `` separator between text nodes, + // so the label and value are asserted separately + 'react-says:', + 'public-var-value', + 'sensitive-var-available', + ], + shouldNotContain: ['super-secret-value'], + }, + ], + }); + + // A production-representative CF build: `output: 'server'` with one + // prerendered page and one on-demand SSR page. This exercises both env + // paths in a single build — the build-time prerender worker (env baked in, + // no bindings loader) and the deployed SSR worker (runtime bindings loader, + // no baked env) — and asserts they don't cross-contaminate. + cfEnv.describeScenario('server output with a prerendered page', { + command: 'astro build', + templateFiles: { + 'src/pages/index.astro': 'pages/server-basic-page.astro', + 'src/pages/prerendered.astro': 'pages/prerendered-page.astro', + 'astro.config.mts': 'configs/astro.config.cloudflare.mts', + 'wrangler.jsonc': 'configs/wrangler.jsonc', + }, + expectSuccess: true, + fileAssertions: [ + { + description: 'prerendered page is rendered at build time with env', + fileGlob: 'dist/client/**/*.html', + shouldContain: ['public-var-value', 'sensitive-var-available'], + shouldNotContain: ['super-secret-value'], + }, + { + // The deployed SSR worker must load env from Cloudflare bindings at + // runtime (varlock's `Cloudflare-Workers` navigator guard is present) + // and must NOT bake the resolved secret into the artifact. + description: 'SSR worker uses the runtime bindings loader, not baked env', + filePath: 'dist/server/entry.mjs', + shouldContain: ['Cloudflare-Workers'], + shouldNotContain: ['super-secret-value'], + }, + { + description: 'no resolved secret is baked anywhere in the deployed worker', + fileGlob: 'dist/server/**/*.mjs', + shouldNotContain: ['super-secret-value'], + }, + ], + outputAssertions: [ + { + description: 'secret is redacted from prerender stdout', + shouldContain: ['secret-log-test:'], + shouldNotContain: ['super-secret-value'], + }, + ], + }); + + // Leak protection must run inside the build-time prerender worker (which + // now has env baked in): a prerendered page that leaks a sensitive value + // fails the build. + cfEnv.describeScenario('leaky prerendered page fails the build', { + command: 'astro build', + templateFiles: { + 'src/pages/index.astro': 'pages/leaky-prerendered-page.astro', + 'astro.config.mts': 'configs/astro.config.cloudflare.mts', + 'wrangler.jsonc': 'configs/wrangler.jsonc', + }, + expectSuccess: false, + outputAssertions: [ + { + description: 'output contains leak detection message', + shouldContain: ['DETECTED LEAKED SENSITIVE CONFIG'], + }, + ], + }); + cfEnv.describeDevScenario('SSR + env injection via worker bindings', { command: `astro dev --port ${port()}`, readyPattern: /http:\/\/localhost/, diff --git a/framework-tests/frameworks/astro/files/components/ReactHello.tsx b/framework-tests/frameworks/astro/files/components/ReactHello.tsx new file mode 100644 index 000000000..44e9f51c6 --- /dev/null +++ b/framework-tests/frameworks/astro/files/components/ReactHello.tsx @@ -0,0 +1,3 @@ +export default function ReactHello(props: { publicVar?: string }) { + return

react-says:{props.publicVar}

; +} diff --git a/framework-tests/frameworks/astro/files/configs/astro.config.cloudflare-static.mts b/framework-tests/frameworks/astro/files/configs/astro.config.cloudflare-static.mts new file mode 100644 index 000000000..ee1b96918 --- /dev/null +++ b/framework-tests/frameworks/astro/files/configs/astro.config.cloudflare-static.mts @@ -0,0 +1,10 @@ +import { defineConfig } from 'astro/config'; +import cloudflare from '@astrojs/cloudflare'; +import react from '@astrojs/react'; +import varlockAstroIntegration from '@varlock/astro-integration'; + +export default defineConfig({ + integrations: [varlockAstroIntegration(), react()], + output: 'static', + adapter: cloudflare(), +}); diff --git a/framework-tests/frameworks/astro/files/pages/leaky-prerendered-page.astro b/framework-tests/frameworks/astro/files/pages/leaky-prerendered-page.astro new file mode 100644 index 000000000..d3f260456 --- /dev/null +++ b/framework-tests/frameworks/astro/files/pages/leaky-prerendered-page.astro @@ -0,0 +1,14 @@ +--- +export const prerender = true; +import { ENV } from 'varlock/env'; +--- + + + + Leaky Prerendered Page + + +

Leaky Prerendered Page

+

Sensitive: {ENV.SENSITIVE_VAR}

+ + diff --git a/framework-tests/frameworks/astro/files/pages/prerendered-page.astro b/framework-tests/frameworks/astro/files/pages/prerendered-page.astro new file mode 100644 index 000000000..0349637bb --- /dev/null +++ b/framework-tests/frameworks/astro/files/pages/prerendered-page.astro @@ -0,0 +1,21 @@ +--- +export const prerender = true; +import { ENV } from 'varlock/env'; + +const publicVar = ENV.PUBLIC_VAR; +const hasSensitive = !!ENV.SENSITIVE_VAR; + +// This should be redacted in build output +console.log('secret-log-test:', ENV.SENSITIVE_VAR); +--- + + + + Varlock Prerendered Test - Astro + + +

Varlock Prerendered Test - Astro

+

Public var: {publicVar}

+

Has sensitive: {hasSensitive ? 'sensitive-var-available' : 'X - not available'}

+ + diff --git a/framework-tests/frameworks/astro/files/pages/react-page.astro b/framework-tests/frameworks/astro/files/pages/react-page.astro new file mode 100644 index 000000000..db917cf30 --- /dev/null +++ b/framework-tests/frameworks/astro/files/pages/react-page.astro @@ -0,0 +1,18 @@ +--- +import { ENV } from 'varlock/env'; +import ReactHello from '../components/ReactHello'; + +const publicVar = ENV.PUBLIC_VAR; +const hasSensitive = !!ENV.SENSITIVE_VAR; +--- + + + + Varlock Framework Test - Astro + React + + +

Varlock Framework Test - Astro + React

+ +

Has sensitive: {hasSensitive ? 'sensitive-var-available' : 'X - not available'}

+ + diff --git a/packages/integrations/vite/src/index.ts b/packages/integrations/vite/src/index.ts index ce222352a..241227f13 100644 --- a/packages/integrations/vite/src/index.ts +++ b/packages/integrations/vite/src/index.ts @@ -215,8 +215,28 @@ export function varlockVitePlugin( // Build the virtual init module content once. This module is imported // by SSR entry points and evaluates before any user code because it // has no transitive dependencies on user modules. - function buildInitModuleCode() { - const ssrInjectMode = vitePluginOptions?.ssrInjectMode ?? 'init-only'; + function buildInitModuleCode(environmentName?: string) { + let ssrInjectMode = vitePluginOptions?.ssrInjectMode ?? 'init-only'; + + // Cloudflare's build-time prerender worker (@astrojs/cloudflare v14 runs + // prerendering inside workerd via @cloudflare/vite-plugin's experimental + // `prerenderWorker`, a vite environment named "prerender") executes during + // the build with no bindings attached, so the runtime bindings loader can + // never find env there and `process.env.__VARLOCK_ENV` doesn't exist inside + // workerd. Bake the resolved env directly instead by routing this env + // through the same `resolved-env` path used below (so it shares the init + + // patch sequence and any future additions to it) — this artifact only + // generates static HTML at build time and is not deployed, and generated + // HTML is still leak-scanned by the framework integrations. The three + // guards keyed off `isCfPrerenderEnv` opt it out of the CF-specific + // behaviors that don't apply to the throwaway prerender worker: the + // "redundant on CF" rejection, env encryption (the worker can't read + // `_VARLOCK_ENV_KEY`, and the artifact is discarded), and the CF bindings + // loader (whose top-level `await import('cloudflare:workers')` + absent + // bindings are exactly what break the build here). + const isCfPrerenderEnv = resolvedIsCloudflareTarget && environmentName === 'prerender' && !isDevCommand; + if (isCfPrerenderEnv) ssrInjectMode = 'resolved-env'; + const isEdgeRuntime = resolvedSsrEdgeRuntime; const lines: Array = [ '// Virtual module generated by @varlock/vite-integration', @@ -225,7 +245,10 @@ export function varlockVitePlugin( ]; const encryptionRequired = varlockLoadedEnv?.settings?.encryptInjectedEnv; - let encryptionKey: string | undefined = process.env._VARLOCK_ENV_KEY; + // Force plaintext for the prerender worker — it can't read _VARLOCK_ENV_KEY + // (no bindings) and is discarded after the build, so an encrypted blob would + // only fail to decrypt. + let encryptionKey: string | undefined = isCfPrerenderEnv ? undefined : process.env._VARLOCK_ENV_KEY; if (ssrInjectMode === 'auto-load') { lines.push("import 'varlock/auto-load';"); @@ -237,7 +260,7 @@ export function varlockVitePlugin( // so resolved-env is the only way to get real values into the worker — // it's the composed integration's own default there, not shipped in a // deploy artifact. - if (resolvedIsCloudflareTarget && !isDevCommand) { + if (resolvedIsCloudflareTarget && !isDevCommand && !isCfPrerenderEnv) { throw new Error( "[varlock] ssrInjectMode: 'resolved-env' is redundant on Cloudflare Workers and ships resolved " + '(possibly sensitive) values into the worker bundle unnecessarily. Cloudflare deploys get their ' @@ -260,7 +283,7 @@ export function varlockVitePlugin( ); } } - if (encryptionRequired && !encryptionKey) { + if (encryptionRequired && !encryptionKey && !isCfPrerenderEnv) { if (isDevCommand) { // auto-generate a temporary key for local dev encryptionKey = generateEncryptionKeyHex(); @@ -282,8 +305,10 @@ export function varlockVitePlugin( } } - // inject custom entry code from integrations (e.g., CF bindings loader) - if (resolvedSsrEntryCode?.length) { + // inject custom entry code from integrations (e.g., CF bindings loader) — + // but not in the prerender worker, where the runtime bindings loader can't + // work and its top-level await breaks the build (env is baked in above). + if (resolvedSsrEntryCode?.length && !isCfPrerenderEnv) { lines.push(...resolvedSsrEntryCode); } @@ -380,7 +405,10 @@ export function varlockVitePlugin( if (id === VARLOCK_INIT_MODULE_ID) return id; }, load(id) { - if (id === VARLOCK_INIT_MODULE_ID) return buildInitModuleCode(); + if (id === VARLOCK_INIT_MODULE_ID) { + // `this.environment` exists in vite 6+ (environments API) + return buildInitModuleCode(this.environment?.name); + } }, // hook to modify config before it is resolved @@ -509,6 +537,10 @@ See https://varlock.dev/integrations/vite/ for more details. // replace build-time ENV.x references let magicString = replacerFn(this, code, id); + // Detect dev vs build. + // Use the environment API (vite 6+), falling back to command check (vite 5). + const isDevEnv = this.environment ? this.environment.mode === 'dev' : isDevCommand; + // Detect if this module is an entry point so we can inject varlock init. // For regular files: try isEntry (build mode), fall back to moduleIds[0] (dev). // For virtual modules: check ssrEntryModuleIds from integrations (e.g. CF plugin). @@ -521,7 +553,13 @@ See https://varlock.dev/integrations/vite/ for more details. } catch { // vite 6 throws "isEntry property of ModuleInfo is not supported" in dev } - if (!isEntry) { + // The moduleIds[0] heuristic is only for dev, where isEntry is + // unavailable. During builds isEntry is authoritative — and under + // rolldown's parallel transforms, moduleIds[0] is timing-dependent and + // can misfire, injecting the init module into an arbitrary module + // (e.g. react-dom's CJS internals, where its top-level await then + // breaks require() paths — see issue #893). + if (!isEntry && isDevEnv) { const moduleIds = Array.from(this.getModuleIds()); if (moduleIds[0] === id) isEntry = true; } @@ -541,9 +579,6 @@ See https://varlock.dev/integrations/vite/ for more details. } else { // Client entry injectCode.push('globalThis.__varlockThrowOnMissingKeys = true;'); - // Detect dev vs build for client-side dev helpers. - // Use the environment API (vite 6+), falling back to command check (vite 5). - const isDevEnv = this.environment ? this.environment.mode === 'dev' : isDevCommand; if (isDevEnv) { injectCode.push( `globalThis.__varlockValidKeys = ${JSON.stringify(Object.keys(varlockLoadedEnv?.config || {}))};`, diff --git a/packages/varlock-website/src/content/docs/integrations/astro.mdx b/packages/varlock-website/src/content/docs/integrations/astro.mdx index f5019f48f..be0cdb2a3 100644 --- a/packages/varlock-website/src/content/docs/integrations/astro.mdx +++ b/packages/varlock-website/src/content/docs/integrations/astro.mdx @@ -173,6 +173,8 @@ When deploying SSR Astro apps to serverless platforms, varlock injects the resol If you deploy your SSR Astro app to Cloudflare Workers via [`@astrojs/cloudflare`](https://docs.astro.build/en/guides/integrations-guide/cloudflare/), install [`@varlock/cloudflare-integration`](/integrations/cloudflare/) alongside the Astro integration. The Astro integration **auto-detects** the Cloudflare adapter and wires up env injection into the worker. There's no extra Vite or adapter config to write, and you keep using `astro dev` as normal. +Static and prerendered pages work too: they are rendered at build time with your resolved env, so a fully static site (`output: 'static'`) needs no vars or secrets uploaded to Cloudflare at all. + 1. **Install the Cloudflare integration**