Skip to content
Closed
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/nextjs-disable-watch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@varlock/nextjs-integration": minor
---

Add disableWatch option to varlockNextConfigPlugin to skip extra env-file watching (useful when sources are FIFOs).
36 changes: 35 additions & 1 deletion packages/integrations/nextjs/src/next-env-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,25 @@ const pendingReloadFiles = new Set<string>();
// ownership via this env var — child processes inherit it at spawn and skip,
// so we don't end up with multiple processes touching the trigger file.
const WATCHER_OWNER_ENV_KEY = '__VARLOCK_NEXT_ENV_WATCHER_PID';
const DISABLE_WATCH_ENV_KEY = '__VARLOCK_NEXT_DISABLE_WATCH';

function isExtraFileWatchDisabled(): boolean {
return !!process.env[DISABLE_WATCH_ENV_KEY];
}

function unwatchAllExtraFiles() {
if (!watchedExtraFiles.size) {
debug('disableWatch: unwatchAllExtraFiles (no active watchers)');
return;
}
debug('disableWatch: unwatchAllExtraFiles', [...watchedExtraFiles]);
for (const filePath of watchedExtraFiles) {
fs.unwatchFile(filePath);
}
watchedExtraFiles.clear();
pendingReloadFiles.clear();
}

function claimExtraFileWatcherOwnership(): boolean {
const ownerPid = process.env[WATCHER_OWNER_ENV_KEY];
if (ownerPid && ownerPid !== String(process.pid)) return false;
Expand Down Expand Up @@ -177,6 +196,11 @@ function getEnvFromNextCommand(dev: boolean): string {

function enableExtraFileWatchers(sources: SerializedEnvGraph['sources'], basePath?: string) {
if (!rootDir) return;
if (isExtraFileWatchDisabled()) {
unwatchAllExtraFiles();
debug('extra file watchers disabled');
return;
}
if (!claimExtraFileWatcherOwnership()) return;

// Collect absolute paths of source files that Next.js does NOT already watch
Expand Down Expand Up @@ -378,6 +402,15 @@ function writeResolvedEnvFile() {
export function updateInitialEnv(newEnv: Env) {
if (!Object.keys(newEnv).length) return;
debug('updateInitialEnv', newEnv);

// Next.js calls this after evaluating next.config with any process.env keys
// that changed during config load. That is how disableWatch reaches this module
// (plugin sets the flag; Next forwards the delta here).
if (DISABLE_WATCH_ENV_KEY in newEnv || isExtraFileWatchDisabled()) {
process.env[DISABLE_WATCH_ENV_KEY] ||= '1';
unwatchAllExtraFiles();
}

// Only merge keys that were already in initialEnv or are Next.js internal vars.
// Varlock-managed keys injected into process.env by initVarlockEnv should NOT
// pollute initialEnv — otherwise they get treated as process.env overrides on reload.
Expand All @@ -397,7 +430,8 @@ function replaceProcessEnv(sourceEnv: Env) {
Object.keys(process.env).forEach((key) => {
// Allow mutating internal Next.js env variables after the server has initiated.
// This is necessary for dynamic things like the IPC server port.
if (!key.startsWith('__NEXT_PRIVATE')) {
// Also preserve varlock next-integration control flags (watcher ownership, disableWatch).
if (!key.startsWith('__NEXT_PRIVATE') && !key.startsWith('__VARLOCK_NEXT_')) {
if (sourceEnv[key] === undefined || sourceEnv[key] === '') {
delete process.env[key];
}
Expand Down
15 changes: 12 additions & 3 deletions packages/integrations/nextjs/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,9 @@ function debug(...args: Array<any>) {
}
debug('✨ LOADED @varlock/next-integration/plugin module!');

type VarlockPluginOptions = {
// injectResolvedConfigAtBuildTime: boolean,
export type VarlockPluginOptions = {
/** Skip live watching of extra env source files (e.g. when sources are FIFOs). */
disableWatch?: boolean;
};

let scannedStaticFiles = false;
Expand Down Expand Up @@ -271,7 +272,15 @@ export type NextConfigFunction = (


// we make this a plugin a function because we'll likely end up adding some options
export function varlockNextConfigPlugin(_pluginOptions?: VarlockPluginOptions) {
export function varlockNextConfigPlugin(pluginOptions?: VarlockPluginOptions) {
if (pluginOptions?.disableWatch) {
// Next.js detects this process.env mutation after loading next.config and
// forwards the delta to @next/env via updateInitialEnv — that is how
// next-env-compat learns to tear down already-installed watchers.
process.env.__VARLOCK_NEXT_DISABLE_WATCH = '1';
debug('disableWatch: set __VARLOCK_NEXT_DISABLE_WATCH');
}

// nextjs doesnt have a proper plugin system :(
// so we use a function which takes in a config object and returns an augmented one
return (nextConfig: any | NextConfig | NextConfigFunction): NextConfigFunction => {
Expand Down
126 changes: 126 additions & 0 deletions packages/integrations/nextjs/test/disable-watch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import {
describe, it, expect, vi, beforeEach, afterEach,
} from 'vitest';
import * as os from 'node:os';
import * as path from 'node:path';
import * as realFs from 'node:fs';

const {
mockExecSyncVarlock,
mockInitVarlockEnv,
mockPatchGlobalConsole,
mockWatchFile,
mockUnwatchFile,
} = vi.hoisted(() => ({
mockExecSyncVarlock: vi.fn(),
mockInitVarlockEnv: vi.fn(),
mockPatchGlobalConsole: vi.fn(),
mockWatchFile: vi.fn(),
mockUnwatchFile: vi.fn(),
}));

vi.mock('varlock/exec-sync-varlock', () => ({
execSyncVarlock: mockExecSyncVarlock,
VarlockExecError: class VarlockExecError extends Error {},
}));

vi.mock('varlock/env', () => ({
initVarlockEnv: mockInitVarlockEnv,
resetRedactionMap: vi.fn(),
}));

vi.mock('varlock/patch-console', () => ({
patchGlobalConsole: mockPatchGlobalConsole,
}));

// next-env-compat uses `import * as fs from 'fs'`.
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
return {
...actual,
watchFile: mockWatchFile,
unwatchFile: mockUnwatchFile,
default: {
...actual,
watchFile: mockWatchFile,
unwatchFile: mockUnwatchFile,
},
};
});

const DISABLE_WATCH_ENV_KEY = '__VARLOCK_NEXT_DISABLE_WATCH';
const WATCHER_OWNER_ENV_KEY = '__VARLOCK_NEXT_ENV_WATCHER_PID';

describe('disableWatch teardown', () => {
let tmpDir: string;

beforeEach(() => {
vi.resetModules();
delete process.env.__VARLOCK_ENV;
delete process.env[DISABLE_WATCH_ENV_KEY];
delete process.env[WATCHER_OWNER_ENV_KEY];
vi.clearAllMocks();

tmpDir = realFs.mkdtempSync(path.join(os.tmpdir(), 'varlock-next-disable-watch-'));
// Early enableExtraFileWatchers([], ...) always watches rootDir/.env.schema
realFs.writeFileSync(path.join(tmpDir, '.env.schema'), '# ---\nFOO=bar\n');

mockExecSyncVarlock.mockReturnValue({
stdout: JSON.stringify({
basePath: tmpDir,
sources: [],
settings: {},
config: {
FOO: { value: 'bar', isSensitive: false },
},
}),
stderr: '',
});

mockWatchFile.mockImplementation(() => ({} as any));
mockUnwatchFile.mockImplementation(() => undefined);
});

afterEach(() => {
realFs.rmSync(tmpDir, { recursive: true, force: true });
delete process.env.__VARLOCK_ENV;
delete process.env[DISABLE_WATCH_ENV_KEY];
delete process.env[WATCHER_OWNER_ENV_KEY];
});

it('tears down already-installed watchers when updateInitialEnv receives the disable flag', async () => {
const { loadEnvConfig, updateInitialEnv } = await import('../src/next-env-compat');

loadEnvConfig(tmpDir, true);

// Dev load always installs at least the .env.schema watcher before/alongside load.
expect(mockWatchFile.mock.calls.length).toBeGreaterThan(0);
const watchedPaths = [...new Set(mockWatchFile.mock.calls.map((call) => call[0] as string))];
expect(watchedPaths).toContain(path.join(tmpDir, '.env.schema'));
expect(mockUnwatchFile).not.toHaveBeenCalled();

// This is the real Next.js path: after next.config mutates process.env,
// config.ts diffs env and calls updateInitialEnv(newEnv) on @next/env.
updateInitialEnv({ [DISABLE_WATCH_ENV_KEY]: '1' });

expect(process.env[DISABLE_WATCH_ENV_KEY]).toBe('1');
expect(mockUnwatchFile.mock.calls.length).toBeGreaterThan(0);
for (const watchedPath of watchedPaths) {
expect(mockUnwatchFile).toHaveBeenCalledWith(watchedPath);
}
});

it('does not install new watchers after disableWatch is set via updateInitialEnv', async () => {
const { loadEnvConfig, updateInitialEnv } = await import('../src/next-env-compat');

loadEnvConfig(tmpDir, true);
expect(mockWatchFile.mock.calls.length).toBeGreaterThan(0);

updateInitialEnv({ [DISABLE_WATCH_ENV_KEY]: '1' });
mockWatchFile.mockClear();

// Later loadEnvConfig still calls enableExtraFileWatchers; must not re-watch.
loadEnvConfig(tmpDir, true);
expect(mockWatchFile).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,12 @@ To integrate varlock into a Next.js application, use our [`@varlock/nextjs-integ
+export default withVarlock(nextConfig);
```

If env sources are FIFOs (or other paths that change constantly under `fs.watchFile`), pass `{ disableWatch: true }` to turn off varlock's extra-file watch and auto-reload. This does not disable Next.js's own watching of `.env`, `.env.local`, and related files.

```ts title="next.config.ts"
const withVarlock = varlockNextConfigPlugin({ disableWatch: true });
```

1. **Verify the override is active**

Start your dev server (or run a build) and look at the startup output. When the override is working, varlock reports the `.env` files it loaded with a banner:
Expand Down
Loading