Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .bumpy/fix-astro-cf-static-prerender.md
Original file line number Diff line number Diff line change
@@ -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)
96 changes: 96 additions & 0 deletions framework-tests/frameworks/astro/astro-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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/,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function ReactHello(props: { publicVar?: string }) {
return <p>react-says:{props.publicVar}</p>;
}
Original file line number Diff line number Diff line change
@@ -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(),
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
export const prerender = true;
import { ENV } from 'varlock/env';
---

<html>
<head>
<title>Leaky Prerendered Page</title>
</head>
<body>
<h1>Leaky Prerendered Page</h1>
<p>Sensitive: {ENV.SENSITIVE_VAR}</p>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -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);
---

<html>
<head>
<title>Varlock Prerendered Test - Astro</title>
</head>
<body>
<h1>Varlock Prerendered Test - Astro</h1>
<p>Public var: {publicVar}</p>
<p>Has sensitive: {hasSensitive ? 'sensitive-var-available' : 'X - not available'}</p>
</body>
</html>
18 changes: 18 additions & 0 deletions framework-tests/frameworks/astro/files/pages/react-page.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
import { ENV } from 'varlock/env';
import ReactHello from '../components/ReactHello';

const publicVar = ENV.PUBLIC_VAR;
const hasSensitive = !!ENV.SENSITIVE_VAR;
---

<html>
<head>
<title>Varlock Framework Test - Astro + React</title>
</head>
<body>
<h1>Varlock Framework Test - Astro + React</h1>
<ReactHello publicVar={publicVar} />
<p>Has sensitive: {hasSensitive ? 'sensitive-var-available' : 'X - not available'}</p>
</body>
</html>
59 changes: 47 additions & 12 deletions packages/integrations/vite/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = [
'// Virtual module generated by @varlock/vite-integration',
Expand All @@ -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';");
Expand All @@ -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 '
Expand All @@ -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();
Expand All @@ -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);
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand All @@ -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;
}
Expand All @@ -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 || {}))};`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Steps>

1. **Install the Cloudflare integration**
Expand Down
Loading