From f1b1b1fd1214b0b799d036f799fb92facc199f85 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Mon, 2 Nov 2026 11:42:34 -0700 Subject: [PATCH 1/4] Skip the Nuxt dev close-hook env refresh when no env file changed The close hook also fires on final shutdown (Ctrl+C), so every quit re-resolved the env, re-running plugin resolvers and re-triggering auth prompts such as 1Password. It now refreshes only when the env files differ from what the instance loaded: loaded sources plus any .env* file next to them, so a newly created .env still refreshes on the CLI's direct reload path. Fixes #1249 --- .bumpy/nuxt-skip-close-refresh.md | 5 ++ packages/integrations/nuxt/src/index.ts | 39 +++++++++++- .../integrations/nuxt/test/module.test.ts | 59 ++++++++++++++++--- .../src/content/docs/integrations/nuxt.mdx | 2 +- 4 files changed, 93 insertions(+), 12 deletions(-) create mode 100644 .bumpy/nuxt-skip-close-refresh.md diff --git a/.bumpy/nuxt-skip-close-refresh.md b/.bumpy/nuxt-skip-close-refresh.md new file mode 100644 index 000000000..d17174390 --- /dev/null +++ b/.bumpy/nuxt-skip-close-refresh.md @@ -0,0 +1,5 @@ +--- +"@varlock/nuxt-integration": patch +--- + +Stop re-resolving env (and re-triggering auth prompts like 1Password) when the Nuxt dev server shuts down or reloads without an env file change diff --git a/packages/integrations/nuxt/src/index.ts b/packages/integrations/nuxt/src/index.ts index c8c9bba5c..6259a8e3e 100644 --- a/packages/integrations/nuxt/src/index.ts +++ b/packages/integrations/nuxt/src/index.ts @@ -1,4 +1,4 @@ -import { existsSync } from 'node:fs'; +import { existsSync, readdirSync, statSync } from 'node:fs'; import path from 'node:path'; import { addPluginTemplate, addServerHandler, addServerPlugin, addTemplate, addTypeTemplate, addVitePlugin, defineNuxtModule, @@ -29,6 +29,32 @@ export interface VarlockNuxtModuleOptions extends VarlockVitePluginOptions { publicDynamicEndpoint?: boolean | { path?: string }, } +/** + * Cheap change marker for the env files a load could read: every loaded source + * plus any `.env*` file next to one (or in the project root), so a newly + * created `.env.local` also counts as a change. Stat only - never re-resolves. + */ +function fingerprintEnvFiles(rootDir: string, sourcePaths: Array) { + const filePaths = new Set(sourcePaths); + for (const dir of new Set([rootDir, ...sourcePaths.map((p) => path.dirname(p))])) { + try { + for (const name of readdirSync(dir)) { + if (name.startsWith('.env')) filePaths.add(path.join(dir, name)); + } + } catch { + // missing/unreadable dir - its files show up as missing below + } + } + return [...filePaths].sort().map((filePath) => { + try { + const stat = statSync(filePath); + return `${filePath}:${stat.size}:${stat.mtimeMs}`; + } catch { + return `${filePath}:missing`; + } + }).join('\n'); +} + const varlockNuxtModule: NuxtModule = defineNuxtModule({ meta: { name: 'varlock', @@ -73,9 +99,11 @@ const varlockNuxtModule: NuxtModule = defineNuxtModule // `watch` is missing from the resolved options on some older nuxt 3 // versions - pushing into a fresh array is then a harmless no-op nuxt.options.watch ||= []; - for (const envFilePath of getVarlockEnvSourcePaths(nuxt.options.rootDir)) { + const envSourcePaths = getVarlockEnvSourcePaths(nuxt.options.rootDir); + for (const envFilePath of envSourcePaths) { if (!nuxt.options.watch.includes(envFilePath)) nuxt.options.watch.push(envFilePath); } + const envFilesFingerprint = fingerprintEnvFiles(nuxt.options.rootDir, envSourcePaths); // Re-resolve env when a dev reload begins, BEFORE the new nuxt instance // evaluates nuxt.config. `ENV` is a proxy over shared global state, so // refreshing the store here makes config-time reads (`varlock/auto-load` @@ -90,6 +118,12 @@ const varlockNuxtModule: NuxtModule = defineNuxtModule // both; instance-local state (not elapsed time) dedupes them, since // hooks are awaited and another restart listener can delay `close` // arbitrarily. + // + // `close` also fires on final shutdown (Ctrl+C), and nothing tells the + // two apart. So it only refreshes when the env files differ from what + // this instance loaded: a plain quit (or a nuxt.config-only edit) then + // skips re-resolving, which would otherwise re-run plugin resolvers and + // can trigger an extra auth prompt (e.g. 1Password). let envRefreshedByRestart = false; nuxt.hook('restart', () => { envRefreshedByRestart = true; @@ -97,6 +131,7 @@ const varlockNuxtModule: NuxtModule = defineNuxtModule }); nuxt.hook('close', () => { if (envRefreshedByRestart) return; + if (fingerprintEnvFiles(nuxt.options.rootDir, envSourcePaths) === envFilesFingerprint) return; refreshVarlockEnv(nuxt.options.rootDir); }); } diff --git a/packages/integrations/nuxt/test/module.test.ts b/packages/integrations/nuxt/test/module.test.ts index c70cf2ba2..4e790fe26 100644 --- a/packages/integrations/nuxt/test/module.test.ts +++ b/packages/integrations/nuxt/test/module.test.ts @@ -1,6 +1,11 @@ import { beforeEach, describe, expect, it, vi, } from 'vitest'; +import { + mkdtempSync, rmSync, writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; import pkg from '../package.json'; @@ -155,7 +160,7 @@ describe('@varlock/nuxt-integration module', () => { nuxtModule.setup?.({}, fakeNuxt); // `restart` covers hook-driven restarts (env file edits via options.watch); - // `close` covers the CLI's direct reload path (nuxt.config edits), which + // `close` covers the CLI's direct reload path (its dotenv file), which // never invokes the restart hook const restartHook = fakeNuxt.hook.mock.calls.find((call) => call[0] === 'restart'); const closeHook = fakeNuxt.hook.mock.calls.find((call) => call[0] === 'close'); @@ -170,14 +175,50 @@ describe('@varlock/nuxt-integration module', () => { expect(refreshVarlockEnvMock).toHaveBeenCalledTimes(1); }); - it('re-resolves env on a direct reload (close without restart)', async () => { - const nuxtModule = await loadModule(); - const fakeNuxt = makeDevNuxt(); - nuxtModule.setup?.({}, fakeNuxt); - - const closeHook = fakeNuxt.hook.mock.calls.find((call) => call[0] === 'close'); - (closeHook![1] as () => void)(); - expect(refreshVarlockEnvMock).toHaveBeenCalledWith('/fake-project'); + describe('close without restart', () => { + let projectDir: string; + beforeEach(() => { + projectDir = mkdtempSync(path.join(tmpdir(), 'varlock-nuxt-close-')); + writeFileSync(path.join(projectDir, '.env.schema'), 'FOO=bar\n'); + getVarlockEnvSourcePathsMock.mockReturnValue([path.join(projectDir, '.env.schema')]); + return () => { + getVarlockEnvSourcePathsMock.mockReset(); + getVarlockEnvSourcePathsMock.mockReturnValue([]); + rmSync(projectDir, { recursive: true, force: true }); + }; + }); + + async function setupAndGetCloseHook() { + const nuxtModule = await loadModule(); + const fakeNuxt = makeDevNuxt(); + fakeNuxt.options.rootDir = projectDir; + nuxtModule.setup?.({}, fakeNuxt); + const closeHook = fakeNuxt.hook.mock.calls.find((call) => call[0] === 'close'); + return closeHook![1] as () => void; + } + + it('re-resolves env on a direct reload after an env file is created', async () => { + const closeHook = await setupAndGetCloseHook(); + // the CLI's own dotenv watcher reloads without the restart hook + writeFileSync(path.join(projectDir, '.env'), 'FOO=changed\n'); + closeHook(); + expect(refreshVarlockEnvMock).toHaveBeenCalledWith(projectDir); + }); + + it('re-resolves env after a loaded env file is edited', async () => { + const closeHook = await setupAndGetCloseHook(); + writeFileSync(path.join(projectDir, '.env.schema'), 'FOO=a-longer-value\n'); + closeHook(); + expect(refreshVarlockEnvMock).toHaveBeenCalledWith(projectDir); + }); + + it('does not re-resolve env on shutdown when no env file changed', async () => { + // final shutdown (Ctrl+C) also fires `close`; re-resolving there would + // re-run plugin resolvers for nothing (and can re-prompt for auth) + const closeHook = await setupAndGetCloseHook(); + closeHook(); + expect(refreshVarlockEnvMock).not.toHaveBeenCalled(); + }); }); it('does not watch or hook restarts outside dev', async () => { diff --git a/packages/varlock-website/src/content/docs/integrations/nuxt.mdx b/packages/varlock-website/src/content/docs/integrations/nuxt.mdx index 7e371e3c1..a642764a6 100644 --- a/packages/varlock-website/src/content/docs/integrations/nuxt.mdx +++ b/packages/varlock-website/src/content/docs/integrations/nuxt.mdx @@ -165,7 +165,7 @@ export default defineNuxtConfig({ ## Dev server behavior -The module watches every env file varlock loads, including `.env.schema`, and restarts the dev server when one changes. This keeps everything in sync: inlined page values, server routes, the Nitro side, and values captured in `nuxt.config.ts` itself (the module re-resolves env at the start of every dev-server reload, before the config re-evaluates, including reloads triggered by editing `nuxt.config.ts` directly). Nuxt's own watcher only reacts to a plain `.env` file. +The module watches every env file varlock loads, including `.env.schema`, and restarts the dev server when one changes. This keeps everything in sync: inlined page values, server routes, the Nitro side, and values captured in `nuxt.config.ts` itself (the module re-resolves env at the start of a dev-server reload, before the config re-evaluates, whenever an env file changed). Reloads where no env file changed, such as editing only `nuxt.config.ts`, and stopping the dev server reuse the env already loaded, so plugin resolvers (and any auth prompts they trigger) do not run again. Nuxt's own watcher only reacts to a plain `.env` file. If your config becomes invalid (a failed validation, a syntax error in a schema file), requests show an error page describing the problem, and the server recovers automatically once you fix and save the file. From 6550fa1ceab3cceb4bc3572e1ee752ac2e09017d Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Mon, 2 Nov 2026 13:01:59 -0700 Subject: [PATCH 2/4] Make the vite integration the single owner of env freshness Replace the consumed-load flag with a stat fingerprint of the env files a load could read. Dev restarts and the new ensureVarlockEnvFresh() only re-resolve when an env file changed or appeared, or the last load failed, so a config-only reload keeps the snapshot its config was evaluated against. The Nuxt module calls ensureVarlockEnvFresh() from both reload hooks instead of fingerprinting itself, which also replaces its restart/close dedupe flag. Startup adopts a graph varlock/auto-load already resolved from the same directory, and a telemetry name change no longer reloads. A Nuxt dev server now resolves env once at startup instead of four times. --- .bumpy/vite-env-freshness.md | 5 + packages/integrations/nuxt/src/index.ts | 69 ++--- .../integrations/nuxt/test/module.test.ts | 79 ++---- packages/integrations/vite/src/index.ts | 172 +++++++++--- .../vite/test/ssr-init-code.test.ts | 251 ++++++++++++++---- .../src/content/docs/integrations/vite.mdx | 4 + 6 files changed, 368 insertions(+), 212 deletions(-) create mode 100644 .bumpy/vite-env-freshness.md diff --git a/.bumpy/vite-env-freshness.md b/.bumpy/vite-env-freshness.md new file mode 100644 index 000000000..29a52fb64 --- /dev/null +++ b/.bumpy/vite-env-freshness.md @@ -0,0 +1,5 @@ +--- +"@varlock/vite-integration": patch +--- + +Dev server restarts only re-resolve env when an env file changed or the last load failed, and startup reuses env already resolved by varlock/auto-load, so plugin resolvers run once instead of several times diff --git a/packages/integrations/nuxt/src/index.ts b/packages/integrations/nuxt/src/index.ts index 6259a8e3e..1c82c1729 100644 --- a/packages/integrations/nuxt/src/index.ts +++ b/packages/integrations/nuxt/src/index.ts @@ -1,11 +1,11 @@ -import { existsSync, readdirSync, statSync } from 'node:fs'; +import { existsSync } from 'node:fs'; import path from 'node:path'; import { addPluginTemplate, addServerHandler, addServerPlugin, addTemplate, addTypeTemplate, addVitePlugin, defineNuxtModule, } from '@nuxt/kit'; import type { NuxtModule } from '@nuxt/schema'; import { - buildVarlockSsrInitCode, ensureDevEncryptionKey, getVarlockEnvSourcePaths, getVarlockLoadedEnv, refreshVarlockEnv, + buildVarlockSsrInitCode, ensureDevEncryptionKey, ensureVarlockEnvFresh, getVarlockEnvSourcePaths, getVarlockLoadedEnv, varlockVitePlugin, type VarlockVitePluginOptions, } from '@varlock/vite-integration'; @@ -29,32 +29,6 @@ export interface VarlockNuxtModuleOptions extends VarlockVitePluginOptions { publicDynamicEndpoint?: boolean | { path?: string }, } -/** - * Cheap change marker for the env files a load could read: every loaded source - * plus any `.env*` file next to one (or in the project root), so a newly - * created `.env.local` also counts as a change. Stat only - never re-resolves. - */ -function fingerprintEnvFiles(rootDir: string, sourcePaths: Array) { - const filePaths = new Set(sourcePaths); - for (const dir of new Set([rootDir, ...sourcePaths.map((p) => path.dirname(p))])) { - try { - for (const name of readdirSync(dir)) { - if (name.startsWith('.env')) filePaths.add(path.join(dir, name)); - } - } catch { - // missing/unreadable dir - its files show up as missing below - } - } - return [...filePaths].sort().map((filePath) => { - try { - const stat = statSync(filePath); - return `${filePath}:${stat.size}:${stat.mtimeMs}`; - } catch { - return `${filePath}:missing`; - } - }).join('\n'); -} - const varlockNuxtModule: NuxtModule = defineNuxtModule({ meta: { name: 'varlock', @@ -99,40 +73,31 @@ const varlockNuxtModule: NuxtModule = defineNuxtModule // `watch` is missing from the resolved options on some older nuxt 3 // versions - pushing into a fresh array is then a harmless no-op nuxt.options.watch ||= []; - const envSourcePaths = getVarlockEnvSourcePaths(nuxt.options.rootDir); - for (const envFilePath of envSourcePaths) { + for (const envFilePath of getVarlockEnvSourcePaths(nuxt.options.rootDir)) { if (!nuxt.options.watch.includes(envFilePath)) nuxt.options.watch.push(envFilePath); } - const envFilesFingerprint = fingerprintEnvFiles(nuxt.options.rootDir, envSourcePaths); - // Re-resolve env when a dev reload begins, BEFORE the new nuxt instance - // evaluates nuxt.config. `ENV` is a proxy over shared global state, so - // refreshing the store here makes config-time reads (`varlock/auto-load` - // in nuxt.config) and re-injected process.env values fresh, even though - // the config's cached auto-load import does not re-execute. + // Bring env up to date when a dev reload begins, BEFORE the new nuxt + // instance evaluates nuxt.config. `ENV` is a proxy over shared global + // state, so refreshing the store here makes config-time reads + // (`varlock/auto-load` in nuxt.config) and re-injected process.env values + // fresh, even though the config's cached auto-load import does not + // re-execute. // // Two hooks because reloads reach a running instance two ways: env-file // changes surfaced via `options.watch` invoke the `restart` hook, while // the CLI's own config watcher (nuxt.config edits, its dotenv file) can // reload directly without it - but every reload path closes the old - // instance first, so `close` catches those. A hook-driven restart fires - // both; instance-local state (not elapsed time) dedupes them, since - // hooks are awaited and another restart listener can delay `close` - // arbitrarily. - // - // `close` also fires on final shutdown (Ctrl+C), and nothing tells the - // two apart. So it only refreshes when the env files differ from what - // this instance loaded: a plain quit (or a nuxt.config-only edit) then - // skips re-resolving, which would otherwise re-run plugin resolvers and - // can trigger an extra auth prompt (e.g. 1Password). - let envRefreshedByRestart = false; + // instance first, so `close` catches those. `close` also fires on final + // shutdown, and nothing tells the two apart, so neither hook forces a + // reload: `ensureVarlockEnvFresh` only re-resolves when an env file + // changed since the last load. A plain quit or a nuxt.config-only reload + // then skips re-running resolvers (and any auth prompts they trigger), + // and a hook-driven restart that fires both hooks loads once. nuxt.hook('restart', () => { - envRefreshedByRestart = true; - refreshVarlockEnv(nuxt.options.rootDir); + ensureVarlockEnvFresh(nuxt.options.rootDir); }); nuxt.hook('close', () => { - if (envRefreshedByRestart) return; - if (fingerprintEnvFiles(nuxt.options.rootDir, envSourcePaths) === envFilesFingerprint) return; - refreshVarlockEnv(nuxt.options.rootDir); + ensureVarlockEnvFresh(nuxt.options.rootDir); }); } diff --git a/packages/integrations/nuxt/test/module.test.ts b/packages/integrations/nuxt/test/module.test.ts index 4e790fe26..28e124a81 100644 --- a/packages/integrations/nuxt/test/module.test.ts +++ b/packages/integrations/nuxt/test/module.test.ts @@ -1,11 +1,6 @@ import { beforeEach, describe, expect, it, vi, } from 'vitest'; -import { - mkdtempSync, rmSync, writeFileSync, -} from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; import pkg from '../package.json'; @@ -20,6 +15,7 @@ const varlockVitePluginMock = vi.fn(() => ({ name: 'varlock-vite-plugin' })); const buildVarlockSsrInitCodeMock = vi.fn(() => 'initVarlockEnv();'); const getVarlockEnvSourcePathsMock = vi.fn((): Array => []); const refreshVarlockEnvMock = vi.fn(); +const ensureVarlockEnvFreshMock = vi.fn(); const ensureDevEncryptionKeyMock = vi.fn(); type FakeLoadedEnv = { basePath: string, @@ -51,6 +47,7 @@ vi.mock('@nuxt/kit', () => ({ vi.mock('@varlock/vite-integration', () => ({ buildVarlockSsrInitCode: buildVarlockSsrInitCodeMock, ensureDevEncryptionKey: ensureDevEncryptionKeyMock, + ensureVarlockEnvFresh: ensureVarlockEnvFreshMock, getVarlockEnvSourcePaths: getVarlockEnvSourcePathsMock, getVarlockLoadedEnv: getVarlockLoadedEnvMock, refreshVarlockEnv: refreshVarlockEnvMock, @@ -154,71 +151,25 @@ describe('@varlock/nuxt-integration module', () => { expect(fakeNuxt.options.watch).toEqual(['/fake-project/.env.schema', '/fake-project/.env.local']); }); - it('re-resolves env when a dev reload begins, once per reload', async () => { + it('brings env up to date when a dev reload begins, via both reload hooks', async () => { const nuxtModule = await loadModule(); const fakeNuxt = makeDevNuxt(); nuxtModule.setup?.({}, fakeNuxt); // `restart` covers hook-driven restarts (env file edits via options.watch); - // `close` covers the CLI's direct reload path (its dotenv file), which - // never invokes the restart hook - const restartHook = fakeNuxt.hook.mock.calls.find((call) => call[0] === 'restart'); - const closeHook = fakeNuxt.hook.mock.calls.find((call) => call[0] === 'close'); - expect(restartHook).toBeDefined(); - expect(closeHook).toBeDefined(); - - expect(refreshVarlockEnvMock).not.toHaveBeenCalled(); - (restartHook![1] as () => void)(); - expect(refreshVarlockEnvMock).toHaveBeenCalledWith('/fake-project'); - // a hook-driven restart fires both hooks - the refresh is deduped - (closeHook![1] as () => void)(); - expect(refreshVarlockEnvMock).toHaveBeenCalledTimes(1); - }); - - describe('close without restart', () => { - let projectDir: string; - beforeEach(() => { - projectDir = mkdtempSync(path.join(tmpdir(), 'varlock-nuxt-close-')); - writeFileSync(path.join(projectDir, '.env.schema'), 'FOO=bar\n'); - getVarlockEnvSourcePathsMock.mockReturnValue([path.join(projectDir, '.env.schema')]); - return () => { - getVarlockEnvSourcePathsMock.mockReset(); - getVarlockEnvSourcePathsMock.mockReturnValue([]); - rmSync(projectDir, { recursive: true, force: true }); - }; - }); - - async function setupAndGetCloseHook() { - const nuxtModule = await loadModule(); - const fakeNuxt = makeDevNuxt(); - fakeNuxt.options.rootDir = projectDir; - nuxtModule.setup?.({}, fakeNuxt); - const closeHook = fakeNuxt.hook.mock.calls.find((call) => call[0] === 'close'); - return closeHook![1] as () => void; + // `close` covers the CLI's direct reload path (its dotenv file, nuxt.config + // edits), which never invokes the restart hook. `close` also fires on + // final shutdown - the vite integration decides whether anything is stale, + // so an unchanged env is never re-resolved + for (const hookName of ['restart', 'close']) { + const hook = fakeNuxt.hook.mock.calls.find((call) => call[0] === hookName); + expect(hook).toBeDefined(); + ensureVarlockEnvFreshMock.mockClear(); + (hook![1] as () => void)(); + expect(ensureVarlockEnvFreshMock).toHaveBeenCalledWith('/fake-project'); } - - it('re-resolves env on a direct reload after an env file is created', async () => { - const closeHook = await setupAndGetCloseHook(); - // the CLI's own dotenv watcher reloads without the restart hook - writeFileSync(path.join(projectDir, '.env'), 'FOO=changed\n'); - closeHook(); - expect(refreshVarlockEnvMock).toHaveBeenCalledWith(projectDir); - }); - - it('re-resolves env after a loaded env file is edited', async () => { - const closeHook = await setupAndGetCloseHook(); - writeFileSync(path.join(projectDir, '.env.schema'), 'FOO=a-longer-value\n'); - closeHook(); - expect(refreshVarlockEnvMock).toHaveBeenCalledWith(projectDir); - }); - - it('does not re-resolve env on shutdown when no env file changed', async () => { - // final shutdown (Ctrl+C) also fires `close`; re-resolving there would - // re-run plugin resolvers for nothing (and can re-prompt for auth) - const closeHook = await setupAndGetCloseHook(); - closeHook(); - expect(refreshVarlockEnvMock).not.toHaveBeenCalled(); - }); + // never a forced reload - that would re-run resolvers on every quit + expect(refreshVarlockEnvMock).not.toHaveBeenCalled(); }); it('does not watch or hook restarts outside dev', async () => { diff --git a/packages/integrations/vite/src/index.ts b/packages/integrations/vite/src/index.ts index a3d677eea..8291c91a8 100644 --- a/packages/integrations/vite/src/index.ts +++ b/packages/integrations/vite/src/index.ts @@ -61,8 +61,9 @@ export let varlockLoadedEnv: SerializedEnvGraph; /** Stderr output from the last failed varlock load (ANSI-colored) */ export let varlockLastError: string | undefined; let lastErrorAt = 0; -// set by every load, cleared by the main config hook once it has used that load -let loadIsUnconsumed = false; +// stat fingerprint of the env files the current graph could have read, taken +// when it was loaded - undefined means "unknown", which always counts as stale +let loadedEnvFingerprint: string | undefined; // one-time guard for the SvelteKit+Cloudflare auto-detection notice let cfDetectNoticeLogged = false; // one-time guard for the Vercel unencrypted resolved-env warning @@ -134,11 +135,90 @@ let activeIntegrationTelemetry = { */ let loadedConfigDir: string | undefined; +/** + * Cheap change marker for the env files a load could read: every loaded source + * plus any `.env*` file next to one, so a newly created `.env.local` also + * counts as a change. Stat only - never re-resolves. Non-regular files (FIFOs) + * are skipped, since their stat churns on every read. + */ +function fingerprintEnvFiles(graph: SerializedEnvGraph | undefined) { + const filePaths = new Set(); + const dirs = new Set(); + if (graph?.basePath) { + dirs.add(path.resolve(graph.basePath)); + for (const source of graph.sources || []) { + if (!source.enabled || !source.path) continue; + const absPath = path.resolve(graph.basePath, source.path); + filePaths.add(absPath); + dirs.add(path.dirname(absPath)); + } + } + for (const dir of dirs) { + try { + for (const name of fs.readdirSync(dir)) { + if (name.startsWith('.env')) filePaths.add(path.join(dir, name)); + } + } catch { + // missing/unreadable dir - its files show up as missing below + } + } + let newestMtimeMs = 0; + const fingerprint = [...filePaths].sort().map((filePath) => { + try { + const stat = fs.statSync(filePath); + if (!stat.isFile()) return `${filePath}:non-regular`; + newestMtimeMs = Math.max(newestMtimeMs, stat.mtimeMs); + return `${filePath}:${stat.size}:${stat.mtimeMs}`; + } catch { + return `${filePath}:missing`; + } + }).join('\n'); + return { fingerprint, newestMtimeMs }; +} + +/** + * Whether the loaded graph is out of date for `dir`: a different directory, a + * failed last load (a retry may succeed), or an env file that changed since. + */ +function isLoadedEnvStale(dir: string) { + if (dir !== loadedConfigDir || !configIsValid || loadedEnvFingerprint === undefined) return true; + return fingerprintEnvFiles(varlockLoadedEnv).fingerprint !== loadedEnvFingerprint; +} + +/** shared tail of every successful load (or adopted graph) */ +function applyLoadedEnv(cwd: string | undefined, prevItemCount: number) { + // Reloading from a different directory can silently wipe a working config if + // that directory has no schema — `varlock load` succeeds there and returns an + // empty graph, which then disables every static replacement. Surface it + // instead of letting the build produce a broken bundle. + if (cwd && prevItemCount > 0 && Object.keys(varlockLoadedEnv?.config || {}).length === 0) { + console.warn( + `\x1b[33m[varlock] ⚠️ no env items found when loading from ${cwd}\x1b[0m\n` + + 'This directory has no `.env.schema`, so `ENV.*` references will not be replaced at build time.\n' + + "If your framework points vite's `root` at a source subdirectory, the integration should pass " + + '`rootDir` to `varlockVitePlugin()`.', + ); + } + + // If a runtime auto-load already populated the global (e.g. `varlock/auto-load` + // imported in nuxt.config), refresh it with the newly resolved graph - + // initVarlockEnv prefers it over the process.env blob, so a stale copy would + // pin the old values through every re-init. + if ((globalThis as any).__varlockLoadedEnv) { + (globalThis as any).__varlockLoadedEnv = varlockLoadedEnv; + } + + // initialize varlock and patch globals as necessary (no-ops where disabled by settings) + initVarlockRuntime(); + + resetStaticReplacements(); +} + function reloadConfig(cwd?: string) { debug('loading config - count =', ++loadCount, cwd ? `(cwd: ${cwd})` : ''); const prevItemCount = Object.keys(varlockLoadedEnv?.config || {}).length; loadedConfigDir = path.resolve(cwd ?? process.cwd()); - loadIsUnconsumed = true; + const loadStartedAt = Date.now(); try { const { stdout } = execSyncVarlock('load --format json-full --compact', { fullResult: true, @@ -187,35 +267,36 @@ function reloadConfig(cwd?: string) { return; } - // Reloading from a different directory can silently wipe a working config if - // that directory has no schema — `varlock load` succeeds there and returns an - // empty graph, which then disables every static replacement. Surface it - // instead of letting the build produce a broken bundle. - if (cwd && prevItemCount > 0 && Object.keys(varlockLoadedEnv?.config || {}).length === 0) { - console.warn( - `\x1b[33m[varlock] ⚠️ no env items found when loading from ${cwd}\x1b[0m\n` - + 'This directory has no `.env.schema`, so `ENV.*` references will not be replaced at build time.\n' - + "If your framework points vite's `root` at a source subdirectory, the integration should pass " - + '`rootDir` to `varlockVitePlugin()`.', - ); - } + // a file edited while the load was running may not be reflected in it, so + // leave the fingerprint unknown and let the next freshness check reload + const { fingerprint, newestMtimeMs } = fingerprintEnvFiles(varlockLoadedEnv); + loadedEnvFingerprint = Math.floor(newestMtimeMs) > loadStartedAt ? undefined : fingerprint; - // If a runtime auto-load already populated the global (e.g. `varlock/auto-load` - // imported in nuxt.config), refresh it with the newly resolved graph - - // initVarlockEnv prefers it over the process.env blob, so a stale copy would - // pin the old values through every re-init. - if ((globalThis as any).__varlockLoadedEnv) { - (globalThis as any).__varlockLoadedEnv = varlockLoadedEnv; - } + applyLoadedEnv(cwd, prevItemCount); +} - // initialize varlock and patch globals as necessary (no-ops where disabled by settings) - initVarlockRuntime(); - resetStaticReplacements(); +/** + * Adopt a graph `varlock/auto-load` already resolved in this process (e.g. it is + * imported at the top of nuxt.config / vite.config), instead of resolving the + * same env a second time. Only when it was loaded from the directory this + * module would load from - anything else falls back to a fresh load. + */ +function adoptAutoLoadedEnv(): boolean { + const graph = (globalThis as any).__varlockLoadedEnv as SerializedEnvGraph | undefined; + if (!graph?.basePath || path.resolve(graph.basePath) !== path.resolve(process.cwd())) return false; + debug('adopting env graph already resolved by varlock/auto-load'); + varlockLoadedEnv = graph; + loadedConfigDir = path.resolve(process.cwd()); + configIsValid = true; + loadedEnvFingerprint = fingerprintEnvFiles(graph).fingerprint; + // auto-load already set (and possibly encrypted) process.env.__VARLOCK_ENV + applyLoadedEnv(undefined, 0); + return true; } // we run this right away so the globals get injected into the vite.config file -reloadConfig(); +if (!adoptAutoLoadedEnv()) reloadConfig(); let devMintedKey: string | undefined; @@ -296,6 +377,21 @@ export function refreshVarlockEnv(rootDir?: string) { reloadConfig(rootDir ?? loadedConfigDir); } +/** + * Re-resolve only if the loaded env is out of date for `rootDir`: a different + * directory, a failed last load, or an env file that changed (or appeared) + * since it was loaded. Otherwise a no-op, so resolvers (and any auth prompts + * they trigger) do not re-run. Used by the Nuxt module when an instance + * closes, which happens both before a dev reload and on final shutdown. + * Returns whether it reloaded. + */ +export function ensureVarlockEnvFresh(rootDir?: string): boolean { + const dir = path.resolve(rootDir ?? loadedConfigDir ?? process.cwd()); + if (!isLoadedEnvStale(dir)) return false; + reloadConfig(dir); + return true; +} + /** * Absolute paths of the env files the current config was loaded from, for * integrations that manage their own file watching (e.g. the Nuxt module @@ -526,14 +622,9 @@ const RESERVED_DEFINE_KEYS = [ export function varlockVitePlugin( vitePluginOptions?: VarlockVitePluginOptions, ): any { + // attributes later loads to the integration - not worth re-resolving for if (vitePluginOptions?.integrationTelemetry) { - const prevName = activeIntegrationTelemetry.name; activeIntegrationTelemetry = vitePluginOptions.integrationTelemetry; - if (prevName !== activeIntegrationTelemetry.name) { - // reload from wherever the config currently comes from — falling back to - // cwd here would drop a root an integration already corrected - reloadConfig(vitePluginOptions.rootDir ?? loadedConfigDir); - } } // Resolved at build time. These start from the passed options but may be @@ -702,16 +793,15 @@ See https://varlock.dev/integrations/vite/ for more details. // root when a framework CLI is pointed elsewhere (`nuxt build --cwd`). const rootDiffersFromLoaded = !!(projectRoot && projectRoot !== loadedConfigDir); - // A load nothing has used yet (the module-level load, the dev-key hook, - // an integration's refresh) is current for this resolve. Otherwise this - // is a dev restart: the module stays cached, so only this hook can - // reload. Loads outside a resolve only happen when the root differs, - // which the main hook has already settled, so they do not leave a stale - // load marked as unused. - if (rootDiffersFromLoaded || (isDevCommand && !loadIsUnconsumed)) { - reloadConfig(projectRoot ?? loadedConfigDir); + // On a dev restart this module stays cached, so only this hook can + // reload - but only when the env is actually stale (an env file changed + // or the last load failed). A restart for any other reason (a config-only + // reload) keeps the snapshot that config was evaluated against. + if (rootDiffersFromLoaded) { + reloadConfig(projectRoot); + } else if (isDevCommand) { + ensureVarlockEnvFresh(loadedConfigDir); } - loadIsUnconsumed = false; // we do not want to inject via config.define - instead we use @rollup/plugin-replace diff --git a/packages/integrations/vite/test/ssr-init-code.test.ts b/packages/integrations/vite/test/ssr-init-code.test.ts index c114cac67..8707e9edc 100644 --- a/packages/integrations/vite/test/ssr-init-code.test.ts +++ b/packages/integrations/vite/test/ssr-init-code.test.ts @@ -1,33 +1,51 @@ import { afterEach, beforeEach, describe, expect, it, vi, } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; import { resolveConfig } from 'vite'; import { decryptEnvBlobSync } from 'varlock/encrypt-env'; // mutable so individual tests can flip the decorator before importing the plugin -const mockState = vi.hoisted(() => ({ encryptInjectedEnv: true, publicValue: 'public-value', loads: 0 })); +const mockState = vi.hoisted(() => ({ + encryptInjectedEnv: true, + publicValue: 'public-value', + loads: 0, + basePath: '/fake-project', + sources: [] as Array<{ enabled: boolean, path: string }>, + failNextLoad: false, +})); -vi.mock('varlock/exec-sync-varlock', () => ({ - execSyncVarlock: vi.fn(() => { - mockState.loads++; - return { - stdout: JSON.stringify({ - basePath: '/fake-project', - sources: [], +vi.mock('varlock/exec-sync-varlock', () => { + class VarlockExecError extends Error { + stdout?: string; + stderr?: string; + } + return { + execSyncVarlock: vi.fn(() => { + mockState.loads++; + const stdout = JSON.stringify({ + basePath: mockState.basePath, + sources: mockState.sources, settings: { encryptInjectedEnv: mockState.encryptInjectedEnv }, config: { SECRET_KEY: { value: 'super-secret-value', isSensitive: true, isDynamic: true }, PUBLIC_VAR: { value: mockState.publicValue, isSensitive: false, isDynamic: false }, }, - }), - stderr: '', - }; - }), - VarlockExecError: class VarlockExecError extends Error { - stdout?: string; - stderr?: string; - }, -})); + }); + if (mockState.failNextLoad) { + mockState.failNextLoad = false; + const err = new VarlockExecError('load failed'); + err.stdout = stdout; + err.stderr = 'resolver failed'; + throw err; + } + return { stdout, stderr: '' }; + }), + VarlockExecError, + }; +}); // `@varlock/cloudflare-integration` is loaded lazily and is not a dependency of this package vi.mock('@varlock/cloudflare-integration/ssr-entry-code', () => ({ @@ -60,6 +78,10 @@ beforeEach(() => { vi.resetModules(); mockState.encryptInjectedEnv = true; mockState.publicValue = 'public-value'; + mockState.basePath = '/fake-project'; + mockState.sources = []; + mockState.failNextLoad = false; + delete (globalThis as any).__varlockLoadedEnv; // the plugin writes process.env directly, which vi.unstubAllEnvs() does not undo delete process.env._VARLOCK_ENV_KEY; }); @@ -267,9 +289,23 @@ describe('config hook - reserved vars in vite `define`', () => { }); }); -describe('config hook - dev server restarts', () => { - // any directory other than the cwd, which the module-level load uses - const appRoot = import.meta.dirname; +describe('env freshness across dev restarts', () => { + // a temp project with a real schema file, so the env-file fingerprint has + // something to compare. It is never the cwd, which the module-level load uses. + let appRoot: string; + beforeEach(() => { + appRoot = mkdtempSync(path.join(tmpdir(), 'varlock-vite-fresh-')); + writeFileSync(path.join(appRoot, '.env.schema'), 'PUBLIC_VAR=first\n'); + mockState.basePath = appRoot; + mockState.sources = [{ enabled: true, path: '.env.schema' }]; + return () => rmSync(appRoot, { recursive: true, force: true }); + }); + + // a resolver output and the file it comes from change together + function editSchema(value: string) { + writeFileSync(path.join(appRoot, '.env.schema'), `PUBLIC_VAR=${value}\n# edited\n`); + mockState.publicValue = value; + } // a restart resolves the config again with new plugin instances, while this // module stays cached, so only the config hook can reload the env @@ -282,72 +318,137 @@ describe('config hook - dev server restarts', () => { }, 'serve'); } - it('reloads on the first restart when the vite root is not the cwd', async () => { + it('reuses the loaded env on a restart when no env file changed', async () => { const { getVarlockLoadedEnv, varlockVitePlugin } = await importPlugin(); - mockState.publicValue = 'first'; await resolveDevConfig(varlockVitePlugin(), appRoot); - expect(getVarlockLoadedEnv()?.config.PUBLIC_VAR?.value).toBe('first'); + const loadsBefore = mockState.loads; - mockState.publicValue = 'second'; + // a resolver whose output changes on its own (exec(), a remote secret) is + // not re-run just because the dev server restarted + mockState.publicValue = 'changed-remotely'; await resolveDevConfig(varlockVitePlugin(), appRoot); - expect(getVarlockLoadedEnv()?.config.PUBLIC_VAR?.value).toBe('second'); - }); - - it('reloads on the first restart with `rootDir` and a key already in the environment', async () => { - // with a key set, the dev-key lifecycle hook does not load `rootDir` first - vi.stubEnv('_VARLOCK_ENV_KEY', TEST_KEY); - const { getVarlockLoadedEnv, varlockVitePlugin } = await importPlugin(); - - mockState.publicValue = 'first'; - await resolveDevConfig(varlockVitePlugin({ rootDir: appRoot })); + await resolveDevConfig(varlockVitePlugin(), appRoot); + expect(mockState.loads - loadsBefore).toBe(0); expect(getVarlockLoadedEnv()?.config.PUBLIC_VAR?.value).toBe('first'); - - mockState.publicValue = 'second'; - await resolveDevConfig(varlockVitePlugin({ rootDir: appRoot })); - expect(getVarlockLoadedEnv()?.config.PUBLIC_VAR?.value).toBe('second'); }); - it('reloads on every restart when the vite root is the cwd, but not on the first resolve', async () => { + it('reloads once per restart after a loaded env file changes', async () => { const { getVarlockLoadedEnv, varlockVitePlugin } = await importPlugin(); + mockState.publicValue = 'first'; + await resolveDevConfig(varlockVitePlugin(), appRoot); const loadsBefore = mockState.loads; - await resolveDevConfig(varlockVitePlugin(), process.cwd()); - expect(mockState.loads - loadsBefore).toBe(0); - for (const value of ['second', 'third']) { - mockState.publicValue = value; - await resolveDevConfig(varlockVitePlugin(), process.cwd()); + editSchema(value); + await resolveDevConfig(varlockVitePlugin(), appRoot); expect(getVarlockLoadedEnv()?.config.PUBLIC_VAR?.value).toBe(value); } expect(mockState.loads - loadsBefore).toBe(2); }); - it('loads once per resolve across restarts when the vite root is not the cwd', async () => { + it('reloads on a restart after a new .env file appears next to a loaded source', async () => { const { getVarlockLoadedEnv, varlockVitePlugin } = await importPlugin(); + await resolveDevConfig(varlockVitePlugin(), appRoot); const loadsBefore = mockState.loads; - for (const value of ['first', 'second', 'third']) { - mockState.publicValue = value; - await resolveDevConfig(varlockVitePlugin(), appRoot); - expect(getVarlockLoadedEnv()?.config.PUBLIC_VAR?.value).toBe(value); - } - expect(mockState.loads - loadsBefore).toBe(3); + writeFileSync(path.join(appRoot, '.env.local'), 'PUBLIC_VAR=from-local\n'); + mockState.publicValue = 'from-local'; + await resolveDevConfig(varlockVitePlugin(), appRoot); + expect(mockState.loads - loadsBefore).toBe(1); + expect(getVarlockLoadedEnv()?.config.PUBLIC_VAR?.value).toBe('from-local'); + }); + + it('retries on a restart when the previous load failed, even with no file change', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + const { getVarlockLoadedEnv, varlockVitePlugin } = await importPlugin(); + mockState.failNextLoad = true; + await resolveDevConfig(varlockVitePlugin(), appRoot); + const loadsBefore = mockState.loads; + + mockState.publicValue = 'recovered'; + await resolveDevConfig(varlockVitePlugin(), appRoot); + expect(mockState.loads - loadsBefore).toBe(1); + expect(getVarlockLoadedEnv()?.config.PUBLIC_VAR?.value).toBe('recovered'); }); - it('loads once per resolve with `rootDir` when the dev-key hook loads first', async () => { + it('loads once per change with `rootDir` when the dev-key hook loads first', async () => { const { getVarlockLoadedEnv, varlockVitePlugin } = await importPlugin(); const loadsBefore = mockState.loads; - for (const value of ['first', 'second', 'third']) { - mockState.publicValue = value; + mockState.publicValue = 'first'; + await resolveDevConfig(varlockVitePlugin({ rootDir: appRoot })); + for (const value of ['second', 'third']) { + editSchema(value); await resolveDevConfig(varlockVitePlugin({ rootDir: appRoot })); expect(getVarlockLoadedEnv()?.config.PUBLIC_VAR?.value).toBe(value); } expect(mockState.loads - loadsBefore).toBe(3); }); - it('does not reload again after an integration refreshed the env (nuxt restart)', async () => { + it('reloads on the first restart after a change with `rootDir` and a key already in the environment', async () => { + // with a key set, the dev-key lifecycle hook does not load `rootDir` first + vi.stubEnv('_VARLOCK_ENV_KEY', TEST_KEY); + const { getVarlockLoadedEnv, varlockVitePlugin } = await importPlugin(); + + mockState.publicValue = 'first'; + await resolveDevConfig(varlockVitePlugin({ rootDir: appRoot })); + expect(getVarlockLoadedEnv()?.config.PUBLIC_VAR?.value).toBe('first'); + + editSchema('second'); + await resolveDevConfig(varlockVitePlugin({ rootDir: appRoot })); + expect(getVarlockLoadedEnv()?.config.PUBLIC_VAR?.value).toBe('second'); + }); + + describe('ensureVarlockEnvFresh() (integration-driven reloads, e.g. nuxt)', () => { + it('does nothing when no env file changed (shutdown, config-only reload)', async () => { + const { ensureVarlockEnvFresh, getVarlockLoadedEnv, varlockVitePlugin } = await importPlugin(); + mockState.publicValue = 'first'; + await resolveDevConfig(varlockVitePlugin({ rootDir: appRoot })); + const loadsBefore = mockState.loads; + + mockState.publicValue = 'changed-remotely'; + ensureVarlockEnvFresh(appRoot); + // a config-only reload: the replacement instance resolves vite again, + // and must see the same snapshot its config was evaluated against + await resolveDevConfig(varlockVitePlugin({ rootDir: appRoot })); + expect(mockState.loads - loadsBefore).toBe(0); + expect(getVarlockLoadedEnv()?.config.PUBLIC_VAR?.value).toBe('first'); + }); + + it('reloads after a change, and the next vite resolve reuses that load', async () => { + const { ensureVarlockEnvFresh, getVarlockLoadedEnv, varlockVitePlugin } = await importPlugin(); + await resolveDevConfig(varlockVitePlugin({ rootDir: appRoot })); + const loadsBefore = mockState.loads; + + editSchema('refreshed'); + ensureVarlockEnvFresh(appRoot); + expect(getVarlockLoadedEnv()?.config.PUBLIC_VAR?.value).toBe('refreshed'); + await resolveDevConfig(varlockVitePlugin({ rootDir: appRoot })); + expect(mockState.loads - loadsBefore).toBe(1); + }); + + it('is idempotent: a second call for the same change does not reload', async () => { + const { ensureVarlockEnvFresh, varlockVitePlugin } = await importPlugin(); + await resolveDevConfig(varlockVitePlugin({ rootDir: appRoot })); + const loadsBefore = mockState.loads; + + editSchema('refreshed'); + // nuxt fires both `restart` and `close` for one hook-driven restart + ensureVarlockEnvFresh(appRoot); + ensureVarlockEnvFresh(appRoot); + expect(mockState.loads - loadsBefore).toBe(1); + }); + + it('reloads when asked for a different root than the loaded one', async () => { + const { ensureVarlockEnvFresh } = await importPlugin(); + const loadsBefore = mockState.loads; + ensureVarlockEnvFresh(appRoot); + expect(mockState.loads - loadsBefore).toBe(1); + }); + }); + + it('refreshVarlockEnv() still forces a reload, and the next resolve reuses it', async () => { const { getVarlockLoadedEnv, refreshVarlockEnv, varlockVitePlugin } = await importPlugin(); await resolveDevConfig(varlockVitePlugin({ rootDir: appRoot })); const loadsBefore = mockState.loads; @@ -373,6 +474,46 @@ describe('config hook - dev server restarts', () => { }); }); +describe('startup loads', () => { + it('adopts an env graph varlock/auto-load already resolved in this process', async () => { + // e.g. `import 'varlock/auto-load'` at the top of nuxt.config / vite.config + const autoLoadedGraph = { + basePath: process.cwd(), + sources: [], + settings: {}, + config: { PUBLIC_VAR: { value: 'from-auto-load', isSensitive: false, isDynamic: false } }, + }; + (globalThis as any).__varlockLoadedEnv = autoLoadedGraph; + const loadsBefore = mockState.loads; + const { getVarlockLoadedEnv } = await importPlugin(); + expect(mockState.loads - loadsBefore).toBe(0); + expect(getVarlockLoadedEnv()?.config.PUBLIC_VAR?.value).toBe('from-auto-load'); + }); + + it('does not adopt an auto-loaded graph from a different directory', async () => { + (globalThis as any).__varlockLoadedEnv = { + basePath: '/some/other/project', sources: [], settings: {}, config: {}, + }; + const loadsBefore = mockState.loads; + const { getVarlockLoadedEnv } = await importPlugin(); + expect(mockState.loads - loadsBefore).toBe(1); + expect(getVarlockLoadedEnv()?.config.PUBLIC_VAR?.value).toBe('public-value'); + }); + + it('resolves at import when nothing has been loaded yet', async () => { + const loadsBefore = mockState.loads; + await importPlugin(); + expect(mockState.loads - loadsBefore).toBe(1); + }); + + it('does not reload just because an integration passes its telemetry name', async () => { + const { varlockVitePlugin } = await importPlugin(); + const loadsBefore = mockState.loads; + varlockVitePlugin({ integrationTelemetry: { name: '@varlock/some-integration', version: '1.0.0' } }); + expect(mockState.loads - loadsBefore).toBe(0); + }); +}); + describe('transformIndexHtml + SvelteKit', () => { // SvelteKit warns about any resolved plugin with `transformIndexHtml` (#1177) async function resolvedHasHtmlHook(extraPlugins: Array) { diff --git a/packages/varlock-website/src/content/docs/integrations/vite.mdx b/packages/varlock-website/src/content/docs/integrations/vite.mdx index a008625f5..678f20d23 100644 --- a/packages/varlock-website/src/content/docs/integrations/vite.mdx +++ b/packages/varlock-website/src/content/docs/integrations/vite.mdx @@ -108,6 +108,10 @@ If you are using Vite's `envDir` option to load `.env` files from a custom direc --- +## Dev server restarts + +The plugin adds every env file varlock loads to Vite's watched config dependencies, so editing one (including `.env.schema`) restarts the dev server with freshly resolved env. A restart only re-resolves when an env file changed (or a new `.env*` file appeared next to one) or the previous load failed. Restarts for other reasons, such as editing `vite.config.*`, reuse the env already loaded, so plugin resolvers (and any auth prompts they trigger) do not run again. To pick up a value that changed outside your files, like a secret updated in a remote vault, stop and start the dev server. + ## SSR Code Injection Within SSR builds, this plugin will automatically inject varlock initialization code into your entry points. There are 3 modes to choose from and specify during plugin initialization. For example: From cf30168bfc45fbed3bf4628e6922d03b911ca687 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Mon, 2 Nov 2026 14:35:04 -0700 Subject: [PATCH 3/4] Move env file drift detection into varlock core checkEnvSourceDrift (varlock/env-source-fingerprint) re-hashes each recorded file source against the contentHash taken when it was parsed, and checks the new absentSourcePaths list: auto-load candidates and allowMissing imports a load looked for and did not find. Creating one of those now counts as drift, which closes the documented gap in injected-env reuse. The vite integration uses it for dev-restart freshness instead of its own mtime fingerprint, so an edit made during a slow load is still caught and an unrelated .env.example no longer triggers a reload. --- .bumpy/env-source-drift-created-files.md | 5 + .bumpy/vite-env-freshness.md | 2 +- packages/integrations/vite/src/index.ts | 63 ++------- .../vite/test/ssr-init-code.test.ts | 37 ++++- .../content/docs/integrations/javascript.mdx | 2 +- .../src/content/docs/integrations/vite.mdx | 2 +- packages/varlock/package.json | 6 + .../varlock/src/env-graph/lib/data-source.ts | 10 +- .../varlock/src/env-graph/lib/env-graph.ts | 21 +++ .../varlock/src/lib/env-source-fingerprint.ts | 72 +++++++++- .../varlock/src/lib/injected-env-reuse.ts | 44 +----- .../src/lib/test/env-source-drift.test.ts | 131 ++++++++++++++++++ .../src/lib/test/injected-env-reuse.test.ts | 10 ++ packages/varlock/tsdown.config.ts | 1 + 14 files changed, 306 insertions(+), 100 deletions(-) create mode 100644 .bumpy/env-source-drift-created-files.md create mode 100644 packages/varlock/src/lib/test/env-source-drift.test.ts diff --git a/.bumpy/env-source-drift-created-files.md b/.bumpy/env-source-drift-created-files.md new file mode 100644 index 000000000..3f2b75e5a --- /dev/null +++ b/.bumpy/env-source-drift-created-files.md @@ -0,0 +1,5 @@ +--- +varlock: patch +--- + +Reusing an injected env blob (auto-load under `varlock run`) now also re-resolves when an env file it would load, like `.env.local`, was created after the blob was made diff --git a/.bumpy/vite-env-freshness.md b/.bumpy/vite-env-freshness.md index 29a52fb64..488f5c698 100644 --- a/.bumpy/vite-env-freshness.md +++ b/.bumpy/vite-env-freshness.md @@ -2,4 +2,4 @@ "@varlock/vite-integration": patch --- -Dev server restarts only re-resolve env when an env file changed or the last load failed, and startup reuses env already resolved by varlock/auto-load, so plugin resolvers run once instead of several times +Dev server restarts only re-resolve env when an env file changed or was created, or the last load failed, and startup reuses env already resolved by varlock/auto-load, so plugin resolvers run once instead of several times diff --git a/packages/integrations/vite/src/index.ts b/packages/integrations/vite/src/index.ts index 8291c91a8..23ee785ed 100644 --- a/packages/integrations/vite/src/index.ts +++ b/packages/integrations/vite/src/index.ts @@ -9,6 +9,7 @@ import { initVarlockRuntime } from 'varlock/init'; import { createDebug, type SerializedEnvGraph } from 'varlock'; import { execSyncVarlock, VarlockExecError } from 'varlock/exec-sync-varlock'; import { encryptEnvBlobSync, generateEncryptionKeyHex } from 'varlock/encrypt-env'; +import { checkEnvSourceDrift } from 'varlock/env-source-fingerprint'; import { createReplacerTransformFn, SUPPORTED_FILES } from '@env-spec/utils/ast-replacer'; @@ -61,9 +62,6 @@ export let varlockLoadedEnv: SerializedEnvGraph; /** Stderr output from the last failed varlock load (ANSI-colored) */ export let varlockLastError: string | undefined; let lastErrorAt = 0; -// stat fingerprint of the env files the current graph could have read, taken -// when it was loaded - undefined means "unknown", which always counts as stale -let loadedEnvFingerprint: string | undefined; // one-time guard for the SvelteKit+Cloudflare auto-detection notice let cfDetectNoticeLogged = false; // one-time guard for the Vercel unencrypted resolved-env warning @@ -135,54 +133,20 @@ let activeIntegrationTelemetry = { */ let loadedConfigDir: string | undefined; -/** - * Cheap change marker for the env files a load could read: every loaded source - * plus any `.env*` file next to one, so a newly created `.env.local` also - * counts as a change. Stat only - never re-resolves. Non-regular files (FIFOs) - * are skipped, since their stat churns on every read. - */ -function fingerprintEnvFiles(graph: SerializedEnvGraph | undefined) { - const filePaths = new Set(); - const dirs = new Set(); - if (graph?.basePath) { - dirs.add(path.resolve(graph.basePath)); - for (const source of graph.sources || []) { - if (!source.enabled || !source.path) continue; - const absPath = path.resolve(graph.basePath, source.path); - filePaths.add(absPath); - dirs.add(path.dirname(absPath)); - } - } - for (const dir of dirs) { - try { - for (const name of fs.readdirSync(dir)) { - if (name.startsWith('.env')) filePaths.add(path.join(dir, name)); - } - } catch { - // missing/unreadable dir - its files show up as missing below - } - } - let newestMtimeMs = 0; - const fingerprint = [...filePaths].sort().map((filePath) => { - try { - const stat = fs.statSync(filePath); - if (!stat.isFile()) return `${filePath}:non-regular`; - newestMtimeMs = Math.max(newestMtimeMs, stat.mtimeMs); - return `${filePath}:${stat.size}:${stat.mtimeMs}`; - } catch { - return `${filePath}:missing`; - } - }).join('\n'); - return { fingerprint, newestMtimeMs }; -} - /** * Whether the loaded graph is out of date for `dir`: a different directory, a * failed last load (a retry may succeed), or an env file that changed since. */ function isLoadedEnvStale(dir: string) { - if (dir !== loadedConfigDir || !configIsValid || loadedEnvFingerprint === undefined) return true; - return fingerprintEnvFiles(varlockLoadedEnv).fingerprint !== loadedEnvFingerprint; + if (dir !== loadedConfigDir || !configIsValid) return true; + // the graph records a hash of what each file source contained when it was + // parsed, so an edit made while a load was running still counts. FIFO sources + // (e.g. 1Password Environments) are served remotely and can't be verified + // without a side-effecting read - like any other remote value, they only + // refresh on a fresh dev server start + const drift = checkEnvSourceDrift(varlockLoadedEnv, { nonRegularFiles: 'assume-unchanged' }); + if (drift.drifted) debug('loaded env is stale:', drift.reason); + return drift.drifted; } /** shared tail of every successful load (or adopted graph) */ @@ -218,7 +182,6 @@ function reloadConfig(cwd?: string) { debug('loading config - count =', ++loadCount, cwd ? `(cwd: ${cwd})` : ''); const prevItemCount = Object.keys(varlockLoadedEnv?.config || {}).length; loadedConfigDir = path.resolve(cwd ?? process.cwd()); - const loadStartedAt = Date.now(); try { const { stdout } = execSyncVarlock('load --format json-full --compact', { fullResult: true, @@ -267,11 +230,6 @@ function reloadConfig(cwd?: string) { return; } - // a file edited while the load was running may not be reflected in it, so - // leave the fingerprint unknown and let the next freshness check reload - const { fingerprint, newestMtimeMs } = fingerprintEnvFiles(varlockLoadedEnv); - loadedEnvFingerprint = Math.floor(newestMtimeMs) > loadStartedAt ? undefined : fingerprint; - applyLoadedEnv(cwd, prevItemCount); } @@ -289,7 +247,6 @@ function adoptAutoLoadedEnv(): boolean { varlockLoadedEnv = graph; loadedConfigDir = path.resolve(process.cwd()); configIsValid = true; - loadedEnvFingerprint = fingerprintEnvFiles(graph).fingerprint; // auto-load already set (and possibly encrypted) process.env.__VARLOCK_ENV applyLoadedEnv(undefined, 0); return true; diff --git a/packages/integrations/vite/test/ssr-init-code.test.ts b/packages/integrations/vite/test/ssr-init-code.test.ts index 8707e9edc..d7f21277f 100644 --- a/packages/integrations/vite/test/ssr-init-code.test.ts +++ b/packages/integrations/vite/test/ssr-init-code.test.ts @@ -1,11 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi, } from 'vitest'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { + mkdtempSync, readFileSync, rmSync, writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { resolveConfig } from 'vite'; import { decryptEnvBlobSync } from 'varlock/encrypt-env'; +import { hashEnvSourceContents } from 'varlock/env-source-fingerprint'; // mutable so individual tests can flip the decorator before importing the plugin const mockState = vi.hoisted(() => ({ @@ -14,7 +17,10 @@ const mockState = vi.hoisted(() => ({ loads: 0, basePath: '/fake-project', sources: [] as Array<{ enabled: boolean, path: string }>, + absentSourcePaths: undefined as Array | undefined, failNextLoad: false, + // set below from the real helper - mocks are hoisted above imports + hashFile: undefined as undefined | ((absPath: string) => string | undefined), })); vi.mock('varlock/exec-sync-varlock', () => { @@ -27,7 +33,12 @@ vi.mock('varlock/exec-sync-varlock', () => { mockState.loads++; const stdout = JSON.stringify({ basePath: mockState.basePath, - sources: mockState.sources, + // like a real load: fingerprint what each file source contained at load time + sources: mockState.sources.map((source) => ({ + ...source, + contentHash: mockState.hashFile?.(`${mockState.basePath}/${source.path}`), + })), + absentSourcePaths: mockState.absentSourcePaths, settings: { encryptInjectedEnv: mockState.encryptInjectedEnv }, config: { SECRET_KEY: { value: 'super-secret-value', isSensitive: true, isDynamic: true }, @@ -52,6 +63,14 @@ vi.mock('@varlock/cloudflare-integration/ssr-entry-code', () => ({ CLOUDFLARE_SSR_ENTRY_CODE: '/* cloudflare edge env loader */', })); +mockState.hashFile = (absPath) => { + try { + return hashEnvSourceContents(readFileSync(absPath, 'utf8')); + } catch { + return undefined; + } +}; + // warm the module graph (plugin + varlock source) at collection time, so the // transform cost is not charged against the first test's timeout await import('../src/index'); @@ -80,6 +99,7 @@ beforeEach(() => { mockState.publicValue = 'public-value'; mockState.basePath = '/fake-project'; mockState.sources = []; + mockState.absentSourcePaths = undefined; mockState.failNextLoad = false; delete (globalThis as any).__varlockLoadedEnv; // the plugin writes process.env directly, which vi.unstubAllEnvs() does not undo @@ -298,6 +318,7 @@ describe('env freshness across dev restarts', () => { writeFileSync(path.join(appRoot, '.env.schema'), 'PUBLIC_VAR=first\n'); mockState.basePath = appRoot; mockState.sources = [{ enabled: true, path: '.env.schema' }]; + mockState.absentSourcePaths = ['.env', '.env.local']; return () => rmSync(appRoot, { recursive: true, force: true }); }); @@ -347,7 +368,7 @@ describe('env freshness across dev restarts', () => { expect(mockState.loads - loadsBefore).toBe(2); }); - it('reloads on a restart after a new .env file appears next to a loaded source', async () => { + it('reloads on a restart after an env file a load would pick up is created', async () => { const { getVarlockLoadedEnv, varlockVitePlugin } = await importPlugin(); await resolveDevConfig(varlockVitePlugin(), appRoot); const loadsBefore = mockState.loads; @@ -359,6 +380,16 @@ describe('env freshness across dev restarts', () => { expect(getVarlockLoadedEnv()?.config.PUBLIC_VAR?.value).toBe('from-local'); }); + it('does not reload when an env-like file varlock never loads appears', async () => { + const { varlockVitePlugin } = await importPlugin(); + await resolveDevConfig(varlockVitePlugin(), appRoot); + const loadsBefore = mockState.loads; + + writeFileSync(path.join(appRoot, '.env.example'), 'PUBLIC_VAR=example\n'); + await resolveDevConfig(varlockVitePlugin(), appRoot); + expect(mockState.loads - loadsBefore).toBe(0); + }); + it('retries on a restart when the previous load failed, even with no file change', async () => { vi.spyOn(console, 'error').mockImplementation(() => undefined); const { getVarlockLoadedEnv, varlockVitePlugin } = await importPlugin(); diff --git a/packages/varlock-website/src/content/docs/integrations/javascript.mdx b/packages/varlock-website/src/content/docs/integrations/javascript.mdx index 9c5a0393a..573aa3e2c 100644 --- a/packages/varlock-website/src/content/docs/integrations/javascript.mdx +++ b/packages/varlock-website/src/content/docs/integrations/javascript.mdx @@ -82,7 +82,7 @@ Reuse only happens when a fresh resolution would produce the same result: - the blob was resolved in the same directory the app would resolve in (a root-level `varlock run` in a monorepo does not stop per-package resolution) - it resolved without errors -- the `.env` files it was resolved from are unchanged on disk (editing an env file and restarting the app inside the same `varlock run` re-resolves and picks up the edit) +- the `.env` files it was resolved from are unchanged on disk, and no file it looked for and did not find (like `.env.local`) has been created since (editing or adding an env file and restarting the app inside the same `varlock run` re-resolves and picks up the change) - no env override recorded in the blob has changed since (`varlock run -- sh -c 'FOO=x node app.js'` re-resolves, so the new `FOO` wins) If any check fails, auto-load falls back to the CLI. Control it explicitly with [`_VARLOCK_USE_INJECTED_ENV`](/reference/reserved-variables/#_varlock_use_injected_env): `0` always re-resolves, `1` always trusts the blob, skipping the directory and drift checks. A blob that resolved with errors is refused in every mode: trust is about where the blob came from, not whether it worked. `varlock run` applies the same rules when it finds a blob, which covers non-Node workloads. Trust mode is how you hand an env into an environment with no `.env` files at all, like a remote sandbox (see the [E2B](/sandboxes/e2b/#passing-resolved-values) and [Fly.io](/sandboxes/flyio/#passing-resolved-values) guides) or a deploy carrying a [frozen payload](/guides/frozen-env/#carrying-the-payload-in-an-env-var-instead) in one variable. diff --git a/packages/varlock-website/src/content/docs/integrations/vite.mdx b/packages/varlock-website/src/content/docs/integrations/vite.mdx index 678f20d23..37a7f1a2a 100644 --- a/packages/varlock-website/src/content/docs/integrations/vite.mdx +++ b/packages/varlock-website/src/content/docs/integrations/vite.mdx @@ -110,7 +110,7 @@ If you are using Vite's `envDir` option to load `.env` files from a custom direc ## Dev server restarts -The plugin adds every env file varlock loads to Vite's watched config dependencies, so editing one (including `.env.schema`) restarts the dev server with freshly resolved env. A restart only re-resolves when an env file changed (or a new `.env*` file appeared next to one) or the previous load failed. Restarts for other reasons, such as editing `vite.config.*`, reuse the env already loaded, so plugin resolvers (and any auth prompts they trigger) do not run again. To pick up a value that changed outside your files, like a secret updated in a remote vault, stop and start the dev server. +The plugin adds every env file varlock loads to Vite's watched config dependencies, so editing one (including `.env.schema`) restarts the dev server with freshly resolved env. A restart only re-resolves when an env file changed, a file varlock would load (like `.env.local`) was created, or the previous load failed. Restarts for other reasons, such as editing `vite.config.*`, reuse the env already loaded, so plugin resolvers (and any auth prompts they trigger) do not run again. To pick up a value that changed outside your files, like a secret updated in a remote vault, stop and start the dev server. ## SSR Code Injection Within SSR builds, this plugin will automatically inject varlock initialization code into your entry points. diff --git a/packages/varlock/package.json b/packages/varlock/package.json index ca16aab64..b80d27073 100644 --- a/packages/varlock/package.json +++ b/packages/varlock/package.json @@ -188,6 +188,12 @@ }, "default": "./dist/lib/exec-sync-varlock.mjs" }, + "./env-source-fingerprint": { + "ts-src": "./src/lib/env-source-fingerprint.ts", + "types": "./dist/lib/env-source-fingerprint.d.mts", + "import": "./dist/lib/env-source-fingerprint.mjs", + "default": "./dist/lib/env-source-fingerprint.mjs" + }, "./config": { "ts-src": "./src/config.ts", "types": "./dist/dotenv-compat.d.mts", diff --git a/packages/varlock/src/env-graph/lib/data-source.ts b/packages/varlock/src/env-graph/lib/data-source.ts index 968ea1931..f5905049a 100644 --- a/packages/varlock/src/env-graph/lib/data-source.ts +++ b/packages/varlock/src/env-graph/lib/data-source.ts @@ -653,7 +653,10 @@ export abstract class EnvGraphDataSource { // TODO: work through possible error types here }); - if (!fsStat && allowMissing) continue; + if (!fsStat && allowMissing) { + this.graph.recordAbsentSourcePath(fullImportPath); + continue; + } if (!fsStat) { this._errors.push(new LoadingError(`Import path does not exist: ${fullImportPath}`)); return; @@ -1082,7 +1085,10 @@ export class DirectoryDataSource extends EnvGraphDataSource { return; } - if (!await pathExists(filePath)) return; + if (!await pathExists(filePath)) { + this.graph.recordAbsentSourcePath(filePath); + return; + } const source = new DotEnvFileDataSource(filePath); await this._addChildWithoutImports(source); return source; diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index a0354d210..e30e23f2c 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -115,6 +115,12 @@ export type SerializedEnvGraph = { */ contentHash?: string; }>, + /** + * Paths (relative to `basePath`) the resolution looked for and did not find, e.g. + * `.env.local` or an `allowMissing` import. If one exists later, a fresh load would read + * it, so the graph is stale (see `checkEnvSourceDrift`). Absent on older producers. + */ + absentSourcePaths?: Array, settings: { /** * `@redact`, as written: `console` is console method redaction; `stdout` is stdout/stderr @@ -323,6 +329,16 @@ export class EnvGraph { return result; } + /** + * Absolute paths this load checked for and did not find: auto-load candidates + * (`.env.local`, `.env.`, ...) and `allowMissing` imports. Serialized so a + * consumer can tell when a file a fresh load would pick up has been created. + */ + absentSourcePaths = new Set(); + recordAbsentSourcePath(fullPath: string) { + this.absentSourcePaths.add(fullPath); + } + /** virtual imports for testing */ virtualImports?: Record; setVirtualImports(basePath: string, files: Record) { @@ -1236,6 +1252,11 @@ export class EnvGraph { ? { contentHash: hashEnvSourceContents(source.rawContents) } : {}, }); } + if (this.absentSourcePaths.size) { + serializedGraph.absentSourcePaths = [...this.absentSourcePaths] + .map((fullPath) => path.relative(this.basePath ?? '', fullPath)) + .sort(); + } for (const itemKey of this.sortedConfigKeys) { // _VARLOCK_* keys configure varlock's own behavior and must never land in the blob: // e.g. _VARLOCK_ENV_KEY encrypts the blob itself (the runtime already has it via diff --git a/packages/varlock/src/lib/env-source-fingerprint.ts b/packages/varlock/src/lib/env-source-fingerprint.ts index 1456664cb..a641cf4aa 100644 --- a/packages/varlock/src/lib/env-source-fingerprint.ts +++ b/packages/varlock/src/lib/env-source-fingerprint.ts @@ -1,9 +1,12 @@ import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import type { SerializedEnvGraph } from '../env-graph/lib/env-graph'; /** * Content fingerprint for a file-based env source, recorded into the serialized graph - * (`SerializedEnvGraph.sources[].contentHash`) at resolution time and re-checked by the - * automatic injected-env reuse path to detect source file edits since the blob was made. + * (`SerializedEnvGraph.sources[].contentHash`) at resolution time and re-checked by + * {@link checkEnvSourceDrift} to detect source file edits since the graph was made. * * Truncated sha256 - this is drift detection, not an integrity/security boundary, and the * blob travels in an env var so we keep it short. @@ -11,3 +14,68 @@ import { createHash } from 'node:crypto'; export function hashEnvSourceContents(contents: string): string { return createHash('sha256').update(contents, 'utf8').digest('hex').slice(0, 16); } + +export type EnvSourceDrift = { drifted: false } | { drifted: true, reason: string }; + +export interface CheckEnvSourceDriftOptions { + /** + * How to treat a source that is not a regular file (e.g. 1Password Environments serves + * `.env` as a FIFO). Its contents can't be verified without reading it, and reading has + * side effects (the serving process rewrites it) or blocks forever on a writerless pipe. + * - `drift` (default): report drift, so the caller re-resolves (which reads it normally) + * - `assume-unchanged`: skip it, for callers that only care about local file edits + */ + nonRegularFiles?: 'drift' | 'assume-unchanged'; +} + +/** + * Whether a fresh load would differ from `graph` because of env files on disk: a recorded + * source was edited, removed, or replaced, or a file the load looked for and did not find + * (an auto-load candidate like `.env.local`, an `allowMissing` import) now exists. + * + * Reads and hashes each recorded file source - no parsing or resolution - so it is cheap + * enough to run before deciding whether to re-resolve. Disabled sources are verified too: + * `@disable` lives in the source's own content, so an edit can re-enable it (an edit that + * leaves it disabled just costs one harmless re-resolution). + * + * Values that change without a file edit (a resolver reading a remote vault, `exec()`) + * are out of scope: only a re-resolution sees those. + */ +export function checkEnvSourceDrift( + graph: Pick, + opts?: CheckEnvSourceDriftOptions, +): EnvSourceDrift { + if (!Array.isArray(graph.sources)) { + return { drifted: true, reason: 'no sources recorded' }; + } + const basePath = graph.basePath ?? ''; + for (const source of graph.sources) { + if (source.path === undefined) continue; + // older producers didn't record fingerprints - we can't verify, so treat as drift + if (!source.contentHash) { + return { drifted: true, reason: `no content fingerprint for source ${source.path}` }; + } + const sourceFullPath = path.resolve(basePath, source.path); + let currentContents: string; + try { + // stat-gate before reading (see `nonRegularFiles`) + if (!fs.statSync(sourceFullPath).isFile()) { + if (opts?.nonRegularFiles === 'assume-unchanged') continue; + return { drifted: true, reason: `source ${source.path} is not a regular file` }; + } + currentContents = fs.readFileSync(sourceFullPath, 'utf8'); + } catch { + return { drifted: true, reason: `source file ${source.path} is missing or unreadable` }; + } + if (hashEnvSourceContents(currentContents) !== source.contentHash) { + return { drifted: true, reason: `source file ${source.path} changed since it was loaded` }; + } + } + // older producers didn't record these - only the recorded sources can be checked + for (const absentPath of graph.absentSourcePaths ?? []) { + if (fs.existsSync(path.resolve(basePath, absentPath))) { + return { drifted: true, reason: `env file ${absentPath} was created since the env was loaded` }; + } + } + return { drifted: false }; +} diff --git a/packages/varlock/src/lib/injected-env-reuse.ts b/packages/varlock/src/lib/injected-env-reuse.ts index 818ec6992..f5c7c0309 100644 --- a/packages/varlock/src/lib/injected-env-reuse.ts +++ b/packages/varlock/src/lib/injected-env-reuse.ts @@ -4,7 +4,7 @@ import type { SerializedEnvGraph } from '../env-graph'; import { isEncryptedBlob, decryptEnvBlobSync } from '../runtime/crypto'; import { readVarlockPackageJsonConfig } from './package-json-config'; import { envValueMatchesBlobItem } from './injected-env-provenance'; -import { hashEnvSourceContents } from './env-source-fingerprint'; +import { checkEnvSourceDrift } from './env-source-fingerprint'; import { applyFrozenBootKeys, getBootItems } from './frozen-boot-keys'; import { FrozenEnvFileError, PreResolvedEnvError, readFrozenEnvFile, getFrozenEnvFilePath, @@ -357,42 +357,12 @@ export function evaluateInjectedEnvReuse(opts: { }; } - // Source drift: the producer fingerprints the contents of each file source it actually - // parsed (see getSerializedGraph). If a source has been edited or removed since the blob - // was made (e.g. an env file edit followed by a dev-server restart inside the same - // `varlock run`), reuse could serve pre-edit values - re-resolve instead. Disabled - // sources are verified too: `@disable` lives in the source's own content, so an edit can - // re-enable it (an edit that leaves it disabled just costs one harmless re-resolution). - // Known gap: a matching env file *created* after the blob won't appear in its sources - // list and isn't detected here. - if (!Array.isArray(parsedEnv.sources)) { - return { reuse: false, reason: 'blob has no sources recorded' }; - } - for (const source of parsedEnv.sources) { - if (source.path === undefined) continue; - // older producers didn't record fingerprints - we can't verify, so re-resolve - if (!source.contentHash) { - return { reuse: false, reason: `blob has no content fingerprint for source ${source.path}` }; - } - const sourceFullPath = path.resolve(parsedEnv.basePath, source.path); - let currentContents: string; - try { - // stat-gate before reading - env sources can legitimately be FIFOs (e.g. 1Password - // Environments serves .env files as pipes), and reading one here would have side - // effects (the serving process rewrites it) or block forever on a writerless pipe. - // A non-regular file's content can't be verified without reading it, so fall back - // to a fresh resolution, which reads it once the same way any normal load does. - if (!fs.statSync(sourceFullPath).isFile()) { - return { reuse: false, reason: `source ${source.path} is not a regular file` }; - } - currentContents = fs.readFileSync(sourceFullPath, 'utf8'); - } catch { - return { reuse: false, reason: `source file ${source.path} is missing or unreadable` }; - } - if (hashEnvSourceContents(currentContents) !== source.contentHash) { - return { reuse: false, reason: `source file ${source.path} changed since the blob was created` }; - } - } + // Source drift: if an env file was edited, removed, or created since the blob was made + // (e.g. an env file edit followed by a dev-server restart inside the same `varlock run`), + // reuse could serve pre-edit values - re-resolve instead. Non-regular sources (FIFOs) count + // as drift: a fresh resolution reads them once the same way any normal load does. + const sourceDrift = checkEnvSourceDrift(parsedEnv); + if (sourceDrift.drifted) return { reuse: false, reason: `blob is stale: ${sourceDrift.reason}` }; // Env drift: if ANY blob config key's ambient value differs from what the parent // injected (e.g. `varlock run -- sh -c 'FOO=x node app.js'`, whether or not FOO was diff --git a/packages/varlock/src/lib/test/env-source-drift.test.ts b/packages/varlock/src/lib/test/env-source-drift.test.ts new file mode 100644 index 000000000..d5a9c4805 --- /dev/null +++ b/packages/varlock/src/lib/test/env-source-drift.test.ts @@ -0,0 +1,131 @@ +import { + describe, test, expect, beforeEach, afterEach, +} from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { EnvGraph } from '../../env-graph'; +import { DirectoryDataSource } from '../../env-graph/lib/data-source'; +import { checkEnvSourceDrift } from '../env-source-fingerprint'; + +let tempDir: string; + +beforeEach(() => { + // realpath so paths aren't confused by symlinked tmp dirs (e.g. /tmp on macOS) + tempDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'varlock-source-drift-'))); +}); + +afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +function writeFile(relPath: string, contents: string) { + fs.mkdirSync(path.dirname(path.join(tempDir, relPath)), { recursive: true }); + fs.writeFileSync(path.join(tempDir, relPath), contents); +} + +async function loadSerializedGraph() { + const g = new EnvGraph(); + g.basePath = tempDir; + await g.setRootDataSource(new DirectoryDataSource(tempDir)); + await g.finishLoad(); + await g.resolveEnvValues(); + return g.getSerializedGraph(); +} + +describe('getSerializedGraph absentSourcePaths', () => { + test('records auto-load candidates that did not exist', async () => { + writeFile('.env.schema', 'FOO=bar\n'); + const graph = await loadSerializedGraph(); + expect(graph.absentSourcePaths).toEqual(expect.arrayContaining(['.env', '.env.local'])); + expect(graph.absentSourcePaths).not.toContain('.env.schema'); + }); + + test('records env-specific candidates for the resolved current env', async () => { + writeFile('.env.schema', '# @currentEnv=$APP_ENV\n# ---\nAPP_ENV=staging\n'); + writeFile('.env.staging', 'FOO=bar\n'); + const graph = await loadSerializedGraph(); + expect(graph.absentSourcePaths).toContain('.env.staging.local'); + expect(graph.absentSourcePaths).not.toContain('.env.staging'); + }); + + test('records missing allowMissing imports', async () => { + writeFile('.env.schema', '# @import(./shared/.env.shared, allowMissing=true)\n# ---\nFOO=bar\n'); + const graph = await loadSerializedGraph(); + expect(graph.absentSourcePaths).toContain(path.join('shared', '.env.shared')); + }); +}); + +describe('checkEnvSourceDrift', () => { + test('no drift when nothing changed', async () => { + writeFile('.env.schema', 'FOO=bar\n'); + const graph = await loadSerializedGraph(); + expect(checkEnvSourceDrift(graph)).toEqual({ drifted: false }); + }); + + test('drift when a loaded source was edited', async () => { + writeFile('.env.schema', 'FOO=bar\n'); + const graph = await loadSerializedGraph(); + writeFile('.env.schema', 'FOO=edited\n'); + expect(checkEnvSourceDrift(graph)).toMatchObject({ drifted: true, reason: expect.stringContaining('changed since') }); + }); + + test('drift when a loaded source was removed', async () => { + writeFile('.env.schema', 'FOO=bar\n'); + writeFile('.env', 'FOO=baz\n'); + const graph = await loadSerializedGraph(); + fs.unlinkSync(path.join(tempDir, '.env')); + expect(checkEnvSourceDrift(graph)).toMatchObject({ drifted: true, reason: expect.stringContaining('missing') }); + }); + + test('drift when a file a load would pick up was created', async () => { + writeFile('.env.schema', 'FOO=bar\n'); + const graph = await loadSerializedGraph(); + writeFile('.env.local', 'FOO=local\n'); + expect(checkEnvSourceDrift(graph)).toMatchObject({ drifted: true, reason: expect.stringContaining('created') }); + }); + + test('no drift when an unrelated .env-like file appears', async () => { + writeFile('.env.schema', 'FOO=bar\n'); + const graph = await loadSerializedGraph(); + writeFile('.env.example', 'FOO=example\n'); + expect(checkEnvSourceDrift(graph)).toEqual({ drifted: false }); + }); + + test('a graph from an older producer without absentSourcePaths only checks recorded sources', async () => { + writeFile('.env.schema', 'FOO=bar\n'); + const graph = await loadSerializedGraph(); + delete graph.absentSourcePaths; + writeFile('.env.local', 'FOO=local\n'); + expect(checkEnvSourceDrift(graph)).toEqual({ drifted: false }); + }); + + test('a source without a fingerprint counts as drift', async () => { + writeFile('.env.schema', 'FOO=bar\n'); + const graph = await loadSerializedGraph(); + delete graph.sources.find((s) => s.path === '.env.schema')!.contentHash; + expect(checkEnvSourceDrift(graph)).toMatchObject({ drifted: true, reason: expect.stringContaining('fingerprint') }); + }); + + describe('non-regular sources (e.g. 1Password Environments FIFOs)', () => { + async function graphWithNonRegularSource() { + writeFile('.env.schema', 'FOO=bar\n'); + const graph = await loadSerializedGraph(); + // swap the file for a directory: same "not a regular file" stat result as a FIFO, + // without the risk of blocking on an open + fs.unlinkSync(path.join(tempDir, '.env.schema')); + fs.mkdirSync(path.join(tempDir, '.env.schema')); + return graph; + } + + test('count as drift by default (contents cannot be verified without reading)', async () => { + const graph = await graphWithNonRegularSource(); + expect(checkEnvSourceDrift(graph)).toMatchObject({ drifted: true, reason: expect.stringContaining('regular file') }); + }); + + test('can be treated as unchanged by callers that only care about local edits', async () => { + const graph = await graphWithNonRegularSource(); + expect(checkEnvSourceDrift(graph, { nonRegularFiles: 'assume-unchanged' })).toEqual({ drifted: false }); + }); + }); +}); diff --git a/packages/varlock/src/lib/test/injected-env-reuse.test.ts b/packages/varlock/src/lib/test/injected-env-reuse.test.ts index 1c531f968..1c515bd85 100644 --- a/packages/varlock/src/lib/test/injected-env-reuse.test.ts +++ b/packages/varlock/src/lib/test/injected-env-reuse.test.ts @@ -230,6 +230,16 @@ describe('evaluateInjectedEnvReuse', () => { expect(decision).toMatchObject({ reuse: false, reason: expect.stringContaining('fingerprint') }); }); + test('does not reuse when an env file a load would pick up was created after the blob', () => { + const source = writeSourceFile('.env', 'FOO=foo-val\n'); + fs.writeFileSync(path.join(tempDir, '.env.local'), 'FOO=local-val\n'); + const decision = evaluateInjectedEnvReuse({ + env: { __VARLOCK_ENV: makeBlob({ sources: [source], absentSourcePaths: ['.env.local'] }) }, + cwd: tempDir, + }); + expect(decision).toMatchObject({ reuse: false, reason: expect.stringContaining('created') }); + }); + test('an unchanged disabled source does not block reuse', () => { const source = writeSourceFile('.env.production', '# @disable\nFOO=prod-val\n'); source.enabled = false; diff --git a/packages/varlock/tsdown.config.ts b/packages/varlock/tsdown.config.ts index 43325f130..a07597e20 100644 --- a/packages/varlock/tsdown.config.ts +++ b/packages/varlock/tsdown.config.ts @@ -39,6 +39,7 @@ export default defineConfig([ 'src/cli/cli-executable.ts', // cli that gets run via `dmno` command 'src/lib/exec-sync-varlock.ts', // helper to call varlock cli from code + 'src/lib/env-source-fingerprint.ts', // env file drift checks for integrations 'src/plugin-lib.ts', ], From 6bf4bea51a3792e8b55027e1148c117a2dbc76a8 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Tue, 3 Nov 2026 13:43:48 -0700 Subject: [PATCH 4/4] Clarify Nuxt dev restart env docs --- .../varlock-website/src/content/docs/integrations/nuxt.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/varlock-website/src/content/docs/integrations/nuxt.mdx b/packages/varlock-website/src/content/docs/integrations/nuxt.mdx index a642764a6..533a12549 100644 --- a/packages/varlock-website/src/content/docs/integrations/nuxt.mdx +++ b/packages/varlock-website/src/content/docs/integrations/nuxt.mdx @@ -109,7 +109,7 @@ export default defineNuxtConfig({ }) ``` -In dev, the module re-resolves your env at the start of every restart it triggers, so config-time values stay in sync when env files change (see [Dev server behavior](#dev-server-behavior)). +In dev, the module re-resolves your env at the start of a restart whenever an env file changed, so config-time values stay in sync with your env files (see [Dev server behavior](#dev-server-behavior)). :::note[TypeScript config] The module registers your generated types file with Nuxt's TypeScript setup automatically, covering app code, server routes, and `nuxt.config.ts`. (Nuxt 4 splits its generated tsconfigs per context, and none of them include a root-level `env.d.ts` on their own.) It looks for `shared/env.d.ts`, then `env.d.ts`, relative to the project root. If you generate types to a custom path, point the module at it: @@ -165,7 +165,7 @@ export default defineNuxtConfig({ ## Dev server behavior -The module watches every env file varlock loads, including `.env.schema`, and restarts the dev server when one changes. This keeps everything in sync: inlined page values, server routes, the Nitro side, and values captured in `nuxt.config.ts` itself (the module re-resolves env at the start of a dev-server reload, before the config re-evaluates, whenever an env file changed). Reloads where no env file changed, such as editing only `nuxt.config.ts`, and stopping the dev server reuse the env already loaded, so plugin resolvers (and any auth prompts they trigger) do not run again. Nuxt's own watcher only reacts to a plain `.env` file. +The module watches every env file varlock loads, including `.env.schema`, and restarts the dev server when one changes. This keeps everything in sync: inlined page values, server routes, the Nitro side, and values captured in `nuxt.config.ts` itself (the module re-resolves env at the start of a dev-server reload, before the config re-evaluates, whenever an env file changed). Reloads where no env file changed, such as editing only `nuxt.config.ts`, and stopping the dev server reuse the env already loaded, so plugin resolvers (and any auth prompts they trigger) do not run again. To pick up a value that changed outside your files, like a secret updated in a remote vault, stop and start the dev server. Nuxt's own watcher only reacts to a plain `.env` file. If your config becomes invalid (a failed validation, a syntax error in a schema file), requests show an error page describing the problem, and the server recovers automatically once you fix and save the file.