From 6a11e40b516216bea227610e75846723876ffb31 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Sat, 6 Jun 2026 00:14:15 -0700 Subject: [PATCH 1/2] Implement static/dynamic config flow across runtime integrations and docs --- framework-tests/README.md | 2 + framework-tests/bun.lock | 5 + .../frameworks/astro/astro-shared.ts | 71 ++++++ ...astro.config.server.custom-public-path.mts | 13 + ...astro.config.server.no-public-endpoint.mts | 13 + .../frameworks/astro/files/schemas/.env.dev | 1 + .../frameworks/astro/files/schemas/.env.prod | 1 + .../astro/files/schemas/.env.schema | 4 +- .../files/schemas/.env.schema.empty-secret | 2 +- .../astro/files/schemas/.env.schema.invalid | 2 +- .../schemas/.env.schema.no-public-dynamic | 15 ++ .../files/pages/dynamic-direct-page.tsx | 10 + .../files/pages/dynamic-nested-component.tsx | 5 + .../files/pages/dynamic-nested-page.tsx | 10 + .../files/schemas/.env.schema.dynamic-public | 16 ++ .../frameworks/nextjs/nextjs-shared.ts | 31 +++ .../sveltekit/files/_base/package.json | 10 + .../sveltekit/files/_base/src/app.html | 11 + .../sveltekit/files/_base/svelte.config.js | 12 + .../sveltekit/files/_base/vite.config.ts | 10 + .../sveltekit/files/pages/basic-page.svelte | 16 ++ .../files/pages/prerender-dynamic-page.svelte | 6 + .../files/routes/public-env-endpoint.ts | 21 ++ .../sveltekit/files/schemas/.env.dev | 3 + .../sveltekit/files/schemas/.env.prod | 3 + .../sveltekit/files/schemas/.env.schema | 15 ++ .../frameworks/sveltekit/sveltekit.test.ts | 106 ++++++++ framework-tests/package.json | 1 + packages/integrations/astro/README.md | 27 ++ packages/integrations/astro/src/index.ts | 94 ++++++- .../astro/src/public-dynamic-env-route.ts | 12 + packages/integrations/astro/tsup.config.ts | 2 +- .../integrations/expo/src/babel-plugin.ts | 24 +- .../expo/test/babel-plugin.test.ts | 5 + .../expo/test/fixtures/.env.schema | 1 + .../expo/test/metro-config.test.ts | 4 +- packages/integrations/nextjs/package.json | 3 +- .../integrations/nextjs/src/dynamic-access.ts | 85 +++++++ packages/integrations/nextjs/src/loader.ts | 42 +++- .../nextjs/src/next-env-compat.ts | 11 +- packages/integrations/nextjs/src/plugin.ts | 39 +++ .../integrations/nextjs/src/webpack-plugin.ts | 11 +- packages/integrations/nextjs/tsup.config.ts | 1 + packages/integrations/vite/src/index.ts | 23 +- .../content/docs/guides/dynamic-config.mdx | 94 +++++++ .../src/content/docs/integrations/astro.mdx | 55 +++++ .../src/content/docs/integrations/nextjs.mdx | 75 +++++- .../content/docs/integrations/sveltekit.mdx | 71 +++++- .../src/content/docs/integrations/vite.mdx | 44 ++++ .../docs/reference/item-decorators.mdx | 26 ++ .../docs/reference/root-decorators.mdx | 20 ++ .../varlock/src/env-graph/lib/config-item.ts | 97 ++++++++ .../varlock/src/env-graph/lib/decorators.ts | 18 ++ .../varlock/src/env-graph/lib/env-graph.ts | 3 + .../env-graph/test/dynamic-decorator.test.ts | 122 +++++++++ .../env-graph/test/helpers/generic-test.ts | 7 + packages/varlock/src/index.ts | 3 +- packages/varlock/src/runtime/env.ts | 231 +++++++++++++++++- .../varlock/src/runtime/test/crypto.test.ts | 2 +- .../runtime/test/dynamic-public-env.test.ts | 175 +++++++++++++ .../src/runtime/test/scan-for-leaks.test.ts | 2 +- 61 files changed, 1799 insertions(+), 45 deletions(-) create mode 100644 framework-tests/frameworks/astro/files/configs/astro.config.server.custom-public-path.mts create mode 100644 framework-tests/frameworks/astro/files/configs/astro.config.server.no-public-endpoint.mts create mode 100644 framework-tests/frameworks/astro/files/schemas/.env.schema.no-public-dynamic create mode 100644 framework-tests/frameworks/nextjs/files/pages/dynamic-direct-page.tsx create mode 100644 framework-tests/frameworks/nextjs/files/pages/dynamic-nested-component.tsx create mode 100644 framework-tests/frameworks/nextjs/files/pages/dynamic-nested-page.tsx create mode 100644 framework-tests/frameworks/nextjs/files/schemas/.env.schema.dynamic-public create mode 100644 framework-tests/frameworks/sveltekit/files/_base/package.json create mode 100644 framework-tests/frameworks/sveltekit/files/_base/src/app.html create mode 100644 framework-tests/frameworks/sveltekit/files/_base/svelte.config.js create mode 100644 framework-tests/frameworks/sveltekit/files/_base/vite.config.ts create mode 100644 framework-tests/frameworks/sveltekit/files/pages/basic-page.svelte create mode 100644 framework-tests/frameworks/sveltekit/files/pages/prerender-dynamic-page.svelte create mode 100644 framework-tests/frameworks/sveltekit/files/routes/public-env-endpoint.ts create mode 100644 framework-tests/frameworks/sveltekit/files/schemas/.env.dev create mode 100644 framework-tests/frameworks/sveltekit/files/schemas/.env.prod create mode 100644 framework-tests/frameworks/sveltekit/files/schemas/.env.schema create mode 100644 framework-tests/frameworks/sveltekit/sveltekit.test.ts create mode 100644 packages/integrations/astro/src/public-dynamic-env-route.ts create mode 100644 packages/integrations/nextjs/src/dynamic-access.ts create mode 100644 packages/varlock-website/src/content/docs/guides/dynamic-config.mdx create mode 100644 packages/varlock/src/env-graph/test/dynamic-decorator.test.ts create mode 100644 packages/varlock/src/runtime/test/dynamic-public-env.test.ts 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..4e3117b84 --- /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 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 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 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); } From 239c7f84629b97accf3500f9344a0272dd409652 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Sat, 6 Jun 2026 00:14:16 -0700 Subject: [PATCH 2/2] Fix typecheck and add bumpy changeset --- .bumpy/static-dynamic-config.md | 9 +++++++++ .../varlock/src/runtime/test/dynamic-public-env.test.ts | 6 +++--- 2 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 .bumpy/static-dynamic-config.md 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/packages/varlock/src/runtime/test/dynamic-public-env.test.ts b/packages/varlock/src/runtime/test/dynamic-public-env.test.ts index 4e3117b84..b20e56f61 100644 --- a/packages/varlock/src/runtime/test/dynamic-public-env.test.ts +++ b/packages/varlock/src/runtime/test/dynamic-public-env.test.ts @@ -124,7 +124,7 @@ describe('dynamic public env hydration', () => { status: 200, headers: { 'content-type': 'application/json' }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; const payload = await loadPublicDynamicEnv(); @@ -141,7 +141,7 @@ describe('dynamic public env hydration', () => { status: 200, headers: { 'content-type': 'application/json' }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; await loadPublicDynamicEnv(); const second = await loadPublicDynamicEnv(); @@ -161,7 +161,7 @@ describe('dynamic public env hydration', () => { status: 200, headers: { 'content-type': 'application/json' }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; const [a, b] = await Promise.all([ loadPublicDynamicEnv(),