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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .bumpy/env-source-drift-created-files.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .bumpy/nuxt-skip-close-refresh.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .bumpy/vite-env-freshness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@varlock/vite-integration": patch
---

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
30 changes: 15 additions & 15 deletions packages/integrations/nuxt/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
} 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';

Expand Down Expand Up @@ -76,28 +76,28 @@ const varlockNuxtModule: NuxtModule<VarlockNuxtModuleOptions> = defineNuxtModule
for (const envFilePath of getVarlockEnvSourcePaths(nuxt.options.rootDir)) {
if (!nuxt.options.watch.includes(envFilePath)) nuxt.options.watch.push(envFilePath);
}
// 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.
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;
refreshVarlockEnv(nuxt.options.rootDir);
ensureVarlockEnvFresh(nuxt.options.rootDir);
});
}

Expand Down
38 changes: 15 additions & 23 deletions packages/integrations/nuxt/test/module.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const varlockVitePluginMock = vi.fn(() => ({ name: 'varlock-vite-plugin' }));
const buildVarlockSsrInitCodeMock = vi.fn(() => 'initVarlockEnv();');
const getVarlockEnvSourcePathsMock = vi.fn((): Array<string> => []);
const refreshVarlockEnvMock = vi.fn();
const ensureVarlockEnvFreshMock = vi.fn();
const ensureDevEncryptionKeyMock = vi.fn();
type FakeLoadedEnv = {
basePath: string,
Expand Down Expand Up @@ -46,6 +47,7 @@ vi.mock('@nuxt/kit', () => ({
vi.mock('@varlock/vite-integration', () => ({
buildVarlockSsrInitCode: buildVarlockSsrInitCodeMock,
ensureDevEncryptionKey: ensureDevEncryptionKeyMock,
ensureVarlockEnvFresh: ensureVarlockEnvFreshMock,
getVarlockEnvSourcePaths: getVarlockEnvSourcePathsMock,
getVarlockLoadedEnv: getVarlockLoadedEnvMock,
refreshVarlockEnv: refreshVarlockEnvMock,
Expand Down Expand Up @@ -149,35 +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 (nuxt.config edits), 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();

// `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');
}
// never a forced reload - that would re-run resolvers on every quit
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);
});

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

it('does not watch or hook restarts outside dev', async () => {
Expand Down
131 changes: 89 additions & 42 deletions packages/integrations/vite/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -61,8 +62,6 @@ 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;
// one-time guard for the SvelteKit+Cloudflare auto-detection notice
let cfDetectNoticeLogged = false;
// one-time guard for the Vercel unencrypted resolved-env warning
Expand Down Expand Up @@ -134,11 +133,55 @@ let activeIntegrationTelemetry = {
*/
let loadedConfigDir: string | undefined;

/**
* 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) 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) */
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;
try {
const { stdout } = execSyncVarlock('load --format json-full --compact', {
fullResult: true,
Expand Down Expand Up @@ -187,35 +230,30 @@ 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()`.',
);
}

// 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;
// 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;

Expand Down Expand Up @@ -296,6 +334,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
Expand Down Expand Up @@ -526,14 +579,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
Expand Down Expand Up @@ -702,16 +750,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

Expand Down
Loading
Loading