diff --git a/.changeset/debounce-unchanged-env-restart.md b/.changeset/debounce-unchanged-env-restart.md new file mode 100644 index 000000000..ec2c16a93 --- /dev/null +++ b/.changeset/debounce-unchanged-env-restart.md @@ -0,0 +1,10 @@ +--- +"@varlock/cloudflare-integration": patch +--- + +fix(cloudflare): debounce wrangler restarts and skip when env is unchanged + +`varlock-wrangler dev` now caches the serialized resolved env graph and compares it +after each debounced watch event. Wrangler only restarts when the resolved env has +actually changed, preventing restart loops caused by spurious `fs.watch()` events +on macOS (which can emit events even when file contents are identical). diff --git a/framework-tests/frameworks/cloudflare/wrangler.test.ts b/framework-tests/frameworks/cloudflare/wrangler.test.ts index c4a7fc401..10d0c5111 100644 --- a/framework-tests/frameworks/cloudflare/wrangler.test.ts +++ b/framework-tests/frameworks/cloudflare/wrangler.test.ts @@ -192,6 +192,58 @@ describe('Cloudflare Workers varlock-wrangler only', () => { ], }); + wranglerEnv.describeDevScenario('no restart on unchanged env content', { + command: 'varlock-wrangler dev --port 18792', + readyPattern: /Ready on|ready in/i, + readyTimeout: 30_000, + templateFiles: { + 'src/index.ts': { path: 'workers/basic-worker.ts', prepend: "import '@varlock/cloudflare-integration/init';\n" }, + 'wrangler.jsonc': '_base-wrangler/wrangler.jsonc', + 'tsconfig.json': '_base-wrangler/tsconfig.json', + }, + requests: [ + // first request — baseline + { + path: '/', + bodyAssertions: { + shouldContain: ['public_var::public-test-value'], + }, + }, + // second request — write the same .env.schema content (simulates macOS spurious watch events) + // use fileEditDelay so we wait without expecting a restart + { + path: '/', + fileEdits: { + '.env.schema': [ + '# @defaultSensitive=false @defaultRequired=infer', + '# @currentEnv=$APP_ENV', + '# ---', + '', + '# @type=enum(dev, prod)', + 'APP_ENV=dev', + '', + 'PUBLIC_VAR=public-test-value', + 'API_URL=https://api.example.com', + '', + '# @sensitive', + 'SECRET_KEY=super-secret-value', + ].join('\n'), + }, + // wait longer than the 300ms debounce to confirm no restart occurred + fileEditDelay: 1500, + bodyAssertions: { + shouldContain: ['public_var::public-test-value'], + }, + }, + ], + outputAssertions: [ + { + description: 'wrangler is not restarted when env content is unchanged', + shouldNotContain: ['env changed, restarting wrangler'], + }, + ], + }); + describe('invalid config', () => { wranglerEnv.describeScenario('invalid schema causes dev failure', { command: 'varlock-wrangler dev --port 18791', diff --git a/packages/integrations/cloudflare/src/varlock-wrangler.ts b/packages/integrations/cloudflare/src/varlock-wrangler.ts index dc741c1a7..36a83a93e 100644 --- a/packages/integrations/cloudflare/src/varlock-wrangler.ts +++ b/packages/integrations/cloudflare/src/varlock-wrangler.ts @@ -412,21 +412,40 @@ async function handleDev(args: Array) { }); // watch env source files for changes and restart wrangler with fresh data + const DEBOUNCE_MS = 300; + // force a restart if the last save event was more than this long after the previous restart, + // even when the resolved env is identical — handles repeated saves of an unchanged file + const FORCE_RESTART_IDLE_MS = 5000; let restartTimeout: ReturnType | undefined; + let cachedGraphJson = loaded.json; + let lastRestartAt = Date.now(); function scheduleRestart() { - // debounce — multiple files may change at once + // debounce — multiple files may change at once (e.g. editor saves multiple files, + // or macOS fs.watch() emits extra events for unchanged files) if (restartTimeout) clearTimeout(restartTimeout); restartTimeout = setTimeout(() => { try { const freshLoaded = loadSerializedGraph(); + const now = Date.now(); + const idleSinceLastRestart = now - lastRestartAt > FORCE_RESTART_IDLE_MS; + // skip restart only when env is unchanged AND a restart happened recently + if (freshLoaded.json === cachedGraphJson && !idleSinceLastRestart) { + restartTimeout = undefined; + return; + } + cachedGraphJson = freshLoaded.json; + loaded = freshLoaded; + lastRestartAt = now; cachedContent = formatEnvFileContent(freshLoaded); handle.update(cachedContent); console.log('[varlock-wrangler] env changed, restarting wrangler...'); wranglerChild?.kill(); + // NOTE: restartTimeout stays truthy so the exit handler knows this was a restart-kill } catch (err) { + restartTimeout = undefined; console.error('[varlock-wrangler] failed to re-resolve env:', (err as Error).message); } - }, 300); + }, DEBOUNCE_MS); } // set up watchers on env source files