diff --git a/.bumpy/static-dynamic-config.md b/.bumpy/static-dynamic-config.md
new file mode 100644
index 000000000..fefd6d63a
--- /dev/null
+++ b/.bumpy/static-dynamic-config.md
@@ -0,0 +1,9 @@
+---
+varlock: minor
+'@varlock/astro-integration': minor
+'@varlock/expo-integration': minor
+'@varlock/nextjs-integration': minor
+'@varlock/vite-integration': minor
+---
+
+Add static/dynamic config controls and dynamic+public framework/runtime support
diff --git a/framework-tests/README.md b/framework-tests/README.md
index 78c8abdf6..7bdb666b4 100644
--- a/framework-tests/README.md
+++ b/framework-tests/README.md
@@ -14,6 +14,7 @@ bun run test
bun run test:astro
bun run test:expo
bun run test:nextjs
+bun run test:sveltekit
# Watch mode (re-runs on file changes)
bun run test:watch
@@ -73,3 +74,4 @@ Each scenario uses `describeScenario()` which:
- **Astro** — tests static builds and SSR dev server, verifying env injection, leak detection in static output / client scripts / server pages / API endpoints, log redaction, and env vars in astro config
- **Next.js** — tests multiple versions (14, 15, 16) and bundlers (webpack, turbopack), verifying env injection, leak detection, log redaction, and sourcemap scrubbing
- **Expo** — tests the babel plugin transform pipeline, verifying static replacement of public vars, protection of sensitive vars, and correct handling of server (+api) routes
+- **SvelteKit** — tests static replacement behavior for static vs dynamic public vars and runtime access to dynamic public vars via server routes
diff --git a/framework-tests/bun.lock b/framework-tests/bun.lock
index 972ec5029..9d3d548de 100644
--- a/framework-tests/bun.lock
+++ b/framework-tests/bun.lock
@@ -6,6 +6,7 @@
"name": "varlock-framework-tests",
"devDependencies": {
"@types/node": "^24.0.0",
+ "@types/react": "19.2.15",
"typescript": "^5.9.3",
"vitest": "^3.2.4",
},
@@ -124,6 +125,8 @@
"@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="],
+ "@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="],
+
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="],
@@ -146,6 +149,8 @@
"check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
+ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
+
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
diff --git a/framework-tests/frameworks/astro/astro-shared.ts b/framework-tests/frameworks/astro/astro-shared.ts
index b99b051ea..4f24697f5 100644
--- a/framework-tests/frameworks/astro/astro-shared.ts
+++ b/framework-tests/frameworks/astro/astro-shared.ts
@@ -260,6 +260,44 @@ export function defineAstroTests(astroVersion: number, testDir: string, opts: {
],
});
+ astroEnv.describeDevScenario('dynamic public env endpoint', {
+ command: `astro dev --port ${port()}`,
+ readyPattern: /http:\/\/localhost/,
+ readyTimeout: 30_000,
+ templateFiles: {
+ 'src/pages/index.astro': 'pages/basic-page.astro',
+ 'astro.config.mts': 'configs/astro.config.server.mts',
+ },
+ requests: [
+ {
+ path: '/__varlock/public-env',
+ bodyAssertions: {
+ shouldContain: ['"PUBLIC_DYNAMIC_VAR":"public-dynamic-var--dev"'],
+ shouldNotContain: ['super-secret-value'],
+ },
+ },
+ ],
+ });
+
+ astroEnv.describeDevScenario('dynamic public env endpoint - custom path', {
+ command: `astro dev --port ${port()}`,
+ readyPattern: /http:\/\/localhost/,
+ readyTimeout: 30_000,
+ templateFiles: {
+ 'src/pages/index.astro': 'pages/basic-page.astro',
+ 'astro.config.mts': 'configs/astro.config.server.custom-public-path.mts',
+ },
+ requests: [
+ {
+ path: '/api/public-env',
+ bodyAssertions: {
+ shouldContain: ['"PUBLIC_DYNAMIC_VAR":"public-dynamic-var--dev"'],
+ shouldNotContain: ['super-secret-value'],
+ },
+ },
+ ],
+ });
+
astroEnv.describeDevScenario('leaky API endpoint', {
command: `astro dev --port ${port()}`,
readyPattern: /http:\/\/localhost/,
@@ -286,6 +324,39 @@ export function defineAstroTests(astroVersion: number, testDir: string, opts: {
],
});
+ astroEnv.describeDevScenario('dynamic public env endpoint - disabled', {
+ command: `astro dev --port ${port()}`,
+ readyPattern: /http:\/\/localhost/,
+ readyTimeout: 30_000,
+ templateFiles: {
+ 'src/pages/index.astro': 'pages/basic-page.astro',
+ 'astro.config.mts': 'configs/astro.config.server.no-public-endpoint.mts',
+ },
+ requests: [
+ {
+ path: '/__varlock/public-env',
+ expectedStatus: 404,
+ },
+ ],
+ });
+
+ astroEnv.describeDevScenario('dynamic public env endpoint - auto disabled when none exist', {
+ command: `astro dev --port ${port()}`,
+ readyPattern: /http:\/\/localhost/,
+ readyTimeout: 30_000,
+ templateFiles: {
+ 'src/pages/index.astro': 'pages/basic-page.astro',
+ 'astro.config.mts': 'configs/astro.config.server.mts',
+ '.env.schema': 'schemas/.env.schema.no-public-dynamic',
+ },
+ requests: [
+ {
+ path: '/__varlock/public-env',
+ expectedStatus: 404,
+ },
+ ],
+ });
+
astroEnv.describeDevScenario('non-existent var access in API endpoint', {
command: `astro dev --port ${port()}`,
readyPattern: /http:\/\/localhost/,
diff --git a/framework-tests/frameworks/astro/files/configs/astro.config.server.custom-public-path.mts b/framework-tests/frameworks/astro/files/configs/astro.config.server.custom-public-path.mts
new file mode 100644
index 000000000..515d4f542
--- /dev/null
+++ b/framework-tests/frameworks/astro/files/configs/astro.config.server.custom-public-path.mts
@@ -0,0 +1,13 @@
+import { defineConfig } from 'astro/config';
+import node from '@astrojs/node';
+import varlockAstroIntegration from '@varlock/astro-integration';
+
+export default defineConfig({
+ integrations: [
+ varlockAstroIntegration({
+ publicDynamicEndpoint: { path: '/api/public-env' },
+ }),
+ ],
+ output: 'server',
+ adapter: node({ mode: 'standalone' }),
+});
diff --git a/framework-tests/frameworks/astro/files/configs/astro.config.server.no-public-endpoint.mts b/framework-tests/frameworks/astro/files/configs/astro.config.server.no-public-endpoint.mts
new file mode 100644
index 000000000..4f5a8518d
--- /dev/null
+++ b/framework-tests/frameworks/astro/files/configs/astro.config.server.no-public-endpoint.mts
@@ -0,0 +1,13 @@
+import { defineConfig } from 'astro/config';
+import node from '@astrojs/node';
+import varlockAstroIntegration from '@varlock/astro-integration';
+
+export default defineConfig({
+ integrations: [
+ varlockAstroIntegration({
+ publicDynamicEndpoint: false,
+ }),
+ ],
+ output: 'server',
+ adapter: node({ mode: 'standalone' }),
+});
diff --git a/framework-tests/frameworks/astro/files/schemas/.env.dev b/framework-tests/frameworks/astro/files/schemas/.env.dev
index 98ad2f5ad..ad54ab228 100644
--- a/framework-tests/frameworks/astro/files/schemas/.env.dev
+++ b/framework-tests/frameworks/astro/files/schemas/.env.dev
@@ -1 +1,2 @@
ENV_SPECIFIC_VAR=env-specific-var--dev
+PUBLIC_DYNAMIC_VAR=public-dynamic-var--dev
diff --git a/framework-tests/frameworks/astro/files/schemas/.env.prod b/framework-tests/frameworks/astro/files/schemas/.env.prod
index 437d847ce..598a20181 100644
--- a/framework-tests/frameworks/astro/files/schemas/.env.prod
+++ b/framework-tests/frameworks/astro/files/schemas/.env.prod
@@ -1 +1,2 @@
ENV_SPECIFIC_VAR=env-specific-var--prod
+PUBLIC_DYNAMIC_VAR=public-dynamic-var--prod
diff --git a/framework-tests/frameworks/astro/files/schemas/.env.schema b/framework-tests/frameworks/astro/files/schemas/.env.schema
index 62560d0ed..b46d1a296 100644
--- a/framework-tests/frameworks/astro/files/schemas/.env.schema
+++ b/framework-tests/frameworks/astro/files/schemas/.env.schema
@@ -1,4 +1,4 @@
-# @defaultSensitive=false @defaultRequired=infer
+# @defaultSensitive=false @defaultRequired=infer @defaultDynamic=sensitive
# @generateTypes(lang="ts", path="env.d.ts")
# @currentEnv=$APP_ENV
# ---
@@ -9,6 +9,8 @@ APP_ENV=dev
PUBLIC_VAR=public-var-value
UNPREFIXED_PUBLIC=unprefixed-public-var
ENV_SPECIFIC_VAR=env-specific-var--default
+# @dynamic
+PUBLIC_DYNAMIC_VAR=public-dynamic-var--default
# @sensitive
SENSITIVE_VAR=super-secret-value
diff --git a/framework-tests/frameworks/astro/files/schemas/.env.schema.empty-secret b/framework-tests/frameworks/astro/files/schemas/.env.schema.empty-secret
index e69f6c285..429023767 100644
--- a/framework-tests/frameworks/astro/files/schemas/.env.schema.empty-secret
+++ b/framework-tests/frameworks/astro/files/schemas/.env.schema.empty-secret
@@ -1,4 +1,4 @@
-# @defaultSensitive=false @defaultRequired=infer
+# @defaultSensitive=false @defaultRequired=infer @defaultDynamic=sensitive
# @generateTypes(lang="ts", path="env.d.ts")
# @currentEnv=$APP_ENV
# ---
diff --git a/framework-tests/frameworks/astro/files/schemas/.env.schema.invalid b/framework-tests/frameworks/astro/files/schemas/.env.schema.invalid
index 35c90bf3c..77c160c7b 100644
--- a/framework-tests/frameworks/astro/files/schemas/.env.schema.invalid
+++ b/framework-tests/frameworks/astro/files/schemas/.env.schema.invalid
@@ -1,4 +1,4 @@
-# @defaultSensitive=false @defaultRequired=infer
+# @defaultSensitive=false @defaultRequired=infer @defaultDynamic=sensitive
# @generateTypes(lang="ts", path="env.d.ts")
# @currentEnv=$APP_ENV
# ---
diff --git a/framework-tests/frameworks/astro/files/schemas/.env.schema.no-public-dynamic b/framework-tests/frameworks/astro/files/schemas/.env.schema.no-public-dynamic
new file mode 100644
index 000000000..05360cbd8
--- /dev/null
+++ b/framework-tests/frameworks/astro/files/schemas/.env.schema.no-public-dynamic
@@ -0,0 +1,15 @@
+# @defaultSensitive=false @defaultRequired=infer
+# @defaultDynamic=sensitive
+# @generateTypes(lang="ts", path="env.d.ts")
+# @currentEnv=$APP_ENV
+# ---
+
+# @type=enum(dev, preview, prod, test)
+APP_ENV=dev
+
+PUBLIC_VAR=public-var-value
+UNPREFIXED_PUBLIC=unprefixed-public-var
+ENV_SPECIFIC_VAR=env-specific-var--default
+
+# @sensitive
+SENSITIVE_VAR=super-secret-value
diff --git a/framework-tests/frameworks/nextjs/files/pages/dynamic-direct-page.tsx b/framework-tests/frameworks/nextjs/files/pages/dynamic-direct-page.tsx
new file mode 100644
index 000000000..c62e5bf99
--- /dev/null
+++ b/framework-tests/frameworks/nextjs/files/pages/dynamic-direct-page.tsx
@@ -0,0 +1,10 @@
+import { ENV } from 'varlock/env';
+
+export default function HomePage() {
+ return (
+
+ Varlock Framework Test - Next.js (direct dynamic)
+ Dynamic public: {ENV.PUBLIC_DYNAMIC_VAR}
+
+ );
+}
diff --git a/framework-tests/frameworks/nextjs/files/pages/dynamic-nested-component.tsx b/framework-tests/frameworks/nextjs/files/pages/dynamic-nested-component.tsx
new file mode 100644
index 000000000..5bd89b144
--- /dev/null
+++ b/framework-tests/frameworks/nextjs/files/pages/dynamic-nested-component.tsx
@@ -0,0 +1,5 @@
+import { ENV } from 'varlock/env';
+
+export function NestedDynamicValue() {
+ return
Nested dynamic public: {ENV.PUBLIC_DYNAMIC_VAR}
;
+}
diff --git a/framework-tests/frameworks/nextjs/files/pages/dynamic-nested-page.tsx b/framework-tests/frameworks/nextjs/files/pages/dynamic-nested-page.tsx
new file mode 100644
index 000000000..f75016651
--- /dev/null
+++ b/framework-tests/frameworks/nextjs/files/pages/dynamic-nested-page.tsx
@@ -0,0 +1,10 @@
+import { NestedDynamicValue } from './components/nested-dynamic-value';
+
+export default function HomePage() {
+ return (
+
+ Varlock Framework Test - Next.js (nested dynamic)
+
+
+ );
+}
diff --git a/framework-tests/frameworks/nextjs/files/schemas/.env.schema.dynamic-public b/framework-tests/frameworks/nextjs/files/schemas/.env.schema.dynamic-public
new file mode 100644
index 000000000..afcc827fd
--- /dev/null
+++ b/framework-tests/frameworks/nextjs/files/schemas/.env.schema.dynamic-public
@@ -0,0 +1,16 @@
+# @defaultSensitive=false @defaultRequired=infer
+# @generateTypes(lang="ts", path="env.d.ts")
+# @currentEnv=$APP_ENV
+# ---
+
+# @type=enum(dev, preview, prod, test)
+APP_ENV=dev
+
+NEXT_PUBLIC_VAR=next-prefixed-public-var
+PUBLIC_VAR=unprefixed-public-var
+ENV_SPECIFIC_VAR=env-specific-var--default
+
+# @sensitive
+SENSITIVE_VAR=super-secret-var
+
+PUBLIC_DYNAMIC_VAR=public-dynamic-var # @dynamic
diff --git a/framework-tests/frameworks/nextjs/nextjs-shared.ts b/framework-tests/frameworks/nextjs/nextjs-shared.ts
index 86c73922e..7745d47c6 100644
--- a/framework-tests/frameworks/nextjs/nextjs-shared.ts
+++ b/framework-tests/frameworks/nextjs/nextjs-shared.ts
@@ -258,6 +258,37 @@ export function defineNextjsTests(nextVersion: number, testDir: string) {
});
describe('default output mode', () => {
+ nextEnv.describeScenario('dynamic public access in page marks route dynamic', {
+ command: buildCommand,
+ skip: nextVersion === 14,
+ templateFiles: {
+ 'app/page.tsx': 'pages/dynamic-direct-page.tsx',
+ '.env.schema': 'schemas/.env.schema.dynamic-public',
+ },
+ outputAssertions: [
+ {
+ description: 'route is treated as dynamic (not prerendered)',
+ shouldContain: ['┌ ƒ /'],
+ },
+ ],
+ });
+
+ nextEnv.describeScenario('nested dynamic public access marks route dynamic', {
+ command: buildCommand,
+ skip: nextVersion === 14,
+ templateFiles: {
+ 'app/page.tsx': 'pages/dynamic-nested-page.tsx',
+ '.env.schema': 'schemas/.env.schema.dynamic-public',
+ 'app/components/nested-dynamic-value.tsx': 'pages/dynamic-nested-component.tsx',
+ },
+ outputAssertions: [
+ {
+ description: 'route is treated as dynamic (not prerendered)',
+ shouldContain: ['┌ ƒ /'],
+ },
+ ],
+ });
+
nextEnv.describeScenario('basic static page', {
command: buildCommand,
templateFiles: {
diff --git a/framework-tests/frameworks/sveltekit/files/_base/package.json b/framework-tests/frameworks/sveltekit/files/_base/package.json
new file mode 100644
index 000000000..ef7934958
--- /dev/null
+++ b/framework-tests/frameworks/sveltekit/files/_base/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "sveltekit-framework-test",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "vite build",
+ "dev": "vite dev"
+ }
+}
diff --git a/framework-tests/frameworks/sveltekit/files/_base/src/app.html b/framework-tests/frameworks/sveltekit/files/_base/src/app.html
new file mode 100644
index 000000000..adf8bd873
--- /dev/null
+++ b/framework-tests/frameworks/sveltekit/files/_base/src/app.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+ %sveltekit.head%
+
+
+ %sveltekit.body%
+
+
diff --git a/framework-tests/frameworks/sveltekit/files/_base/svelte.config.js b/framework-tests/frameworks/sveltekit/files/_base/svelte.config.js
new file mode 100644
index 000000000..d350b12ac
--- /dev/null
+++ b/framework-tests/frameworks/sveltekit/files/_base/svelte.config.js
@@ -0,0 +1,12 @@
+import adapter from '@sveltejs/adapter-static';
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
+
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ preprocess: vitePreprocess(),
+ kit: {
+ adapter: adapter(),
+ },
+};
+
+export default config;
diff --git a/framework-tests/frameworks/sveltekit/files/_base/vite.config.ts b/framework-tests/frameworks/sveltekit/files/_base/vite.config.ts
new file mode 100644
index 000000000..91cbcea4d
--- /dev/null
+++ b/framework-tests/frameworks/sveltekit/files/_base/vite.config.ts
@@ -0,0 +1,10 @@
+import { sveltekit } from '@sveltejs/kit/vite';
+import { varlockVitePlugin } from '@varlock/vite-integration';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [
+ varlockVitePlugin(),
+ sveltekit(),
+ ],
+});
diff --git a/framework-tests/frameworks/sveltekit/files/pages/basic-page.svelte b/framework-tests/frameworks/sveltekit/files/pages/basic-page.svelte
new file mode 100644
index 000000000..326204019
--- /dev/null
+++ b/framework-tests/frameworks/sveltekit/files/pages/basic-page.svelte
@@ -0,0 +1,16 @@
+
+
+SvelteKit Varlock Test
+{staticVar}
+{dynamicVar}
diff --git a/framework-tests/frameworks/sveltekit/files/pages/prerender-dynamic-page.svelte b/framework-tests/frameworks/sveltekit/files/pages/prerender-dynamic-page.svelte
new file mode 100644
index 000000000..897ac9f80
--- /dev/null
+++ b/framework-tests/frameworks/sveltekit/files/pages/prerender-dynamic-page.svelte
@@ -0,0 +1,6 @@
+
+
+Prerender Dynamic Test
+{ENV.PUBLIC_DYNAMIC_VAR}
diff --git a/framework-tests/frameworks/sveltekit/files/routes/public-env-endpoint.ts b/framework-tests/frameworks/sveltekit/files/routes/public-env-endpoint.ts
new file mode 100644
index 000000000..91c2d1162
--- /dev/null
+++ b/framework-tests/frameworks/sveltekit/files/routes/public-env-endpoint.ts
@@ -0,0 +1,21 @@
+import { ENV, getPublicDynamicEnv } from 'varlock/env';
+
+export const GET = async () => {
+ const keys = ['PUBLIC_DYNAMIC_VAR'];
+ const payload = getPublicDynamicEnv(keys) as Record;
+ for (const key of keys) {
+ if (payload[key] !== undefined) continue;
+ if (process.env[key] !== undefined) {
+ payload[key] = process.env[key];
+ continue;
+ }
+ const envVal = (ENV as any)[key];
+ if (envVal !== undefined) payload[key] = envVal;
+ }
+ return new Response(JSON.stringify(payload), {
+ headers: {
+ 'content-type': 'application/json; charset=utf-8',
+ 'cache-control': 'no-store',
+ },
+ });
+};
diff --git a/framework-tests/frameworks/sveltekit/files/schemas/.env.dev b/framework-tests/frameworks/sveltekit/files/schemas/.env.dev
new file mode 100644
index 000000000..9a4a93d84
--- /dev/null
+++ b/framework-tests/frameworks/sveltekit/files/schemas/.env.dev
@@ -0,0 +1,3 @@
+PUBLIC_STATIC_VAR=public-static-dev
+PUBLIC_DYNAMIC_VAR=public-dynamic-dev
+SECRET_VAR=super-secret-dev
diff --git a/framework-tests/frameworks/sveltekit/files/schemas/.env.prod b/framework-tests/frameworks/sveltekit/files/schemas/.env.prod
new file mode 100644
index 000000000..24124caaa
--- /dev/null
+++ b/framework-tests/frameworks/sveltekit/files/schemas/.env.prod
@@ -0,0 +1,3 @@
+PUBLIC_STATIC_VAR=public-static-prod
+PUBLIC_DYNAMIC_VAR=public-dynamic-prod
+SECRET_VAR=super-secret-prod
diff --git a/framework-tests/frameworks/sveltekit/files/schemas/.env.schema b/framework-tests/frameworks/sveltekit/files/schemas/.env.schema
new file mode 100644
index 000000000..7c9d2c561
--- /dev/null
+++ b/framework-tests/frameworks/sveltekit/files/schemas/.env.schema
@@ -0,0 +1,15 @@
+# @defaultSensitive=false
+# @defaultRequired=infer
+# @defaultDynamic=sensitive
+# @currentEnv=$APP_ENV
+# ---
+
+# @type=enum(dev, prod)
+APP_ENV=dev
+
+PUBLIC_STATIC_VAR=public-static-default
+# @dynamic
+PUBLIC_DYNAMIC_VAR=public-dynamic-default
+
+# @sensitive
+SECRET_VAR=super-secret-default
diff --git a/framework-tests/frameworks/sveltekit/sveltekit.test.ts b/framework-tests/frameworks/sveltekit/sveltekit.test.ts
new file mode 100644
index 000000000..dbde93b9e
--- /dev/null
+++ b/framework-tests/frameworks/sveltekit/sveltekit.test.ts
@@ -0,0 +1,106 @@
+import {
+ describe, beforeAll, afterAll,
+} from 'vitest';
+import { FrameworkTestEnv } from '../../harness/index';
+
+describe('SvelteKit', () => {
+ const sveltekitEnv = new FrameworkTestEnv({
+ testDir: import.meta.dirname,
+ framework: 'sveltekit',
+ packageManager: 'pnpm',
+ dependencies: {
+ '@sveltejs/adapter-static': '^3',
+ '@sveltejs/kit': '^2',
+ '@sveltejs/vite-plugin-svelte': '^4',
+ svelte: '^5',
+ vite: '^7',
+ varlock: 'will-be-replaced',
+ '@varlock/vite-integration': 'will-be-replaced',
+ },
+ packageJsonMerge: {
+ packageManager: 'pnpm@10.17.0',
+ },
+ templateFiles: {
+ '.env.schema': 'schemas/.env.schema',
+ '.env.dev': 'schemas/.env.dev',
+ '.env.prod': 'schemas/.env.prod',
+ 'src/routes/+page.svelte': 'pages/basic-page.svelte',
+ },
+ });
+
+ beforeAll(() => sveltekitEnv.setup(), 180_000);
+ afterAll(() => sveltekitEnv.teardown());
+
+ sveltekitEnv.describeScenario('static build: dynamic+public is not inlined', {
+ command: 'vite build',
+ fileAssertions: [
+ {
+ description: 'client bundle contains static public value',
+ fileGlob: '.svelte-kit/output/client/**/*.js',
+ shouldContain: ['public-static-dev'],
+ },
+ {
+ description: 'client bundle does not inline dynamic public value',
+ fileGlob: '.svelte-kit/output/client/**/*.js',
+ shouldContain: ['PUBLIC_DYNAMIC_VAR'],
+ shouldNotContain: ['public-dynamic-dev'],
+ },
+ {
+ description: 'sensitive value is absent from output',
+ fileGlob: '.svelte-kit/output/**/*.js',
+ shouldNotContain: [
+ 'super-secret-dev',
+ 'public-dynamic-dev',
+ ],
+ },
+ ],
+ });
+
+ sveltekitEnv.describeDevScenario('dev: dynamic+public is available at runtime', {
+ command: 'vite dev --port 14720',
+ readyPattern: /localhost:14720/,
+ readyTimeout: 30_000,
+ templateFiles: {
+ 'src/routes/__varlock/public-env/+server.ts': 'routes/public-env-endpoint.ts',
+ },
+ requests: [
+ {
+ path: '/__varlock/public-env',
+ bodyAssertions: {
+ shouldContain: ['"PUBLIC_DYNAMIC_VAR":"public-dynamic-dev"'],
+ shouldNotContain: ['super-secret-dev', 'SECRET_VAR'],
+ },
+ },
+ {
+ path: '/__varlock/public-env',
+ fileEditDelay: 2500,
+ fileEdits: {
+ '.env.dev': [
+ 'PUBLIC_STATIC_VAR=public-static-dev',
+ 'PUBLIC_DYNAMIC_VAR=public-dynamic-dev-updated',
+ 'SECRET_VAR=super-secret-dev',
+ ].join('\n'),
+ },
+ bodyAssertions: {
+ shouldContain: ['"PUBLIC_DYNAMIC_VAR":"public-dynamic-dev-updated"'],
+ shouldNotContain: ['super-secret-dev', 'SECRET_VAR'],
+ },
+ },
+ ],
+ });
+
+ sveltekitEnv.describeScenario('prerender + dynamic access is rejected (TODO)', {
+ skip: true,
+ command: 'vite build',
+ templateFiles: {
+ 'src/routes/+page.svelte': 'pages/prerender-dynamic-page.svelte',
+ },
+ expectSuccess: false,
+ outputAssertions: [
+ {
+ description: 'build error mentions dynamic var in prerender context',
+ shouldContain: ['PUBLIC_DYNAMIC_VAR', 'prerender'],
+ },
+ ],
+ });
+});
diff --git a/framework-tests/package.json b/framework-tests/package.json
index 93dcd694c..e2782f057 100644
--- a/framework-tests/package.json
+++ b/framework-tests/package.json
@@ -15,6 +15,7 @@
"test:nextjs": "vitest run frameworks/nextjs",
"test:vanilla-node": "vitest run frameworks/vanilla-node",
"test:tanstack-start": "vitest run frameworks/tanstack-start",
+ "test:sveltekit": "vitest run frameworks/sveltekit",
"test:vite": "vitest run frameworks/vite"
},
"devDependencies": {
diff --git a/packages/integrations/astro/README.md b/packages/integrations/astro/README.md
index a088c2220..37e8de010 100644
--- a/packages/integrations/astro/README.md
+++ b/packages/integrations/astro/README.md
@@ -22,3 +22,30 @@ While some of these features are similar to what can be accomplished via [`astro
- More data types and options available
- Leak detection, log redaction, and more security guardrails
- Works with various adapters and platforms to make your resolved config available
+- Automatically injects a server route for dynamic+public values (`/__varlock/public-env` by default)
+
+## Dynamic public endpoint
+
+The integration injects a JSON endpoint for `getPublicDynamicEnv()` in server/dev mode:
+
+- default behavior: auto-enabled only when your schema has dynamic+public items
+- default path when enabled: `/__varlock/public-env`
+- force-enable at default path:
+
+```ts
+varlockAstroIntegration({ publicDynamicEndpoint: true });
+```
+
+- disable it:
+
+```ts
+varlockAstroIntegration({ publicDynamicEndpoint: false });
+```
+
+- customize the path:
+
+```ts
+varlockAstroIntegration({
+ publicDynamicEndpoint: { path: '/api/public-env' },
+});
+```
diff --git a/packages/integrations/astro/src/index.ts b/packages/integrations/astro/src/index.ts
index a07f8a9c6..fb55a4976 100644
--- a/packages/integrations/astro/src/index.ts
+++ b/packages/integrations/astro/src/index.ts
@@ -1,28 +1,110 @@
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
-import { createDebug } from 'varlock';
+import { createDebug, type SerializedEnvGraph } from 'varlock';
+import { execSyncVarlock } from 'varlock/exec-sync-varlock';
import { scanForLeaks, varlockSettings } from 'varlock/env';
-import { varlockVitePlugin } from '@varlock/vite-integration';
+import { varlockVitePlugin, type VarlockVitePluginOptions } from '@varlock/vite-integration';
import type { AstroIntegration } from 'astro';
const debug = createDebug('varlock:astro-integration');
+const DEFAULT_PUBLIC_DYNAMIC_ENDPOINT = '/__varlock/public-env';
+const PUBLIC_DYNAMIC_ROUTE_ENTRYPOINT = fileURLToPath(
+ new URL('./public-dynamic-env-route.js', import.meta.url),
+);
debug('Loaded varlock astro integration file');
+interface VarlockAstroPublicDynamicEndpointOptions {
+ /** Route path for public dynamic values */
+ path?: string,
+}
+
+export interface VarlockAstroIntegrationOptions extends VarlockVitePluginOptions {
+ /**
+ * Inject a route that returns `getPublicDynamicEnv()`.
+ * - `undefined`: auto (enabled only when dynamic+public config exists)
+ * - `true`: always enabled at `DEFAULT_PUBLIC_DYNAMIC_ENDPOINT`
+ * - `false`: disabled
+ * - object: enabled with custom options
+ */
+ publicDynamicEndpoint?: boolean | VarlockAstroPublicDynamicEndpointOptions,
+}
+
+function hasDynamicPublicConfigInSchema(cwd?: string): boolean {
+ try {
+ const { stdout } = execSyncVarlock('load --format json-full --compact', {
+ fullResult: true,
+ ...(cwd && { cwd }),
+ });
+ const loadedEnv = JSON.parse(stdout) as SerializedEnvGraph;
+ return Object.values(loadedEnv.config || {}).some((itemInfo) => itemInfo.isDynamic && !itemInfo.isSensitive);
+ } catch (err) {
+ debug('Failed to auto-detect dynamic+public config, defaulting to endpoint enabled', err);
+ // Fail open in auto mode so the endpoint remains available.
+ return true;
+ }
+}
+
+function shouldInjectPublicDynamicEndpoint(
+ option: VarlockAstroIntegrationOptions['publicDynamicEndpoint'],
+ cwd?: string,
+): boolean {
+ if (option === false) return false;
+ if (option === true || typeof option === 'object') return true;
+ // Auto mode: only inject when dynamic+public keys exist.
+ return hasDynamicPublicConfigInSchema(cwd);
+}
+
+function resolvePublicDynamicEndpointPath(
+ option: VarlockAstroIntegrationOptions['publicDynamicEndpoint'],
+ cwd?: string,
+): string | null {
+ if (!shouldInjectPublicDynamicEndpoint(option, cwd)) return null;
+ const configuredPath = (typeof option === 'object' && option.path) || DEFAULT_PUBLIC_DYNAMIC_ENDPOINT;
+ if (!configuredPath.startsWith('/')) {
+ throw new Error('[varlock] `publicDynamicEndpoint.path` must start with "/"');
+ }
+ return configuredPath;
+}
+
function varlockAstroIntegration(
- // re-expose all options from vite plugin
- integrationOptions?: Parameters[0],
+ integrationOptions?: VarlockAstroIntegrationOptions,
): AstroIntegration {
+ const {
+ publicDynamicEndpoint,
+ ...vitePluginOptions
+ } = integrationOptions ?? {};
+
return {
name: 'varlock-astro-integration',
hooks: {
+ 'astro:config:setup': ({ command, config, injectRoute }) => {
+ const routePath = resolvePublicDynamicEndpointPath(publicDynamicEndpoint, fileURLToPath(config.root));
+ if (!routePath) return;
+
+ // Server-only route handlers are not supported in static production builds.
+ // We still inject during `astro dev`, so local development stays consistent.
+ if (command === 'build' && config.output !== 'server') {
+ debug(
+ `Skipping "${routePath}" injection for static build output. Set output="server" to enable dynamic public env route.`,
+ );
+ return;
+ }
+
+ injectRoute({
+ pattern: routePath,
+ entrypoint: PUBLIC_DYNAMIC_ROUTE_ENTRYPOINT,
+ prerender: false,
+ });
+ },
+
// docs say to use astro:config:setup hook to adjust vite config
// but we wait to until here because we don't know the adapter yet
// and we want to use that to infer ssrInjectMode
'astro:config:done': async (opts) => {
const adapterName = opts.config.adapter?.name;
- let ssrInjectMode = integrationOptions?.ssrInjectMode;
+ let ssrInjectMode = vitePluginOptions.ssrInjectMode;
if (['@astrojs/netlify', '@astrojs/vercel', '@astrojs/cloudflare'].includes(adapterName || '')) {
ssrInjectMode ??= 'resolved-env';
} else if (adapterName === '@astrojs/node') {
@@ -32,7 +114,7 @@ function varlockAstroIntegration(
opts.config.vite.plugins ||= [];
opts.config.vite?.plugins?.push(
varlockVitePlugin({
- ...integrationOptions,
+ ...vitePluginOptions,
ssrInjectMode,
}) as any,
);
diff --git a/packages/integrations/astro/src/public-dynamic-env-route.ts b/packages/integrations/astro/src/public-dynamic-env-route.ts
new file mode 100644
index 000000000..08065f78a
--- /dev/null
+++ b/packages/integrations/astro/src/public-dynamic-env-route.ts
@@ -0,0 +1,12 @@
+import type { APIRoute } from 'astro';
+import { getPublicDynamicEnv } from 'varlock/env';
+
+export const prerender = false;
+
+export const GET: APIRoute = () => new Response(
+ JSON.stringify(getPublicDynamicEnv()),
+ {
+ status: 200,
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
+ },
+);
diff --git a/packages/integrations/astro/tsup.config.ts b/packages/integrations/astro/tsup.config.ts
index 32c344d4f..6545b7bf8 100644
--- a/packages/integrations/astro/tsup.config.ts
+++ b/packages/integrations/astro/tsup.config.ts
@@ -3,7 +3,7 @@ import { defineConfig } from 'tsup';
export default defineConfig({
entry: [ // Entry point(s)
'src/index.ts',
- 'src/astro-middleware.ts',
+ 'src/public-dynamic-env-route.ts',
],
dts: true,
diff --git a/packages/integrations/expo/src/babel-plugin.ts b/packages/integrations/expo/src/babel-plugin.ts
index b2c4fde62..895aed15a 100644
--- a/packages/integrations/expo/src/babel-plugin.ts
+++ b/packages/integrations/expo/src/babel-plugin.ts
@@ -93,7 +93,7 @@ function valueToNode(t: BabelAPI['types'], value: unknown): object {
* Babel plugin for Expo/React Native projects that integrates varlock.
*
* Replaces `ENV.xxx` member expressions with their static values at compile time
- * for non-sensitive config items. Sensitive items are NOT inlined and are only
+ * for non-dynamic config items. Dynamic items are NOT inlined and are only
* accessible at runtime in Expo server routes (+api files) via the ENV proxy.
* Accessing a sensitive value in native code will emit a build-time warning.
*
@@ -121,16 +121,18 @@ export default function varlockExpoBabelPlugin(api: BabelAPI) {
const { config } = varlockLoadedEnv;
const t = api.types;
- // Build the set of non-sensitive keys that can be statically replaced
+ // Build the set of non-dynamic keys that can be statically replaced
const warnedSensitiveKeys = new Set();
- const nonSensitiveKeys = new Set();
+ const staticKeys = new Set();
for (const itemKey in config) {
- if (!config[itemKey].isSensitive) {
- nonSensitiveKeys.add(itemKey);
+ const item = config[itemKey];
+ const isDynamic = item.isDynamic ?? item.isSensitive;
+ if (!isDynamic) {
+ staticKeys.add(itemKey);
}
}
- debug('static replacements keys', [...nonSensitiveKeys]);
+ debug('static replacements keys', [...staticKeys]);
return {
name: 'varlock-expo-integration',
@@ -147,14 +149,16 @@ export default function varlockExpoBabelPlugin(api: BabelAPI) {
) {
const key = node.property.name as string;
- if (nonSensitiveKeys.has(key)) {
+ if (staticKeys.has(key)) {
const item = config[key];
nodePath.replaceWith(valueToNode(t, item.value));
debug(`replaced ENV.${key} with static value`);
- } else if (config[key]?.isSensitive) {
- debug(`ENV.${key} is sensitive - skipping static replacement`);
+ } else if (config[key]) {
+ const isDynamic = config[key].isDynamic ?? config[key].isSensitive;
+ if (!isDynamic) return;
+ debug(`ENV.${key} is dynamic - skipping static replacement`);
- if (!isServerFile(state.filename) && !warnedSensitiveKeys.has(key)) {
+ if (config[key].isSensitive && !isServerFile(state.filename) && !warnedSensitiveKeys.has(key)) {
warnedSensitiveKeys.add(key);
// eslint-disable-next-line no-console
console.warn([
diff --git a/packages/integrations/expo/test/babel-plugin.test.ts b/packages/integrations/expo/test/babel-plugin.test.ts
index 7cbb4acd1..5c8e7bc21 100644
--- a/packages/integrations/expo/test/babel-plugin.test.ts
+++ b/packages/integrations/expo/test/babel-plugin.test.ts
@@ -157,6 +157,11 @@ describe('varlockExpoBabelPlugin – sensitive values are NOT replaced', () => {
const replaceWith = visitMemberExpression('ENV', 'SECRET_KEY', false, '/app/api/route+api.ts');
expect(replaceWith).not.toHaveBeenCalled();
});
+
+ it('does NOT replace ENV.PUBLIC_DYNAMIC_URL (isDynamic=true)', () => {
+ const replaceWith = visitMemberExpression('ENV', 'PUBLIC_DYNAMIC_URL');
+ expect(replaceWith).not.toHaveBeenCalled();
+ });
});
describe('varlockExpoBabelPlugin – unknown / irrelevant expressions are skipped', () => {
diff --git a/packages/integrations/expo/test/fixtures/.env.schema b/packages/integrations/expo/test/fixtures/.env.schema
index 58f4f5dc9..f26dea964 100644
--- a/packages/integrations/expo/test/fixtures/.env.schema
+++ b/packages/integrations/expo/test/fixtures/.env.schema
@@ -4,6 +4,7 @@ API_URL=https://api.example.com
PORT=3000
DEBUG=true
EMPTY=
+PUBLIC_DYNAMIC_URL=https://runtime.example.com # @public @dynamic
# @sensitive
SECRET_KEY=s3cr3t
diff --git a/packages/integrations/expo/test/metro-config.test.ts b/packages/integrations/expo/test/metro-config.test.ts
index 47baa8300..9afa83c35 100644
--- a/packages/integrations/expo/test/metro-config.test.ts
+++ b/packages/integrations/expo/test/metro-config.test.ts
@@ -28,8 +28,8 @@ const FAKE_ENV_GRAPH = {
sources: [],
settings: {},
config: {
- API_URL: { value: 'https://api.example.com', isSensitive: false },
- SECRET_KEY: { value: 's3cr3t', isSensitive: true },
+ API_URL: { value: 'https://api.example.com', isSensitive: false, isDynamic: false },
+ SECRET_KEY: { value: 's3cr3t', isSensitive: true, isDynamic: true },
},
};
diff --git a/packages/integrations/nextjs/package.json b/packages/integrations/nextjs/package.json
index a5f9c9aa1..99fa086fb 100644
--- a/packages/integrations/nextjs/package.json
+++ b/packages/integrations/nextjs/package.json
@@ -12,7 +12,8 @@
"exports": {
".": "./dist/next-env-compat.js",
"./plugin": "./dist/plugin.js",
- "./loader": "./dist/loader.js"
+ "./loader": "./dist/loader.js",
+ "./dynamic-access": "./dist/dynamic-access.js"
},
"files": ["dist"],
"scripts": {
diff --git a/packages/integrations/nextjs/src/dynamic-access.ts b/packages/integrations/nextjs/src/dynamic-access.ts
new file mode 100644
index 000000000..1af5d487d
--- /dev/null
+++ b/packages/integrations/nextjs/src/dynamic-access.ts
@@ -0,0 +1,85 @@
+type DynamicConfigAccessMeta = {
+ key: string,
+ isPublic: boolean,
+};
+
+type DynamicConfigAccessHook = ((meta: DynamicConfigAccessMeta) => void) & {
+ _varlockNextjsWrapped?: boolean,
+};
+
+let cachedHeadersFn: undefined | (() => unknown) | null;
+
+function debug(...args: Array) {
+ if (!process.env.DEBUG_VARLOCK_NEXT_INTEGRATION) return;
+ // eslint-disable-next-line no-console
+ console.log('[varlock-next-dynamic-access]', ...args);
+}
+
+function getNextHeadersFn() {
+ if (cachedHeadersFn !== undefined) return cachedHeadersFn;
+ const candidates = [
+ 'next/headers',
+ 'next/dist/api/headers',
+ 'next/dist/server/request/headers',
+ ];
+ for (const candidate of candidates) {
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const mod = require(candidate);
+ if (typeof mod?.headers === 'function') {
+ debug(`resolved headers() from ${candidate}`);
+ cachedHeadersFn = mod.headers;
+ return cachedHeadersFn;
+ }
+ } catch (err) {
+ debug(`failed loading ${candidate}: ${String((err as any)?.message ?? err)}`);
+ }
+ }
+ cachedHeadersFn = null;
+ return cachedHeadersFn;
+}
+
+/**
+ * Installs a global callback used by varlock/env on dynamic ENV access.
+ * During valid Next request rendering, calling headers() marks the route dynamic.
+ */
+export function initVarlockNextDynamicAccess() {
+ const existingHook = (globalThis as any).__varlockOnDynamicConfigAccess as DynamicConfigAccessHook | undefined;
+ if (existingHook?._varlockNextjsWrapped) return;
+ debug('installing dynamic config access hook');
+
+ const wrappedHook: DynamicConfigAccessHook = (meta) => {
+ existingHook?.(meta);
+ if (!meta) return;
+ debug(`hook invoked for key=${meta.key} isPublic=${meta.isPublic}`);
+
+ const headersFn = getNextHeadersFn();
+ if (!headersFn) {
+ debug('next/headers headers() function unavailable');
+ return;
+ }
+
+ try {
+ debug(`calling headers() for ENV.${meta.key}`);
+ headersFn();
+ debug(`headers() call completed for ENV.${meta.key}`);
+ } catch (err) {
+ // headers() is only valid in request render contexts.
+ // Outside that context we no-op so build/module init paths don't break.
+ const msg = String((err as any)?.message ?? err ?? '');
+ if (
+ msg.includes('outside a request scope')
+ || msg.includes('outside of a request')
+ || msg.includes('requestAsyncStorage')
+ ) {
+ debug(`headers() no-op outside request context for ENV.${meta.key}: ${msg}`);
+ return;
+ }
+ debug(`headers() threw for ENV.${meta.key}: ${msg}`);
+ throw err;
+ }
+ };
+
+ wrappedHook._varlockNextjsWrapped = true;
+ (globalThis as any).__varlockOnDynamicConfigAccess = wrappedHook;
+}
diff --git a/packages/integrations/nextjs/src/loader.ts b/packages/integrations/nextjs/src/loader.ts
index ffc29e734..7b1f1dcbb 100644
--- a/packages/integrations/nextjs/src/loader.ts
+++ b/packages/integrations/nextjs/src/loader.ts
@@ -46,11 +46,11 @@ function prependAfterDirectives(source: string, codeToPrepend: string): string {
/**
* Webpack/Turbopack loader that:
* 1. Injects resolved env config into instrumentation and proxy files.
- * 2. Replaces `ENV.KEY` references for non-sensitive vars with literal JSON values.
+ * 2. Replaces `ENV.KEY` references for static vars with literal JSON values.
*
- * SECURITY: Sensitive env values are ONLY embedded into server-side files
- * (never client components). The static ENV.KEY replacements explicitly skip
- * sensitive vars.
+ * SECURITY: Dynamic env values are not embedded at build time, and sensitive
+ * values are expected to stay dynamic by default. The static ENV.KEY
+ * replacements explicitly skip dynamic vars.
*/
function webpackLoader(this: LoaderContext, source: string) {
// only transform files within the project root
@@ -105,7 +105,7 @@ function webpackLoader(this: LoaderContext, source: string) {
// is handled by the runtime file injection (processAssets hook).
initGuard = 'if(globalThis.__varlockPatchConsole)globalThis.__varlockPatchConsole();';
} else {
- initGuard = 'if(!globalThis.__varlockBuildInit){globalThis.__varlockBuildInit=true;require(\'varlock/env\').initVarlockEnv();require(\'varlock/patch-console\').patchGlobalConsole();}';
+ initGuard = 'if(!globalThis.__varlockBuildInit){globalThis.__varlockBuildInit=true;require(\'varlock/env\').initVarlockEnv();require(\'varlock/patch-console\').patchGlobalConsole();require(\'@varlock/nextjs-integration/dynamic-access\').initVarlockNextDynamicAccess();}';
// When used from webpack, React wraps console for RSC dev replay AFTER our initial
// patch in the runtime file. Re-patching outside the once-guard ensures our redaction
// wraps React's wrapper so secrets are redacted before React captures them.
@@ -117,13 +117,41 @@ function webpackLoader(this: LoaderContext, source: string) {
result = prependAfterDirectives(result, initGuard);
}
- // static replacements for non-sensitive env vars
+ if (!isClientComponent && source.includes('ENV.')) {
+ const dynamicKeys = Object.entries(envGraph.config)
+ .filter(([, item]) => (item.isDynamic ?? item.isSensitive))
+ .map(([key]) => key);
+
+ if (dynamicKeys.length) {
+ const markerFn = '__varlockMarkDynamicAccess';
+ const markerImport = '__varlockHeadersForDynamicAccess';
+ let hasDynamicRewrites = false;
+
+ for (const key of dynamicKeys) {
+ const pattern = new RegExp(`\\bENV\\.${escapeRegExp(key)}(?![\\w$])`, 'g');
+ if (!pattern.test(result)) continue;
+ hasDynamicRewrites = true;
+ result = result.replace(pattern, `(${markerFn}(), ENV.${key})`);
+ }
+
+ if (hasDynamicRewrites && !result.includes(`const ${markerFn} =`)) {
+ const dynamicAccessPrelude = [
+ `import { headers as ${markerImport} } from 'next/headers';`,
+ `const ${markerFn} = () => { ${markerImport}(); };`,
+ ].join('\n');
+ result = prependAfterDirectives(result, dynamicAccessPrelude);
+ }
+ }
+ }
+
+ // static replacements for non-dynamic env vars
// webpack uses DefinePlugin for this, so only needed for turbopack
if (isTurbopack && source.includes('ENV.')) {
// disable caching only for files that reference ENV — their output depends on env values
this.cacheable(false);
for (const [key, item] of Object.entries(envGraph.config)) {
- if (item.isSensitive) continue;
+ const isDynamic = item.isDynamic ?? item.isSensitive;
+ if (isDynamic) continue;
// TODO: smarter replacement (vite version uses AST?)
diff --git a/packages/integrations/nextjs/src/next-env-compat.ts b/packages/integrations/nextjs/src/next-env-compat.ts
index a73459ddf..0360f0d43 100644
--- a/packages/integrations/nextjs/src/next-env-compat.ts
+++ b/packages/integrations/nextjs/src/next-env-compat.ts
@@ -419,6 +419,7 @@ type LoadedEnvConfig = {
let loadCount = 0;
let suppressSkipLogUntil = 0;
+let hasLoadedEnvInThisProcess = false;
export function loadEnvConfig(
dir: string,
@@ -459,7 +460,10 @@ export function loadEnvConfig(
lastLoadedSourceStateHash = computeSourceStateHash(varlockLoadedEnv.sources, varlockLoadedEnv.basePath);
}
- let useCachedEnv = !!process.env.__VARLOCK_ENV;
+ let useCachedEnv = !!process.env.__VARLOCK_ENV && hasLoadedEnvInThisProcess;
+ if (!useCachedEnv && process.env.__VARLOCK_ENV && !hasLoadedEnvInThisProcess) {
+ debug('ignoring inherited __VARLOCK_ENV cache on first process load');
+ }
if (forceReload) {
// Throttle reloads to at most once per second to avoid spinning during
// rapid file-change bursts (Next.js may fire multiple events per edit)
@@ -508,6 +512,7 @@ export function loadEnvConfig(
if (dev) enableExtraFileWatchers(varlockLoadedEnv.sources, varlockLoadedEnv.basePath);
debug('>> USING CACHED ENV');
+ hasLoadedEnvInThisProcess = true;
return { combinedEnv, parsedEnv, loadedEnvFiles };
}
@@ -531,6 +536,7 @@ export function loadEnvConfig(
const { stdout } = execSyncVarlock(`load --format json-full --env ${envFromNextCommand}`, {
fullResult: true,
env: cleanEnv as any,
+ cwd: rootDir || dir,
});
if (loadCount >= 2 && forceReload) {
const envChanged = stdout !== previousSerializedEnv;
@@ -617,6 +623,8 @@ export function loadEnvConfig(
enableExtraFileWatchers(varlockLoadedEnv.sources, varlockLoadedEnv.basePath);
}
+ hasLoadedEnvInThisProcess = true;
+
return { combinedEnv: { ...initialEnv }, parsedEnv: {}, loadedEnvFiles: [] };
}
@@ -644,6 +652,7 @@ export function loadEnvConfig(
// pre-resolved env values at runtime (they don't re-run @next/env on boot)
// TODO: re-enable once we verify instrumentation approach works for prod
// if (!dev) writeResolvedEnvFile();
+ hasLoadedEnvInThisProcess = true;
return { combinedEnv, parsedEnv, loadedEnvFiles };
}
diff --git a/packages/integrations/nextjs/src/plugin.ts b/packages/integrations/nextjs/src/plugin.ts
index a9078fbdf..c3ca1629b 100644
--- a/packages/integrations/nextjs/src/plugin.ts
+++ b/packages/integrations/nextjs/src/plugin.ts
@@ -9,6 +9,7 @@ import {
} from 'varlock/env';
import { patchGlobalConsole } from 'varlock/patch-console';
import { patchGlobalServerResponse } from 'varlock/patch-server-response';
+import { execSyncVarlock } from 'varlock/exec-sync-varlock';
import { type SerializedEnvGraph } from 'varlock';
import { createWebpackConfigFn } from './webpack-plugin';
@@ -64,6 +65,44 @@ function debug(...args: Array) {
}
debug('✨ LOADED @varlock/next-integration/plugin module!');
+function getEnvFromNextCommandForPlugin() {
+ if (process.env.NODE_ENV === 'test') return 'test';
+ return process.env.NODE_ENV === 'production' ? 'production' : 'development';
+}
+
+function hydrateVarlockEnvForPlugin() {
+ const cwd = process.cwd();
+ const inherited = process.env.__VARLOCK_ENV;
+ if (inherited) {
+ try {
+ const inheritedGraph = JSON.parse(inherited) as SerializedEnvGraph;
+ if (inheritedGraph.basePath) {
+ const inheritedBasePath = path.resolve(inheritedGraph.basePath);
+ if (inheritedBasePath.startsWith(cwd)) {
+ debug('using existing __VARLOCK_ENV in plugin process', { inheritedBasePath, cwd });
+ return;
+ }
+ }
+ debug('ignoring inherited __VARLOCK_ENV from another project context');
+ } catch {
+ debug('failed to parse inherited __VARLOCK_ENV, reloading');
+ }
+ }
+
+ const envFromNextCommand = getEnvFromNextCommandForPlugin();
+ const cleanEnv = { ...process.env } as Record;
+ delete cleanEnv.DEBUG_VARLOCK;
+ const { stdout } = execSyncVarlock(`load --format json-full --env ${envFromNextCommand}`, {
+ fullResult: true,
+ env: cleanEnv as any,
+ cwd,
+ });
+ process.env.__VARLOCK_ENV = stdout;
+ debug('hydrated __VARLOCK_ENV in plugin process from varlock load');
+}
+
+hydrateVarlockEnvForPlugin();
+
type VarlockPluginOptions = {
// injectResolvedConfigAtBuildTime: boolean,
};
diff --git a/packages/integrations/nextjs/src/webpack-plugin.ts b/packages/integrations/nextjs/src/webpack-plugin.ts
index beffe2513..23eed6d5f 100644
--- a/packages/integrations/nextjs/src/webpack-plugin.ts
+++ b/packages/integrations/nextjs/src/webpack-plugin.ts
@@ -24,7 +24,8 @@ function createStaticReplacementsProxy(debug: (...args: Array) => void) {
const replaceKeys = [] as Array;
for (const itemKey in latestLoadedVarlockEnv.config) {
const item = latestLoadedVarlockEnv.config[itemKey];
- if (!item.isSensitive) replaceKeys.push(`ENV.${itemKey}`);
+ const isDynamic = item.isDynamic ?? item.isSensitive;
+ if (!isDynamic) replaceKeys.push(`ENV.${itemKey}`);
}
debug('reloaded static replacements keys', replaceKeys);
return replaceKeys;
@@ -32,7 +33,9 @@ function createStaticReplacementsProxy(debug: (...args: Array) => void) {
getOwnPropertyDescriptor(_target, prop) {
const itemKey = prop.toString().split('.')[1];
const item = latestLoadedVarlockEnv?.config[itemKey];
- if (!item || item.isSensitive) return;
+ if (!item) return;
+ const isDynamic = item.isDynamic ?? item.isSensitive;
+ if (isDynamic) return;
return {
value: '', // this value is not used, the get handler will return the value
writable: false,
@@ -43,7 +46,9 @@ function createStaticReplacementsProxy(debug: (...args: Array) => void) {
get(_target, prop) {
const itemKey = prop.toString().split('.')[1];
const item = latestLoadedVarlockEnv?.config[itemKey];
- if (item && !item.isSensitive) return JSON.stringify(item.value);
+ if (!item) return;
+ const isDynamic = item.isDynamic ?? item.isSensitive;
+ if (!isDynamic) return JSON.stringify(item.value);
},
});
}
diff --git a/packages/integrations/nextjs/tsup.config.ts b/packages/integrations/nextjs/tsup.config.ts
index 04a36e519..804ad318d 100644
--- a/packages/integrations/nextjs/tsup.config.ts
+++ b/packages/integrations/nextjs/tsup.config.ts
@@ -27,6 +27,7 @@ export default defineConfig([
entry: [
'src/plugin.ts',
'src/loader.ts',
+ 'src/dynamic-access.ts',
],
dts: true,
diff --git a/packages/integrations/vite/src/index.ts b/packages/integrations/vite/src/index.ts
index e4087c725..02cf68029 100644
--- a/packages/integrations/vite/src/index.ts
+++ b/packages/integrations/vite/src/index.ts
@@ -52,20 +52,26 @@ export let varlockLastError: string | undefined;
let lastErrorAt = 0;
let configHookCalled = false;
let staticReplacements: Record = {};
+let dynamicPublicKeys: Array = [];
let replacerFn: ReturnType;
function resetStaticReplacements() {
staticReplacements = {};
+ dynamicPublicKeys = [];
for (const itemKey in varlockLoadedEnv?.config) {
const itemInfo = varlockLoadedEnv.config[itemKey];
- // TODO: probably reimplement static/dynamic controls here too
- if (!itemInfo.isSensitive) {
+ const isDynamic = itemInfo.isDynamic ?? itemInfo.isSensitive;
+ if (isDynamic && !itemInfo.isSensitive) {
+ dynamicPublicKeys.push(itemKey);
+ }
+ if (!isDynamic) {
// we have to pass in a string of 'undefined' so it gets replaced properly
const val = itemInfo.value === undefined ? 'undefined' : JSON.stringify(itemInfo.value);
staticReplacements[`ENV.${itemKey}`] = val;
}
}
+ (globalThis as any).__varlockDynamicPublicKeys = dynamicPublicKeys;
debug('static replacements', staticReplacements);
@@ -165,6 +171,7 @@ export function varlockVitePlugin(
'// Virtual module generated by @varlock/vite-integration',
'// Runs before any user code to ensure ENV is available at module top-level',
'globalThis.__varlockThrowOnMissingKeys = true;',
+ `globalThis.__varlockDynamicPublicKeys = ${JSON.stringify(dynamicPublicKeys)};`,
];
const encryptionRequired = varlockLoadedEnv?.settings?.encryptInjectedEnv;
@@ -267,6 +274,11 @@ See https://varlock.dev/integrations/vite/ for more details.
}
isDevCommand = env.command === 'serve';
+ if (env.command === 'build') {
+ process.env._VARLOCK_EXECUTION_PHASE = 'build';
+ } else {
+ delete process.env._VARLOCK_EXECUTION_PHASE;
+ }
// Determine the project root for the current Vite/Vitest project.
// In monorepo setups with Vitest workspace projects, config.root
@@ -392,6 +404,9 @@ See https://varlock.dev/integrations/vite/ for more details.
`globalThis.__varlockValidKeys = ${JSON.stringify(Object.keys(varlockLoadedEnv?.config || {}))};`,
);
}
+ injectCode.push(
+ `globalThis.__varlockDynamicPublicKeys = ${JSON.stringify(dynamicPublicKeys)};`,
+ );
}
injectCode.push('// -------- ');
@@ -437,8 +452,8 @@ See https://varlock.dev/integrations/vite/ for more details.
(_fullMatch, itemKey) => {
if (!varlockLoadedEnv.config[itemKey]) {
throw new Error(`Config item \`${itemKey}\` does not exist`);
- } else if (varlockLoadedEnv.config[itemKey].isSensitive) {
- throw new Error(`Config item \`${itemKey}\` is sensitive and cannot be used in html replacements`);
+ } else if ((varlockLoadedEnv.config[itemKey].isDynamic ?? varlockLoadedEnv.config[itemKey].isSensitive)) {
+ throw new Error(`Config item \`${itemKey}\` is dynamic and cannot be used in html replacements`);
} else {
// undefined will be turned into empty string in html replacements
return varlockLoadedEnv.config[itemKey].value ?? '';
diff --git a/packages/varlock-website/src/content/docs/guides/dynamic-config.mdx b/packages/varlock-website/src/content/docs/guides/dynamic-config.mdx
new file mode 100644
index 000000000..f7acfde42
--- /dev/null
+++ b/packages/varlock-website/src/content/docs/guides/dynamic-config.mdx
@@ -0,0 +1,94 @@
+---
+title: Static vs Dynamic Config
+description: How to control build-time replacement vs runtime resolution, and how to use dynamic+public values safely
+---
+
+Varlock separates two concerns:
+
+- **sensitive vs public**: security boundary (can this be exposed to clients?)
+- **static vs dynamic**: execution-time boundary (can this be replaced at build time?)
+
+These are related, but not the same.
+
+## Defaults and decorators
+
+By default, static/dynamic behavior follows sensitivity:
+
+```env-spec title=".env.schema"
+# @defaultDynamic=sensitive
+# ---
+PUBLIC_FOO= # static by default
+# @sensitive
+SECRET_BAR= # dynamic by default
+```
+
+Override per item with:
+- [`@dynamic`](/reference/item-decorators/#dynamic)
+- [`@static`](/reference/item-decorators/#static)
+
+Or override globally with [`@defaultDynamic`](/reference/root-decorators/#defaultdynamic):
+- `sensitive` (default)
+- `true` (all dynamic unless overridden)
+- `false` (all static unless overridden)
+
+## Typical patterns
+
+- `static + public` (most common): safe to inline in client bundles
+- `dynamic + sensitive` (most common): runtime-only secrets
+- `dynamic + public` (valid): runtime feature flags, environment banners, etc.
+- `static + sensitive` (rare, use with care): server-only build-time replacement
+
+## Runtime helpers for dynamic+public
+
+When public values are dynamic, the client must load them at runtime:
+
+- `getPublicDynamicEnv(keys?)` — server-side helper for endpoint payloads
+- `loadPublicDynamicEnv(opts?)` — client-side fetch + hydrate helper
+- `ENV.MY_VAR` — keep using the same access pattern after hydration
+
+Example:
+
+```ts title="server endpoint"
+import { getPublicDynamicEnv } from 'varlock/env';
+
+export function GET() {
+ return Response.json(getPublicDynamicEnv(), {
+ headers: { 'cache-control': 'no-store' },
+ });
+}
+```
+
+```ts title="client code"
+import { ENV, loadPublicDynamicEnv } from 'varlock/env';
+
+await loadPublicDynamicEnv({ endpoint: '/__varlock/public-env' });
+console.log(ENV.PUBLIC_RUNTIME_FLAG);
+```
+
+## Framework behavior
+
+| Framework | Dynamic server access | Dynamic+public endpoint |
+| :-- | :-- | :-- |
+| Next.js | Dynamic access via `ENV.KEY` in server render paths marks route dynamic | Add a route handler (recommended default path: `/__varlock/public-env`) |
+| Astro | Works in SSR paths | Auto-injected endpoint in SSR mode (configurable) |
+| SvelteKit | Works in SSR paths | Add a `+server.ts` endpoint |
+| Vite (generic) | Works in SSR/server runtime | Add your own server endpoint |
+
+See framework-specific recipes:
+- [Next.js](/integrations/nextjs/)
+- [Astro](/integrations/astro/)
+- [SvelteKit](/integrations/sveltekit/)
+- [Vite](/integrations/vite/)
+
+## Prerender/build guardrails
+
+Dynamic config should not be consumed in static prerender/build contexts unless explicitly designed for it.
+
+Varlock runtime can detect this and warn/error when dynamic keys are accessed during prerender/build phases, helping catch accidental usage early.
+
+## Guidance
+
+- Start with `@defaultDynamic=sensitive` for predictable defaults.
+- Mark intentional runtime public values with `@dynamic`.
+- Keep dynamic+public loading scoped to only the parts of your app that need it.
+- Prefer one app-level endpoint/payload shape per app unless you have a strong reason to split.
diff --git a/packages/varlock-website/src/content/docs/integrations/astro.mdx b/packages/varlock-website/src/content/docs/integrations/astro.mdx
index d5c5705ea..67ab63d58 100644
--- a/packages/varlock-website/src/content/docs/integrations/astro.mdx
+++ b/packages/varlock-website/src/content/docs/integrations/astro.mdx
@@ -75,6 +75,61 @@ console.log(ENV.SOMEVAR); // ✨ recommended
- Better error messages for invalid or unavailable keys
- Enables future DX improvements and tighter control over what is bundled
+## Dynamic+public config
+
+Use [`@dynamic`](/reference/item-decorators/#dynamic) to keep selected public values runtime-resolved:
+For the shared model and tradeoffs, see the [Static vs Dynamic Config guide](/guides/dynamic-config/).
+
+```env-spec title=".env.schema"
+# @defaultSensitive=inferFromPrefix('PUBLIC_')
+# @defaultDynamic=sensitive
+# ---
+PUBLIC_STATIC_FLAG=enabled
+PUBLIC_RUNTIME_FLAG=enabled # @dynamic
+```
+
+### Server rendering
+
+In server-rendered Astro code, access dynamic values via `ENV.KEY` as usual.
+
+### Client-side usage
+
+When the schema contains any dynamic+public keys, this integration automatically injects a route at `/__varlock/public-env` (SSR builds only). Load it in the browser with `loadPublicDynamicEnv()`:
+
+```astro title="src/components/MyClientThing.astro"
+---
+---
+
+```
+
+:::note[Static output]
+If `output: 'static'`, there is no server route to fetch runtime public values from. Dynamic+public values require a server-rendered deployment target.
+:::
+
+### Route injection options
+
+You can control endpoint injection:
+
+```ts title="astro.config.ts"
+import { defineConfig } from 'astro/config';
+import varlockAstroIntegration from '@varlock/astro-integration';
+
+export default defineConfig({
+ integrations: [
+ varlockAstroIntegration({
+ publicDynamicEndpoint: true, // auto by default
+ // or: { path: '/my-public-env' }
+ // or: false (disable auto endpoint)
+ }),
+ ],
+});
+```
+
### Within `astro.config.*`
It's often useful to be able to access env vars in your Astro config. Without varlock, it's a bit awkward, but varlock makes it dead simple - in fact it's already available! Just import varlock's `ENV` object and reference env vars via `ENV.SOME_ITEM` like you do everywhere else.
diff --git a/packages/varlock-website/src/content/docs/integrations/nextjs.mdx b/packages/varlock-website/src/content/docs/integrations/nextjs.mdx
index 4f913a507..b11b45b4d 100644
--- a/packages/varlock-website/src/content/docs/integrations/nextjs.mdx
+++ b/packages/varlock-website/src/content/docs/integrations/nextjs.mdx
@@ -191,6 +191,79 @@ To enable type-safety and IntelliSense for your env vars, enable the [`@generate
- Better error messages for invalid or unavailable keys
- Enables future DX improvements and tighter control over what is bundled
+## Static vs dynamic config
+
+Varlock separates:
+- **sensitive vs public** (security boundary)
+- **dynamic vs static** (runtime vs build-time replacement)
+
+For a full mental model and cross-framework patterns, see the [Static vs Dynamic Config guide](/guides/dynamic-config/).
+
+By default, dynamic behavior follows sensitivity (`@defaultDynamic=sensitive`), but you can override per item with [`@dynamic`](/reference/item-decorators/#dynamic) / [`@static`](/reference/item-decorators/#static).
+
+```env-spec title=".env.schema"
+# @defaultSensitive=inferFromPrefix('NEXT_PUBLIC_')
+# @defaultDynamic=sensitive
+# ---
+NEXT_PUBLIC_STATIC_FLAG=enabled
+NEXT_PUBLIC_RUNTIME_FLAG=enabled # @dynamic
+SECRET_API_KEY= # dynamic by default
+```
+
+### Server-rendered access
+
+For server components and route handlers, use `ENV.KEY` directly:
+
+```tsx title="app/page.tsx"
+import { ENV } from 'varlock/env';
+
+export default function Page() {
+ return {ENV.NEXT_PUBLIC_RUNTIME_FLAG}
;
+}
+```
+
+When a **dynamic** key is accessed in server-rendered code, the Next integration marks that render path as dynamic (not statically prerendered), including nested component access.
+
+### Client-side access for dynamic+public keys
+
+Dynamic+public values are not inlined into client bundles. The recommended pattern is:
+
+1. Add a route that returns `getPublicDynamicEnv()`
+2. Load once in client code with `loadPublicDynamicEnv()`
+3. Continue reading via `ENV.KEY`
+
+```ts title="app/__varlock/public-env/route.ts"
+import { getPublicDynamicEnv } from 'varlock/env';
+
+export const dynamic = 'force-dynamic';
+
+export async function GET() {
+ return Response.json(getPublicDynamicEnv(), {
+ headers: { 'cache-control': 'no-store' },
+ });
+}
+```
+
+```tsx title="app/example/client-widget.tsx"
+'use client';
+
+import { useEffect } from 'react';
+import { ENV, loadPublicDynamicEnv } from 'varlock/env';
+
+export function ClientWidget() {
+ useEffect(() => {
+ void loadPublicDynamicEnv();
+ }, []);
+ return {ENV.NEXT_PUBLIC_RUNTIME_FLAG}
;
+}
+```
+
+If you want to return only a subset, pass keys to `getPublicDynamicEnv(['KEY_A', 'KEY_B'])`.
+
+:::tip[Keep unaffected pages static]
+Do not put client dynamic-env loading in your root layout unless every route needs it. Prefer a route group/segment-level layout or a local component so pages that do not use dynamic+public values can still be prerendered.
+:::
+
---
## Managing multiple environments
@@ -277,7 +350,7 @@ APP_ENV=remap($WORKERS_CI_BRANCH,
Next.js uses the `NEXT_PUBLIC_` prefix to determine which env vars are public (bundled for the browser). Varlock lets you control this using decorators.
-Set [`@defaultSensitive`](/reference/root-decorators/#defaultsensitive) and mark specific items [@senstive](/reference/item-decorators/#sensitive):
+Set [`@defaultSensitive`](/reference/root-decorators/#defaultsensitive) and mark specific items [`@sensitive`](/reference/item-decorators/#sensitive):
```diff lang="env-spec" title=".env.schema"
+# @defaultSensitive=true
diff --git a/packages/varlock-website/src/content/docs/integrations/sveltekit.mdx b/packages/varlock-website/src/content/docs/integrations/sveltekit.mdx
index 3e1af932c..72f669939 100644
--- a/packages/varlock-website/src/content/docs/integrations/sveltekit.mdx
+++ b/packages/varlock-website/src/content/docs/integrations/sveltekit.mdx
@@ -56,6 +56,75 @@ See the [**Vite integration page**](/integrations/vite/) for configuration optio
---
+## Dynamic+public recipe
+
+SvelteKit uses Vite, so dynamic/public behavior comes from varlock core + the Vite integration.
+For the shared model and tradeoffs, see the [Static vs Dynamic Config guide](/guides/dynamic-config/).
+
+Use this schema shape:
+
+```env-spec title=".env.schema"
+# @defaultSensitive=false
+# @defaultDynamic=sensitive
+# ---
+PUBLIC_STATIC_VAR=
+PUBLIC_DYNAMIC_VAR= # @dynamic
+
+# @sensitive
+SECRET_VAR=
+```
+
+### 1) Server-rendered routes
+
+In non-prerendered server paths, access `ENV.KEY` directly:
+
+```ts title="src/routes/+page.server.ts"
+import { ENV } from 'varlock/env';
+
+export const load = async () => ({
+ runtimeFlag: ENV.PUBLIC_DYNAMIC_VAR,
+});
+```
+
+### 2) Client routes/components
+
+Expose an endpoint and load once before reading `ENV.KEY` on the client:
+
+```ts title="src/routes/__varlock/public-env/+server.ts"
+import { getPublicDynamicEnv } from 'varlock/env';
+
+export const GET = async () => new Response(JSON.stringify(getPublicDynamicEnv()), {
+ headers: {
+ 'content-type': 'application/json; charset=utf-8',
+ 'cache-control': 'no-store',
+ },
+});
+```
+
+```svelte title="src/routes/+page.svelte"
+
+
+{runtimeFlag}
+```
+
+If you only want a subset, return `getPublicDynamicEnv(['PUBLIC_DYNAMIC_VAR'])` from the endpoint.
+
+### 3) Prerendered routes
+
+Prerendered routes should only use static values. Keep dynamic-key usage out of routes/files that must be prerendered.
+
+---
+
## Setup for Cloudflare Workers
@@ -143,7 +212,7 @@ Varlock replaces all four `$env/*` modules with a single `ENV` object from `varl
| :---------------- | :----------------- |
| `$env/static/public` | `ENV.FOO` where `@sensitive=false` — non-sensitive values are inlined at build time on both client and server |
| `$env/static/private` | `ENV.FOO` where `@sensitive=true`, referenced from SSR/server code — inlined into the server bundle, build errors if referenced from client code |
-| `$env/dynamic/public` | **Not yet supported** — non-sensitive values are currently always inlined at build time (equivalent to `$env/static/public`). Runtime-resolved public values are on the roadmap. |
+| `$env/dynamic/public` | `ENV.FOO` where `@dynamic` + non-sensitive, loaded at runtime for client usage via `loadPublicDynamicEnv()` (see [recipe above](#dynamicpublic-recipe)) |
| `$env/dynamic/private` | `ENV.FOO` — in server contexts it's read from the live runtime env (e.g. Cloudflare bindings via `varlockCloudflareVitePlugin`, or Node's `process.env` elsewhere) rather than baked into the bundle |
| `PUBLIC_` prefix requirement | Per-item [`@sensitive`](/reference/item-decorators/#sensitive) decorator, or a schema-wide [`@defaultSensitive`](/reference/root-decorators/#defaultsensitive) rule |
diff --git a/packages/varlock-website/src/content/docs/integrations/vite.mdx b/packages/varlock-website/src/content/docs/integrations/vite.mdx
index 26d9437ba..aebf021a6 100644
--- a/packages/varlock-website/src/content/docs/integrations/vite.mdx
+++ b/packages/varlock-website/src/content/docs/integrations/vite.mdx
@@ -141,6 +141,50 @@ console.log(ENV.SOMEVAR); // ✨ recommended
- Better error messages for invalid or unavailable keys
- Enables future DX improvements and tighter control over what is bundled
+## Dynamic+public config recipe
+
+Use `@dynamic` for public values that must be resolved at runtime instead of inlined at build time:
+For the shared model and tradeoffs, see the [Static vs Dynamic Config guide](/guides/dynamic-config/).
+
+```env-spec title=".env.schema"
+# @defaultSensitive=inferFromPrefix('VITE_')
+# @defaultDynamic=sensitive
+# ---
+VITE_STATIC_FLAG=enabled
+VITE_RUNTIME_FLAG=enabled # @dynamic
+```
+
+### Server-side rendering
+
+In SSR code, keep using `ENV.KEY` directly.
+
+### Client-side rendering
+
+For browser code, expose an endpoint that returns `getPublicDynamicEnv()` and then hydrate with `loadPublicDynamicEnv()`:
+
+```ts title="server route example"
+import { getPublicDynamicEnv } from 'varlock/env';
+
+export function GET() {
+ return Response.json(getPublicDynamicEnv(), {
+ headers: { 'cache-control': 'no-store' },
+ });
+}
+```
+
+```ts title="client entry example"
+import { ENV, loadPublicDynamicEnv } from 'varlock/env';
+
+await loadPublicDynamicEnv({ endpoint: '/__varlock/public-env' });
+console.log(ENV.VITE_RUNTIME_FLAG);
+```
+
+If you only need a subset, return `getPublicDynamicEnv(['VITE_RUNTIME_FLAG'])`.
+
+:::note[No automatic endpoint in plain Vite]
+The Vite integration does not inject a route automatically. Framework integrations may add this for you (for example, Astro can auto-inject one in SSR mode).
+:::
+
### Within `vite.config.*`
It's often useful to be able to access env vars in your Vite config. Without varlock, it's a bit awkward, but varlock makes it dead simple - in fact it's already available! Just import varlock's `ENV` object and reference env vars via `ENV.SOME_ITEM` like you do everywhere else.
diff --git a/packages/varlock-website/src/content/docs/reference/item-decorators.mdx b/packages/varlock-website/src/content/docs/reference/item-decorators.mdx
index 3afb0980e..a38eaf8a4 100644
--- a/packages/varlock-website/src/content/docs/reference/item-decorators.mdx
+++ b/packages/varlock-website/src/content/docs/reference/item-decorators.mdx
@@ -92,6 +92,32 @@ PUBLIC_API_URL=https://api.example.com
```
+
+### `@dynamic`
+**Value type:** `boolean`
+
+Sets whether the item is _dynamic_ - meaning integrations should avoid replacing it at build time and instead resolve it at runtime.
+
+By default, dynamic behavior follows sensitivity (sensitive items are dynamic, non-sensitive items are static), but this can be overridden globally with [`@defaultDynamic`](/reference/root-decorators/#defaultdynamic).
+
+```env-spec
+# @dynamic
+PUBLIC_RUNTIME_FLAG=
+```
+
+
+
+### `@static`
+**Value type:** `boolean`
+
+Opposite of [`@dynamic`](#dynamic). Equivalent to writing `@dynamic=false`.
+
+```env-spec
+# @static
+SENSITIVE_BUILD_TIME_TOKEN=
+```
+
+
### `@type`
**Value type:** [`data type`](/reference/data-types) (name only or function call)
diff --git a/packages/varlock-website/src/content/docs/reference/root-decorators.mdx b/packages/varlock-website/src/content/docs/reference/root-decorators.mdx
index 4f3534bea..cff1ff6c8 100644
--- a/packages/varlock-website/src/content/docs/reference/root-decorators.mdx
+++ b/packages/varlock-website/src/content/docs/reference/root-decorators.mdx
@@ -110,6 +110,26 @@ OTHER_BAR= # not sensitive (explicit)
```
+
+### `@defaultDynamic`
+**Value type:** `boolean | "sensitive"`
+
+Sets the default state of each item being treated as _dynamic_ (runtime-resolved) vs _static_ (eligible for build-time replacement). Only applied to items that have a definition within the same file. Can be overridden on individual items using [`@dynamic`](/reference/item-decorators/#dynamic)/[`@static`](/reference/item-decorators/#static).
+
+- `"sensitive"` (default): Dynamic behavior follows sensitivity.
+- `true`: All items are dynamic unless explicitly marked static.
+- `false`: All items are static unless explicitly marked dynamic.
+
+```env-spec
+# @defaultSensitive=inferFromPrefix(PUBLIC_)
+# @defaultDynamic=sensitive
+# ---
+
+PUBLIC_FOO= # static by default (public)
+SECRET_BAR= # dynamic by default (sensitive)
+```
+
+
### `@disable`
**Value type:** `boolean`
diff --git a/packages/varlock/src/env-graph/lib/config-item.ts b/packages/varlock/src/env-graph/lib/config-item.ts
index c53ad1972..f2f269507 100644
--- a/packages/varlock/src/env-graph/lib/config-item.ts
+++ b/packages/varlock/src/env-graph/lib/config-item.ts
@@ -455,6 +455,55 @@ export class ConfigItem {
if (sensitiveFromDataType !== undefined) this._isSensitive = sensitiveFromDataType;
}
+ _isDynamic: boolean = true;
+ get isDynamic(): boolean {
+ return this._isDynamic;
+ }
+ private async processDynamic() {
+ try {
+ // Pass 1: explicit per-item @dynamic / @static decorators take highest priority
+ for (const def of this.defs) {
+ const dynamicDecs = def.itemDef.decorators?.filter((d) => d.name === 'dynamic' || d.name === 'static') || [];
+ const dynamicDec = dynamicDecs[0];
+ if (!dynamicDec) continue;
+
+ const usingStatic = dynamicDec.name === 'static';
+ const dynamicDecValue = await dynamicDec.resolve();
+ if (dynamicDec.schemaErrors.some((e) => !e.isWarning)) return;
+ if (![true, false, undefined].includes(dynamicDecValue)) {
+ throw new SchemaError('@dynamic/@static must resolve to a boolean or undefined');
+ }
+ if (dynamicDecValue !== undefined) {
+ this._isDynamic = usingStatic ? !dynamicDecValue : dynamicDecValue;
+ return;
+ }
+ }
+
+ // Pass 2: @defaultDynamic from source files
+ for (const def of this.defs) {
+ const defaultDynamicDec = def.source?.getRootDec('defaultDynamic');
+ if (!defaultDynamicDec) continue;
+
+ const defaultDynamicVal = await defaultDynamicDec.resolve();
+ if (_.isBoolean(defaultDynamicVal)) {
+ this._isDynamic = defaultDynamicVal;
+ return;
+ }
+ if (defaultDynamicVal === 'sensitive') {
+ this._isDynamic = this._isSensitive;
+ return;
+ }
+ throw new SchemaError('@defaultDynamic must resolve to true, false, or "sensitive"');
+ }
+ } catch (err) {
+ this._schemaErrors.push(err instanceof SchemaError ? err : new SchemaError(err as Error));
+ return;
+ }
+
+ // Default behavior: dynamic state follows sensitivity.
+ this._isDynamic = this._isSensitive;
+ }
+
get errors() {
return _.compact([
@@ -527,6 +576,7 @@ export class ConfigItem {
}
}
}
+ await this.processDynamic();
if (!this.valueResolver) {
this.isResolved = true;
@@ -744,6 +794,51 @@ export class ConfigItem {
// on error, fall back to default (sensitive=true, safe default)
}
+ // isDynamic - mirrors processDynamic() logic
+ let isDynamic = isSensitive;
+ try {
+ let foundDynamic = false;
+ for (const def of defs) {
+ const dynamicDecs = def.itemDef.decorators?.filter((d) => d.name === 'dynamic' || d.name === 'static') || [];
+ const dynamicDec = dynamicDecs[0];
+
+ if (dynamicDec) {
+ const usingStatic = dynamicDec.name === 'static';
+ // Skip dynamic decorators to avoid caching a stale result
+ if (dynamicDec.decValueResolver?.fnName !== '\0static') break;
+ const dynamicDecValue = await dynamicDec.resolve();
+ if (dynamicDec.schemaErrors.some((e) => !e.isWarning)) break;
+ if (![true, false, undefined].includes(dynamicDecValue)) break;
+ if (dynamicDecValue !== undefined) {
+ isDynamic = usingStatic ? !dynamicDecValue : dynamicDecValue;
+ foundDynamic = true;
+ break;
+ }
+ }
+
+ const defaultDynamicDec = def.source?.getRootDec('defaultDynamic');
+ if (!defaultDynamicDec) continue;
+
+ const defaultDynamicVal = await defaultDynamicDec.resolve();
+ if (_.isBoolean(defaultDynamicVal)) {
+ isDynamic = defaultDynamicVal;
+ foundDynamic = true;
+ break;
+ }
+ if (defaultDynamicVal === 'sensitive') {
+ isDynamic = isSensitive;
+ foundDynamic = true;
+ break;
+ }
+ }
+ if (!foundDynamic) {
+ isDynamic = isSensitive;
+ }
+ } catch {
+ // on error, fall back to default linkage (dynamic follows sensitivity)
+ isDynamic = isSensitive;
+ }
+
// icon - resolve from filtered defs' decorators
let icon: string | undefined;
for (const def of defs) {
@@ -799,6 +894,7 @@ export class ConfigItem {
isRequired,
isRequiredDynamic,
isSensitive,
+ isDynamic,
icon,
docsLinks,
isDeprecated: this.isDeprecated,
@@ -815,6 +911,7 @@ export type TypeGenItemInfo = {
isRequired: boolean;
isRequiredDynamic: boolean;
isSensitive: boolean;
+ isDynamic: boolean;
icon?: string;
docsLinks: Array<{ url: string, description?: string }>;
isDeprecated: boolean;
diff --git a/packages/varlock/src/env-graph/lib/decorators.ts b/packages/varlock/src/env-graph/lib/decorators.ts
index 417589cfd..d76abdda4 100644
--- a/packages/varlock/src/env-graph/lib/decorators.ts
+++ b/packages/varlock/src/env-graph/lib/decorators.ts
@@ -303,6 +303,16 @@ export const builtInRootDecorators: Array
> = [
}
},
},
+ {
+ name: 'defaultDynamic',
+ process: (decVal) => {
+ if (!decVal.isStatic || ![true, false, 'sensitive'].includes(decVal.staticValue as any)) {
+ throw new Error(
+ '@defaultDynamic decorator value must be a static value of true, false, or "sensitive"',
+ );
+ }
+ },
+ },
{
name: 'disable',
},
@@ -486,6 +496,14 @@ export const builtInItemDecorators: Array> = [
name: 'public',
incompatibleWith: ['sensitive'],
},
+ {
+ name: 'dynamic',
+ incompatibleWith: ['static'],
+ },
+ {
+ name: 'static',
+ incompatibleWith: ['dynamic'],
+ },
{
name: 'type',
useFnArgsResolver: true,
diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts
index 47a0ccb63..bcd0213f5 100644
--- a/packages/varlock/src/env-graph/lib/env-graph.ts
+++ b/packages/varlock/src/env-graph/lib/env-graph.ts
@@ -52,6 +52,7 @@ export type SerializedEnvGraph = {
config: Record;
/** Present only when config has errors — consumers can check `if (data.errors)` */
errors?: SerializedEnvGraphErrors;
@@ -296,6 +297,7 @@ export class EnvGraph {
// internal def has no decorators and no source with root-level defaults.
item._isRequired = false;
item._isSensitive = false;
+ item._isDynamic = false;
// Set dataType directly since registerBuiltinVar is called synchronously
// during resolver processing, and the item may not get a process() call
// from the finishLoad loop (for...in doesn't reliably visit new keys).
@@ -556,6 +558,7 @@ export class EnvGraph {
serializedGraph.config[itemKey] = {
value: item.resolvedValue,
isSensitive: item.isSensitive,
+ isDynamic: item.isDynamic,
};
}
diff --git a/packages/varlock/src/env-graph/test/dynamic-decorator.test.ts b/packages/varlock/src/env-graph/test/dynamic-decorator.test.ts
new file mode 100644
index 000000000..3ca787d75
--- /dev/null
+++ b/packages/varlock/src/env-graph/test/dynamic-decorator.test.ts
@@ -0,0 +1,122 @@
+import {
+ describe, test,
+} from 'vitest';
+import outdent from 'outdent';
+import { envFilesTest } from './helpers/generic-test';
+
+describe('@dynamic, @static, and @defaultDynamic', () => {
+ test('default behavior: dynamic follows sensitivity', envFilesTest({
+ envFile: outdent`
+ SECRET= # @sensitive
+ PUBLIC= # @public
+ SECRET_FALSE= # @sensitive=false
+ PUBLIC_FALSE= # @public=false
+ `,
+ expectDynamic: {
+ SECRET: true,
+ PUBLIC: false,
+ SECRET_FALSE: false,
+ PUBLIC_FALSE: true,
+ },
+ }));
+
+ test('@dynamic and @static override default behavior', envFilesTest({
+ envFile: outdent`
+ STATIC_SECRET= # @sensitive @static
+ DYNAMIC_PUBLIC= # @public @dynamic
+ STATIC_FALSE= # @static=false
+ DYNAMIC_FALSE= # @dynamic=false
+ `,
+ expectDynamic: {
+ STATIC_SECRET: false,
+ DYNAMIC_PUBLIC: true,
+ STATIC_FALSE: true,
+ DYNAMIC_FALSE: false,
+ },
+ }));
+
+ test('dynamic @dynamic/@static values work', envFilesTest({
+ envFile: outdent`
+ DYNAMIC_TRUE= # @dynamic=if(yes)
+ DYNAMIC_FALSE= # @dynamic=if(0)
+ STATIC_TRUE= # @static=if(yes)
+ STATIC_FALSE= # @static=if(0)
+ `,
+ expectDynamic: {
+ DYNAMIC_TRUE: true,
+ DYNAMIC_FALSE: false,
+ STATIC_TRUE: false,
+ STATIC_FALSE: true,
+ },
+ }));
+
+ test('@defaultDynamic=true', envFilesTest({
+ envFile: outdent`
+ # @defaultDynamic=true
+ # ---
+ PUBLIC= # @public
+ OTHER=
+ `,
+ expectDynamic: {
+ PUBLIC: true,
+ OTHER: true,
+ },
+ }));
+
+ test('@defaultDynamic=false', envFilesTest({
+ envFile: outdent`
+ # @defaultDynamic=false
+ # ---
+ SECRET= # @sensitive
+ OTHER=
+ `,
+ expectDynamic: {
+ SECRET: false,
+ OTHER: false,
+ },
+ }));
+
+ test('@defaultDynamic=sensitive links dynamic to final sensitivity', envFilesTest({
+ envFile: outdent`
+ # @defaultSensitive=inferFromPrefix(PUBLIC_)
+ # @defaultDynamic=sensitive
+ # ---
+ PUBLIC_FOO=
+ SECRET_BAR=
+ `,
+ expectSensitive: {
+ PUBLIC_FOO: false,
+ SECRET_BAR: true,
+ },
+ expectDynamic: {
+ PUBLIC_FOO: false,
+ SECRET_BAR: true,
+ },
+ }));
+
+ test('explicit @dynamic/@static beats @defaultDynamic', envFilesTest({
+ envFile: outdent`
+ # @defaultDynamic=sensitive
+ # ---
+ SECRET_STATIC= # @sensitive @static
+ PUBLIC_DYNAMIC= # @public @dynamic
+ `,
+ expectDynamic: {
+ SECRET_STATIC: false,
+ PUBLIC_DYNAMIC: true,
+ },
+ }));
+
+ test('serializes isDynamic in graph output', envFilesTest({
+ envFile: outdent`
+ PUBLIC= # @public
+ DYNAMIC_PUBLIC= # @public @dynamic
+ `,
+ expectSerializedMatches: {
+ config: {
+ PUBLIC: { isDynamic: false },
+ DYNAMIC_PUBLIC: { isDynamic: true },
+ },
+ },
+ }));
+});
diff --git a/packages/varlock/src/env-graph/test/helpers/generic-test.ts b/packages/varlock/src/env-graph/test/helpers/generic-test.ts
index c1d13cf9e..170b80692 100644
--- a/packages/varlock/src/env-graph/test/helpers/generic-test.ts
+++ b/packages/varlock/src/env-graph/test/helpers/generic-test.ts
@@ -42,6 +42,7 @@ export function envFilesTest(spec: {
expectRequired?: Record>;
expectRequiredIsDynamic?: Record;
expectSensitive?: Record>;
+ expectDynamic?: Record>;
expectSerializedMatches?: any;
/**
* Simulate calling getTypeGenInfo() on all items before resolveEnvValues(),
@@ -185,6 +186,12 @@ export function envFilesTest(spec: {
expect(item.isSensitive, `expected ${key} to be ${spec.expectSensitive[key] ? 'sensitive' : 'NOT sensitive'}`).toBe(spec.expectSensitive[key]);
}
}
+ if (spec.expectDynamic) {
+ for (const key of Object.keys(spec.expectDynamic)) {
+ const item = g.configSchema[key];
+ expect(item.isDynamic, `expected ${key} to be ${spec.expectDynamic[key] ? 'dynamic' : 'NOT dynamic'}`).toBe(spec.expectDynamic[key]);
+ }
+ }
}
if (spec.expectSerializedMatches) {
diff --git a/packages/varlock/src/index.ts b/packages/varlock/src/index.ts
index 27b7ae843..112eae74d 100644
--- a/packages/varlock/src/index.ts
+++ b/packages/varlock/src/index.ts
@@ -39,7 +39,8 @@ export function getBuildTimeReplacements(opts?: {
const replacements = {} as Record;
for (const key in envInfo.config) {
const itemInfo = envInfo.config[key];
- const replaceItem = !itemInfo.isSensitive || opts?.includeSensitive;
+ const isDynamic = itemInfo.isDynamic;
+ const replaceItem = !isDynamic || opts?.includeSensitive;
if (!replaceItem) continue;
replacements[`${opts?.objectKey || 'ENV'}.${key}`] = JSON.stringify(envInfo.config[key].value);
}
diff --git a/packages/varlock/src/runtime/env.ts b/packages/varlock/src/runtime/env.ts
index be23acb24..e20ea575b 100644
--- a/packages/varlock/src/runtime/env.ts
+++ b/packages/varlock/src/runtime/env.ts
@@ -270,6 +270,8 @@ export function initVarlockEnv(opts?: {
resetRedactionMap(serializedEnvData);
const setProcessEnv = processExists && !serializedEnvData.settings?.disableProcessEnvInjection;
+ const dynamicKeys: Array = [];
+ const dynamicPublicKeys: Array = [];
// if we've already injected process.env vars in the past, we'll reset those now
if (setProcessEnv) {
@@ -281,7 +283,13 @@ export function initVarlockEnv(opts?: {
}
for (const itemKey in serializedEnvData.config) {
- const itemValue = serializedEnvData.config[itemKey].value;
+ const itemData = serializedEnvData.config[itemKey];
+ const itemValue = itemData.value;
+ const isDynamic = itemData.isDynamic;
+ if (isDynamic) {
+ dynamicKeys.push(itemKey);
+ if (!itemData.isSensitive) dynamicPublicKeys.push(itemKey);
+ }
envValues[itemKey] = itemValue;
if (setProcessEnv) {
varlockInjectedProcessEnvKeys?.push(itemKey);
@@ -290,6 +298,8 @@ export function initVarlockEnv(opts?: {
process.env[itemKey] = itemValue === undefined ? '' : String(itemValue);
}
}
+ (globalThis as any).__varlockDynamicKeys = dynamicKeys;
+ (globalThis as any).__varlockDynamicPublicKeys = dynamicPublicKeys;
initializedEnv = true;
}
@@ -324,6 +334,183 @@ const IGNORED_PROXY_KEYS = [
// but TS wont let us, so instead we start with it being empty, which will cause type errors
// unless type generation is enabled
export interface TypedEnvSchema {}
+export interface PublicTypedEnvSchema {}
+type DynamicConfigAccessMeta = {
+ key: string,
+ isPublic: boolean,
+};
+
+function getStringArrayGlobal(name: string): Array {
+ const val = (globalThis as any)[name];
+ if (!Array.isArray(val)) return [];
+ return val.filter((k) => typeof k === 'string');
+}
+
+export function getDynamicConfigKeys(): Array {
+ return getStringArrayGlobal('__varlockDynamicKeys');
+}
+
+export function getDynamicPublicConfigKeys(): Array {
+ return getStringArrayGlobal('__varlockDynamicPublicKeys');
+}
+
+function getDynamicPublicKeys(): Array {
+ return getDynamicPublicConfigKeys();
+}
+
+function resolvePublicDynamicKeys(keys?: Array): Array {
+ const allowedKeys = getDynamicPublicKeys();
+ if (!keys?.length) return allowedKeys;
+ if (!allowedKeys.length) return keys;
+ const allowedSet = new Set(allowedKeys);
+ return keys.filter((k) => allowedSet.has(k));
+}
+
+function getVarlockExecutionPhase() {
+ if ((globalThis as any).__varlockExecutionPhase) return (globalThis as any).__varlockExecutionPhase as string;
+ return globalThis.process?.env?._VARLOCK_EXECUTION_PHASE;
+}
+
+function getDynamicBuildAccessMode() {
+ const mode = (globalThis as any).__varlockDynamicBuildAccessMode
+ ?? globalThis.process?.env?._VARLOCK_DYNAMIC_BUILD_ACCESS_MODE
+ ?? 'error';
+ return mode === 'warn' ? 'warn' : 'error';
+}
+
+function shouldGuardDynamicAccessDuringBuild() {
+ const phase = getVarlockExecutionPhase();
+ return phase === 'build' || phase === 'prerender';
+}
+
+function notifyDynamicConfigAccess(meta: DynamicConfigAccessMeta) {
+ const onDynamicConfigAccess = (globalThis as any).__varlockOnDynamicConfigAccess;
+ if (typeof onDynamicConfigAccess !== 'function') {
+ debug(`[dynamic-access] no hook installed for ENV.${meta.key}`);
+ return;
+ }
+ debug(`[dynamic-access] notifying hook for ENV.${meta.key} (isPublic=${meta.isPublic})`);
+ onDynamicConfigAccess(meta);
+}
+
+const dynamicBuildAccessWarnedKeys = new Set();
+const DEFAULT_PUBLIC_DYNAMIC_ENV_ENDPOINT = '/__varlock/public-env';
+let dynamicPublicEnvLoadPromise: Promise> | undefined;
+let hasLoadedDynamicPublicEnv = false;
+let lastLoadedDynamicPublicEnv = {} as Record;
+
+function hasHydratedDynamicPublicEnv() {
+ const dynamicPublicKeys = getDynamicPublicKeys();
+ if (!dynamicPublicKeys.length) return false;
+ return dynamicPublicKeys.every((key) => key in envValues);
+}
+
+/**
+ * Hydrate dynamic+public env values at runtime (typically in the browser),
+ * while keeping the same `ENV.KEY` access pattern.
+ */
+export function setDynamicPublicEnv(
+ values: Partial | Record,
+) {
+ initializedEnv = true;
+ for (const [key, value] of Object.entries(values || {})) {
+ envValues[key] = value;
+ }
+}
+
+/**
+ * Returns the currently hydrated dynamic+public values from the ENV proxy store.
+ */
+export function getDynamicPublicEnv(): Partial {
+ const out: Record = {};
+ for (const key of resolvePublicDynamicKeys()) {
+ if (key in envValues) out[key] = envValues[key];
+ }
+ return out as Partial;
+}
+
+/**
+ * Returns dynamic+public env values as an object.
+ * Optionally pass a key list to limit which values are included.
+ */
+export function getPublicDynamicEnv(keys?: Array): Partial {
+ const out: Record = {};
+ for (const key of resolvePublicDynamicKeys(keys)) {
+ if (key in envValues) out[key] = envValues[key];
+ }
+ return out as Partial;
+}
+
+/**
+ * Clears hydrated dynamic+public values from the ENV proxy store.
+ */
+export function clearDynamicPublicEnv(keys?: Array) {
+ for (const key of resolvePublicDynamicKeys(keys)) {
+ delete envValues[key];
+ }
+ hasLoadedDynamicPublicEnv = false;
+ lastLoadedDynamicPublicEnv = {};
+}
+
+/**
+ * Loads dynamic+public env values from a server endpoint and hydrates the ENV proxy.
+ * The server endpoint controls which keys are returned.
+ */
+export async function loadPublicDynamicEnv(opts?: {
+ endpoint?: string,
+ fetch?: (input: string | URL, init?: RequestInit) => Promise,
+ force?: boolean,
+ requestInit?: RequestInit,
+}): Promise> {
+ if (!opts?.force) {
+ if (hasHydratedDynamicPublicEnv()) return getDynamicPublicEnv();
+ if (hasLoadedDynamicPublicEnv) return lastLoadedDynamicPublicEnv as Partial;
+ if (dynamicPublicEnvLoadPromise) return dynamicPublicEnvLoadPromise;
+ }
+
+ const endpoint = opts?.endpoint
+ ?? (globalThis as any).__varlockPublicDynamicEnvEndpoint
+ ?? DEFAULT_PUBLIC_DYNAMIC_ENV_ENDPOINT;
+
+ const fetchImpl = opts?.fetch ?? globalThis.fetch?.bind(globalThis);
+ if (!fetchImpl) {
+ throw new Error(
+ '[varlock] loadPublicDynamicEnv requires fetch. '
+ + 'Pass opts.fetch or call it in an environment with global fetch.',
+ );
+ }
+
+ dynamicPublicEnvLoadPromise = (async () => {
+ const response = await fetchImpl(endpoint, {
+ method: 'GET',
+ cache: 'no-store',
+ credentials: 'same-origin',
+ ...opts?.requestInit,
+ });
+ if (!response.ok) {
+ throw new Error(
+ `[varlock] Failed to load dynamic public env (${response.status}) from ${endpoint}`,
+ );
+ }
+
+ const payload = await response.json() as unknown;
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
+ throw new Error('[varlock] loadPublicDynamicEnv expected a JSON object payload');
+ }
+ const payloadObj = payload as Record;
+
+ setDynamicPublicEnv(payloadObj);
+ hasLoadedDynamicPublicEnv = true;
+ lastLoadedDynamicPublicEnv = payloadObj;
+ return payloadObj as Partial;
+ })();
+
+ try {
+ return await dynamicPublicEnvLoadPromise;
+ } finally {
+ dynamicPublicEnvLoadPromise = undefined;
+ }
+}
const EnvProxy = new Proxy({}, {
get(target, prop) {
@@ -345,8 +532,48 @@ const EnvProxy = new Proxy({}, {
return undefined;
}
- if (prop in envValues) return envValues[prop];
+ const dynamicKeys = getDynamicConfigKeys();
+ if (prop in envValues) {
+ const isDynamic = dynamicKeys.includes(prop);
+ if (isDynamic) {
+ debug(`[dynamic-access] detected dynamic access for ENV.${prop}`);
+ }
+ if (isDynamic) {
+ notifyDynamicConfigAccess({
+ key: prop,
+ isPublic: getDynamicPublicKeys().includes(prop),
+ });
+ }
+ if (isDynamic && shouldGuardDynamicAccessDuringBuild()) {
+ const msg = [
+ `[varlock] dynamic config \`ENV.${prop}\` was accessed during ${getVarlockExecutionPhase()}.`,
+ 'Dynamic values cannot be safely inlined during static build/prerender.',
+ 'Use runtime SSR access (non-prerender) or load/hydrate dynamic public values at runtime.',
+ ].join(' ');
+ if (getDynamicBuildAccessMode() === 'warn') {
+ if (!dynamicBuildAccessWarnedKeys.has(prop)) {
+ dynamicBuildAccessWarnedKeys.add(prop);
+ // eslint-disable-next-line no-console
+ console.warn(msg);
+ }
+ } else {
+ throw new Error(msg);
+ }
+ }
+ return envValues[prop];
+ }
if ((globalThis as any).__varlockThrowOnMissingKeys) {
+ if (dynamicKeys.includes(prop)) {
+ if (getDynamicPublicKeys().includes(prop)) {
+ throw new Error(
+ `\`ENV.${prop}\` is dynamic+public and has not been hydrated yet. `
+ + 'Load dynamic public env first, then access it via ENV.',
+ );
+ }
+ throw new Error(
+ `\`ENV.${prop}\` is dynamic and is not available in this environment.`,
+ );
+ }
// during development, we can feed in extra metadata and show more helpful errors
if ((globalThis as any).__varlockValidKeys && (globalThis as any).__varlockValidKeys.includes(prop)) {
throw new Error(`\`ENV.${prop}\` exists, but is not available in this environment`);
diff --git a/packages/varlock/src/runtime/test/crypto.test.ts b/packages/varlock/src/runtime/test/crypto.test.ts
index 2b488942a..6458bb081 100644
--- a/packages/varlock/src/runtime/test/crypto.test.ts
+++ b/packages/varlock/src/runtime/test/crypto.test.ts
@@ -9,7 +9,7 @@ import {
const TEST_KEY = 'a'.repeat(64); // valid 256-bit hex key
const TEST_JSON = JSON.stringify({
- config: { API_KEY: { value: 'secret-123', isSensitive: true } },
+ config: { API_KEY: { value: 'secret-123', isSensitive: true, isDynamic: true } },
sources: [],
settings: {},
});
diff --git a/packages/varlock/src/runtime/test/dynamic-public-env.test.ts b/packages/varlock/src/runtime/test/dynamic-public-env.test.ts
new file mode 100644
index 000000000..b20e56f61
--- /dev/null
+++ b/packages/varlock/src/runtime/test/dynamic-public-env.test.ts
@@ -0,0 +1,175 @@
+import {
+ afterEach, beforeEach, describe, expect, it, vi,
+} from 'vitest';
+import {
+ ENV,
+ clearDynamicPublicEnv,
+ getDynamicConfigKeys,
+ getDynamicPublicConfigKeys,
+ getDynamicPublicEnv,
+ loadPublicDynamicEnv,
+ getPublicDynamicEnv,
+ initVarlockEnv,
+ setDynamicPublicEnv,
+} from '../env';
+
+const DYNAMIC_KEY = 'PUBLIC_DYNAMIC_TEST';
+const originalFetch = globalThis.fetch;
+
+describe('dynamic public env hydration', () => {
+ beforeEach(() => {
+ (globalThis as any).__varlockThrowOnMissingKeys = true;
+ (globalThis as any).__varlockExecutionPhase = undefined;
+ (globalThis as any).__varlockDynamicBuildAccessMode = undefined;
+ (globalThis as any).__varlockDynamicKeys = [DYNAMIC_KEY];
+ (globalThis as any).__varlockDynamicPublicKeys = [DYNAMIC_KEY];
+ (globalThis as any).__varlockOnDynamicConfigAccess = undefined;
+ delete process.env._VARLOCK_EXECUTION_PHASE;
+ delete process.env._VARLOCK_DYNAMIC_BUILD_ACCESS_MODE;
+ clearDynamicPublicEnv([DYNAMIC_KEY]);
+ });
+
+ afterEach(() => {
+ globalThis.fetch = originalFetch;
+ });
+
+ it('hydrates ENV values via setDynamicPublicEnv', () => {
+ setDynamicPublicEnv({ [DYNAMIC_KEY]: 'hello-runtime' });
+ expect((ENV as any)[DYNAMIC_KEY]).toBe('hello-runtime');
+ expect((getDynamicPublicEnv() as any)[DYNAMIC_KEY]).toBe('hello-runtime');
+ expect((getPublicDynamicEnv() as any)[DYNAMIC_KEY]).toBe('hello-runtime');
+ });
+
+ it('getPublicDynamicEnv accepts an optional key list', () => {
+ (globalThis as any).__varlockDynamicPublicKeys = [
+ 'PUBLIC_DYNAMIC_TEST',
+ 'OTHER_PUBLIC_DYNAMIC',
+ ];
+ setDynamicPublicEnv({
+ PUBLIC_DYNAMIC_TEST: 'a',
+ OTHER_PUBLIC_DYNAMIC: 'b',
+ });
+ expect(getPublicDynamicEnv(['PUBLIC_DYNAMIC_TEST'])).toEqual({
+ PUBLIC_DYNAMIC_TEST: 'a',
+ });
+ });
+
+ it('getPublicDynamicEnv honors explicit keys when metadata is unavailable', () => {
+ (globalThis as any).__varlockDynamicPublicKeys = undefined;
+ setDynamicPublicEnv({
+ PUBLIC_DYNAMIC_TEST: 'a',
+ OTHER_PUBLIC_DYNAMIC: 'b',
+ });
+ expect(getPublicDynamicEnv(['PUBLIC_DYNAMIC_TEST'])).toEqual({
+ PUBLIC_DYNAMIC_TEST: 'a',
+ });
+ });
+
+ it('throws helpful guidance when dynamic+public key is accessed before hydration', () => {
+ expect(() => (ENV as any)[DYNAMIC_KEY]).toThrow(/dynamic\+public and has not been hydrated yet/i);
+ });
+
+ it('notifies on dynamic key access when a runtime hook is installed', () => {
+ const onAccess = vi.fn();
+ (globalThis as any).__varlockOnDynamicConfigAccess = onAccess;
+ setDynamicPublicEnv({ [DYNAMIC_KEY]: 'hello-runtime' });
+ expect((ENV as any)[DYNAMIC_KEY]).toBe('hello-runtime');
+ expect(onAccess).toHaveBeenCalledWith({
+ key: DYNAMIC_KEY,
+ isPublic: true,
+ });
+ });
+
+ it('exposes dynamic metadata from initVarlockEnv', () => {
+ process.env.__VARLOCK_ENV = JSON.stringify({
+ sources: [],
+ settings: {},
+ config: {
+ PUBLIC_STATIC_TEST: { value: 'a', isSensitive: false, isDynamic: false },
+ PUBLIC_DYNAMIC_TEST: { value: 'b', isSensitive: false, isDynamic: true },
+ SECRET_DYNAMIC_TEST: { value: 'c', isSensitive: true, isDynamic: true },
+ },
+ });
+
+ initVarlockEnv();
+
+ expect(getDynamicConfigKeys()).toEqual(expect.arrayContaining([
+ 'PUBLIC_DYNAMIC_TEST',
+ 'SECRET_DYNAMIC_TEST',
+ ]));
+ expect(getDynamicPublicConfigKeys()).toEqual(['PUBLIC_DYNAMIC_TEST']);
+ });
+
+ it('throws when a dynamic key is accessed during build/prerender phase', () => {
+ (globalThis as any).__varlockDynamicKeys = [DYNAMIC_KEY];
+ (globalThis as any).__varlockExecutionPhase = 'build';
+ setDynamicPublicEnv({ [DYNAMIC_KEY]: 'hello-runtime' });
+ expect(() => (ENV as any)[DYNAMIC_KEY]).toThrow(/accessed during build/i);
+ });
+
+ it('supports _VARLOCK_EXECUTION_PHASE and _VARLOCK_DYNAMIC_BUILD_ACCESS_MODE', () => {
+ (globalThis as any).__varlockExecutionPhase = undefined;
+ (globalThis as any).__varlockDynamicBuildAccessMode = undefined;
+ process.env._VARLOCK_EXECUTION_PHASE = 'build';
+ process.env._VARLOCK_DYNAMIC_BUILD_ACCESS_MODE = 'warn';
+ setDynamicPublicEnv({ [DYNAMIC_KEY]: 'hello-runtime' });
+ expect(() => (ENV as any)[DYNAMIC_KEY]).not.toThrow();
+ });
+
+ it('loadPublicDynamicEnv fetches and hydrates ENV values', async () => {
+ let fetchCount = 0;
+ globalThis.fetch = (async () => {
+ fetchCount += 1;
+ return new Response(JSON.stringify({ [DYNAMIC_KEY]: 'loaded-from-endpoint' }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ });
+ }) as unknown as typeof fetch;
+
+ const payload = await loadPublicDynamicEnv();
+
+ expect(fetchCount).toBe(1);
+ expect(payload).toEqual({ [DYNAMIC_KEY]: 'loaded-from-endpoint' });
+ expect((ENV as any)[DYNAMIC_KEY]).toBe('loaded-from-endpoint');
+ });
+
+ it('loadPublicDynamicEnv avoids refetch when already hydrated', async () => {
+ let fetchCount = 0;
+ globalThis.fetch = (async () => {
+ fetchCount += 1;
+ return new Response(JSON.stringify({ [DYNAMIC_KEY]: 'loaded-once' }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ });
+ }) as unknown as typeof fetch;
+
+ await loadPublicDynamicEnv();
+ const second = await loadPublicDynamicEnv();
+
+ expect(fetchCount).toBe(1);
+ expect(second).toEqual({ [DYNAMIC_KEY]: 'loaded-once' });
+ });
+
+ it('loadPublicDynamicEnv dedupes concurrent fetches', async () => {
+ let fetchCount = 0;
+ globalThis.fetch = (async () => {
+ fetchCount += 1;
+ await new Promise((resolve) => {
+ setTimeout(resolve, 10);
+ });
+ return new Response(JSON.stringify({ [DYNAMIC_KEY]: 'loaded-concurrent' }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ });
+ }) as unknown as typeof fetch;
+
+ const [a, b] = await Promise.all([
+ loadPublicDynamicEnv(),
+ loadPublicDynamicEnv(),
+ ]);
+
+ expect(fetchCount).toBe(1);
+ expect(a).toEqual({ [DYNAMIC_KEY]: 'loaded-concurrent' });
+ expect(b).toEqual({ [DYNAMIC_KEY]: 'loaded-concurrent' });
+ });
+});
diff --git a/packages/varlock/src/runtime/test/scan-for-leaks.test.ts b/packages/varlock/src/runtime/test/scan-for-leaks.test.ts
index 290a9a0d7..03c51b025 100644
--- a/packages/varlock/src/runtime/test/scan-for-leaks.test.ts
+++ b/packages/varlock/src/runtime/test/scan-for-leaks.test.ts
@@ -8,7 +8,7 @@ import type { SerializedEnvGraph } from '../../env-graph';
function setSecret(key: string, value: string) {
resetRedactionMap({
config: {
- [key]: { isSensitive: true, value },
+ [key]: { isSensitive: true, isDynamic: true, value },
},
} as unknown as SerializedEnvGraph);
}