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/nextjs-fifo-env-sources.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@varlock/nextjs-integration": patch
---

Detect FIFO/non-regular env sources (e.g. 1Password Environments) and disable watching and reload checks for them, fixing dev-server hangs and repeated reload logs
5 changes: 5 additions & 0 deletions .bumpy/vite-fifo-env-sources.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@varlock/vite-integration": patch
---

Detect FIFO/non-regular env sources (e.g. 1Password Environments) and skip registering them for dev-server restart watching, with a one-time notice
5 changes: 5 additions & 0 deletions .bumpy/wrangler-fifo-and-mtime-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@varlock/cloudflare-integration": patch
---

varlock-wrangler dev: skip watching FIFO/non-regular env sources (fixes endless no-op reload logs), and ignore spurious watch events where file mtime is unchanged
5 changes: 5 additions & 0 deletions .bumpy/write-back-non-regular-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
varlock: patch
---

Refuse to write back encrypted values to non-regular source files (FIFO/pipe) with a clear error instead of blocking
29 changes: 27 additions & 2 deletions packages/integrations/cloudflare/src/varlock-wrangler.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable no-console */

import {
writeFileSync, unlinkSync, watch, existsSync,
writeFileSync, unlinkSync, watch, existsSync, statSync,
} from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
Expand Down Expand Up @@ -643,7 +643,32 @@ async function handleDev(args: Array<string>) {
if (!source.enabled || !source.path) continue;
const fullPath = join(loaded.graph.basePath, source.path);
try {
const w = watch(fullPath, () => scheduleRestart(fullPath));
// Sources that are not regular files (e.g. a FIFO served by 1Password
// Environments) cannot be watched: their stat churns whenever they are
// read, and each reload re-reads every source, so watching one creates
// an endless reload loop. Live reload is disabled for those sources.
const initialStat = statSync(fullPath);
if (!initialStat.isFile()) {
console.log(`ℹ️ [varlock-wrangler] ${source.path} is not a regular file (FIFO/pipe), live reload is disabled for it`);
continue;
}
let lastMtimeMs = initialStat.mtimeMs;
const w = watch(fullPath, () => {
// macOS fs.watch emits spurious change events when another local tool
// merely opens/inspects the file (issue #845). A real save always
// updates mtime, so ignore events where mtime is unchanged.
try {
const currentMtimeMs = statSync(fullPath).mtimeMs;
if (currentMtimeMs === lastMtimeMs) {
debug('dev: ignoring watch event with unchanged mtime', fullPath);
return;
}
lastMtimeMs = currentMtimeMs;
} catch {
// file may have been deleted/renamed — treat as a change
}
scheduleRestart(fullPath);
});
watchers.push(w);
debug('dev: watching', fullPath);
} catch {
Expand Down
89 changes: 79 additions & 10 deletions packages/integrations/nextjs/src/next-env-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,40 @@ function debugHash(hash: string | undefined) {
return hash.slice(0, 10);
}

// Env sources may not be regular files — e.g. 1Password Environments serves
// `.env` as a FIFO (named pipe) that `op` re-serves on every read. Reading such
// a file from our reload machinery makes the serving process write to the pipe
// again, which fires a new fs event, which triggers another reload check...
// an endless loop of reloads/logs. So these files must never be read, watched,
// or written to outside of the actual `varlock load` call.
function isExistingNonRegularFile(filePath: string): boolean {
try {
return !fs.statSync(filePath).isFile();
} catch {
return false; // missing files are handled as "missing" by callers
}
}

// deduped via env var because Next.js loads duplicate copies of this module
// (and spawns worker processes that would each log the same notice)
const WARNED_NON_REGULAR_ENV_KEY = '__VARLOCK_NEXT_WARNED_NON_REGULAR_FILES';
function warnNonRegularSourceOnce(filePath: string) {
const warnedList = (process.env[WARNED_NON_REGULAR_ENV_KEY] || '').split(',');
if (warnedList.includes(filePath)) return;
process.env[WARNED_NON_REGULAR_ENV_KEY] = [...warnedList.filter(Boolean), filePath].join(',');
const displayPath = rootDir ? (path.relative(rootDir, filePath) || filePath) : filePath;
logUserInfo(`ℹ️ [varlock] ${displayPath} is not a regular file (FIFO/pipe), live reload is disabled for it`);
}

function readFileHash(filePath: string): string | undefined {
try {
if (!fs.existsSync(filePath)) return undefined;
if (isExistingNonRegularFile(filePath)) {
// treat as opaque + constant — content is generated on read, so hashing it
// is meaningless and the read itself would re-trigger fs events
warnNonRegularSourceOnce(filePath);
return '(non-regular-file)';
}
const contents = fs.readFileSync(filePath, 'utf-8');
const hash = createHash('sha256');
hash.update(contents, 'utf8');
Expand Down Expand Up @@ -136,6 +167,13 @@ const NEXT_WATCHED_ENV_FILES = ['.env', '.env.local', '.env.development', '.env.
const watchedExtraFiles = new Set<string>();
const pendingReloadFiles = new Set<string>();

// Next's own env watcher fires on FIFO mtime churn (every read/serve of the
// pipe updates it), so "change detected" events for those files carry no signal
function nextWatchedEnvFilesIncludeNonRegular(): boolean {
if (!rootDir) return false;
return NEXT_WATCHED_ENV_FILES.some((f) => isExistingNonRegularFile(path.join(rootDir!, f)));
}

// Exactly one process should own the extra-file watchers. Which process calls
// loadEnvConfig varies by Next version: on next <= 15 the router server loads
// env itself (before spawning render workers), but on next 16 ONLY the render
Expand Down Expand Up @@ -185,9 +223,13 @@ function enableExtraFileWatchers(sources: SerializedEnvGraph['sources'], basePat
for (const source of sources) {
if (!source.enabled || !source.path) continue;
const absPath = basePath ? path.resolve(basePath, source.path) : path.resolve(rootDir!, source.path);
if (!nextWatchedAbsolute.has(absPath) && !watchedExtraFiles.has(absPath)) {
extraFilePaths.push(absPath);
if (nextWatchedAbsolute.has(absPath) || watchedExtraFiles.has(absPath)) continue;
if (isExistingNonRegularFile(absPath)) {
// watching a FIFO/pipe is meaningless (stat churns on every read/serve)
warnNonRegularSourceOnce(absPath);
continue;
}
extraFilePaths.push(absPath);
}
// Also always watch .env.schema even if it wasn't in sources (it may not exist yet)
const envSchemaPath = path.join(rootDir!, '.env.schema');
Expand All @@ -198,17 +240,34 @@ function enableExtraFileWatchers(sources: SerializedEnvGraph['sources'], basePat
if (!extraFilePaths.length) return;

// Find a Next-watched file to touch as the reload trigger.
// Prefer an existing file (cheaper), otherwise we'll create+destroy .env
// Prefer an existing regular file (cheaper) — a FIFO/pipe must never be
// written to (it would block and interfere with whatever serves it).
// Otherwise we'll create+destroy the first watched name that doesn't exist.
let triggerFilePath: string | null = null;
let mustDestroyTriggerFile = false;
for (const envFileName of NEXT_WATCHED_ENV_FILES) {
const filePath = path.join(rootDir!, envFileName);
if (fs.existsSync(filePath)) {
if (fs.existsSync(filePath) && !isExistingNonRegularFile(filePath)) {
triggerFilePath = filePath;
break;
}
}
const mustDestroyTriggerFile = !triggerFilePath;
triggerFilePath ||= path.join(rootDir!, '.env');
if (!triggerFilePath) {
for (const envFileName of NEXT_WATCHED_ENV_FILES) {
const filePath = path.join(rootDir!, envFileName);
if (!fs.existsSync(filePath)) {
triggerFilePath = filePath;
mustDestroyTriggerFile = true;
break;
}
}
}
if (!triggerFilePath) {
// all Next-watched env files exist but none are regular files — nothing we
// can safely touch to trigger a reload, so skip installing watchers
debug('no usable reload trigger file, skipping extra file watchers');
return;
}

const pendingWatchChanges = new Set<string>();
const watchedFileHashes = new Map<string, string | undefined>();
Expand Down Expand Up @@ -397,7 +456,9 @@ 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's own control flags (watcher ownership, dedupe markers)
// which are set after the initialEnv snapshot was taken.
if (!key.startsWith('__NEXT_PRIVATE') && !key.startsWith('__VARLOCK_NEXT_')) {
if (sourceEnv[key] === undefined || sourceEnv[key] === '') {
delete process.env[key];
}
Expand Down Expand Up @@ -496,7 +557,12 @@ export function loadEnvConfig(
if (currentSourceStateHash && currentSourceStateHash === lastLoadedSourceStateHash) {
useCachedEnv = true;
if (loadCount >= 2 && effectiveReloadSummary) {
if (Date.now() >= suppressSkipLogUntil) {
if (!reloadSummary && nextWatchedEnvFilesIncludeNonRegular()) {
// event came from Next's own watcher (not one of our tracked files) and
// a natively-watched env file is a FIFO — it's just churn from the pipe
// being read, not a user edit
debug('suppressing skip log for non-regular file watch churn');
} else if (Date.now() >= suppressSkipLogUntil) {
logUserInfo(`ℹ️ [varlock] change detected in ${effectiveReloadSummary}; file contents unchanged, skipping reload.`);
} else {
debug('suppressing immediate follow-up skip log');
Expand Down Expand Up @@ -551,14 +617,17 @@ export function loadEnvConfig(
version: __VARLOCK_INTEGRATION_VERSION__,
},
});
// The load itself reads every source file — for FIFO-backed sources (e.g.
// 1Password Environments) that read makes the pipe get re-served, firing
// new fs events. Suppress the "unchanged, skipping" log those immediate
// follow-up events would produce.
suppressSkipLogUntil = Date.now() + 1200;
if (loadCount >= 2 && forceReload) {
const envChanged = stdout !== previousSerializedEnv;
if (effectiveReloadSummary) {
if (envChanged) {
suppressSkipLogUntil = Date.now() + 1200;
logUserInfo(`✅ [varlock] change detected in ${effectiveReloadSummary}; reloaded env, changes found.`);
} else {
suppressSkipLogUntil = Date.now() + 1200;
logUserInfo(`✅ [varlock] change detected in ${effectiveReloadSummary}; reloaded env, no changes found.`);
}
}
Expand Down
157 changes: 157 additions & 0 deletions packages/integrations/nextjs/test/fifo-sources.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import {
describe, it, expect, vi, beforeEach, afterEach,
} from 'vitest';
import * as os from 'node:os';
import * as path from 'node:path';
import { execSync } from 'node:child_process';
import * as realFs from 'node:fs';

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

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

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

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

// next-env-compat uses `import * as fs from 'fs'`. We mock watchFile/unwatchFile
// and spy on readFileSync — reading a FIFO with no writer blocks forever, so the
// spy returns fake content for FIFO paths instead of delegating (a correct
// implementation never reads them; the assertion below is what actually matters).
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
const readFileSync = (...args: Array<any>) => {
readFileSyncSpy(...args);
const filePath = args[0];
if (typeof filePath === 'string' && actual.existsSync(filePath) && !actual.statSync(filePath).isFile()) {
return 'FAKE_FIFO_CONTENT=1\n';
}
return (actual.readFileSync as any)(...args);
};
return {
...actual,
readFileSync,
watchFile: mockWatchFile,
unwatchFile: mockUnwatchFile,
default: {
...actual,
readFileSync,
watchFile: mockWatchFile,
unwatchFile: mockUnwatchFile,
},
};
});

function readFileSyncCallsFor(filePath: string) {
return readFileSyncSpy.mock.calls.filter((call) => call[0] === filePath);
}

describe.skipIf(process.platform === 'win32')('FIFO env sources (e.g. 1Password Environments)', () => {
let tmpDir: string;
let fifoEnvPath: string; // .env — a name Next.js natively watches
let fifoExtraPath: string; // .env.custom — a name varlock would extra-watch
let consoleLogSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
delete process.env.__VARLOCK_ENV;
delete process.env.__VARLOCK_NEXT_ENV_WATCHER_PID;
delete process.env.__VARLOCK_NEXT_WARNED_NON_REGULAR_FILES;
(globalThis as any).__VARLOCK_INTEGRATION_NAME__ = '@varlock/nextjs-integration';
(globalThis as any).__VARLOCK_INTEGRATION_VERSION__ = '0.0.0-test';

tmpDir = realFs.mkdtempSync(path.join(os.tmpdir(), 'varlock-next-fifo-'));
realFs.writeFileSync(path.join(tmpDir, '.env.schema'), '# ---\nFOO=bar\n');
fifoEnvPath = path.join(tmpDir, '.env');
fifoExtraPath = path.join(tmpDir, '.env.custom');
execSync(`mkfifo "${fifoEnvPath}" "${fifoExtraPath}"`);

mockExecSyncVarlock.mockReturnValue({
stdout: JSON.stringify({
basePath: tmpDir,
sources: [
{
enabled: true, path: '.env.schema', type: 'file', label: '.env.schema',
},
{
enabled: true, path: '.env', type: 'file', label: '.env',
},
{
enabled: true, path: '.env.custom', type: 'file', label: '.env.custom',
},
],
settings: {},
config: { FOO: { value: 'bar', isSensitive: false } },
}),
stderr: '',
});
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
});

afterEach(() => {
consoleLogSpy.mockRestore();
realFs.rmSync(tmpDir, { recursive: true, force: true });
delete process.env.__VARLOCK_ENV;
delete process.env.__VARLOCK_NEXT_ENV_WATCHER_PID;
delete process.env.__VARLOCK_NEXT_WARNED_NON_REGULAR_FILES;
});

it('never reads or watches FIFO sources during load and reload checks', async () => {
const { loadEnvConfig } = await import('../src/next-env-compat');

loadEnvConfig(tmpDir, true);

// regular extra file (.env.schema) gets watched, FIFO sources do not
const watchedPaths = mockWatchFile.mock.calls.map((call) => call[0] as string);
expect(watchedPaths).toContain(path.join(tmpDir, '.env.schema'));
expect(watchedPaths).not.toContain(fifoEnvPath);
expect(watchedPaths).not.toContain(fifoExtraPath);

// the source-state hashing must not have read the FIFOs
expect(readFileSyncCallsFor(fifoEnvPath)).toHaveLength(0);
expect(readFileSyncCallsFor(fifoExtraPath)).toHaveLength(0);

// simulate Next's own .env watcher firing (it always watches .env);
// the forceReload hash check must still not read the FIFOs
vi.useFakeTimers();
try {
vi.advanceTimersByTime(2000); // get past the 1s reload throttle
loadEnvConfig(tmpDir, true, console, true);
} finally {
vi.useRealTimers();
}
expect(readFileSyncCallsFor(fifoEnvPath)).toHaveLength(0);
expect(readFileSyncCallsFor(fifoExtraPath)).toHaveLength(0);
});

it('logs a one-time notice that live reload is disabled for FIFO sources', async () => {
const { loadEnvConfig } = await import('../src/next-env-compat');

loadEnvConfig(tmpDir, true);
loadEnvConfig(tmpDir, true);

const fifoNoticeLogs = consoleLogSpy.mock.calls.filter(
(call: Array<any>) => String(call[0]).includes('.env.custom') && String(call[0]).includes('live reload is disabled'),
);
expect(fifoNoticeLogs).toHaveLength(1);
});
});
Loading
Loading