diff --git a/.bumpy/nextjs-disable-watch.md b/.bumpy/nextjs-disable-watch.md new file mode 100644 index 000000000..b0929815c --- /dev/null +++ b/.bumpy/nextjs-disable-watch.md @@ -0,0 +1,5 @@ +--- +"@varlock/nextjs-integration": minor +--- + +Add disableWatch option to varlockNextConfigPlugin to skip extra env-file watching (useful when sources are FIFOs). diff --git a/packages/integrations/nextjs/src/next-env-compat.ts b/packages/integrations/nextjs/src/next-env-compat.ts index 891e49edc..ad426f94d 100644 --- a/packages/integrations/nextjs/src/next-env-compat.ts +++ b/packages/integrations/nextjs/src/next-env-compat.ts @@ -143,6 +143,25 @@ const pendingReloadFiles = new Set(); // 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; @@ -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 @@ -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. @@ -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]; } diff --git a/packages/integrations/nextjs/src/plugin.ts b/packages/integrations/nextjs/src/plugin.ts index 50534b94b..2adcecd64 100644 --- a/packages/integrations/nextjs/src/plugin.ts +++ b/packages/integrations/nextjs/src/plugin.ts @@ -80,8 +80,9 @@ function debug(...args: Array) { } 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; @@ -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 => { diff --git a/packages/integrations/nextjs/test/disable-watch.test.ts b/packages/integrations/nextjs/test/disable-watch.test.ts new file mode 100644 index 000000000..9e73873d8 --- /dev/null +++ b/packages/integrations/nextjs/test/disable-watch.test.ts @@ -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('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(); + }); +}); diff --git a/packages/varlock-website/src/content/docs/integrations/nextjs.mdx b/packages/varlock-website/src/content/docs/integrations/nextjs.mdx index 48e5ae94e..11fa14113 100644 --- a/packages/varlock-website/src/content/docs/integrations/nextjs.mdx +++ b/packages/varlock-website/src/content/docs/integrations/nextjs.mdx @@ -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: