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'; +--- + + + +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); +--- + + + +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; +--- + + + +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