import chokidar from "chokidar"; import path from "path"; import { existsSync, mkdirSync, writeFileSync } from "fs"; import grabDirNames from "../../utils/grab-dir-names"; import fullRebuild from "./full-rebuild"; import { AppData } from "../../data/app-data"; import checkExcludedPatterns from "../../utils/check-excluded-patterns"; import allPagesESBuildContextBundler, { isHydrationEntryPage, } from "../bundler/all-pages-esbuild-context-bundler"; import grabAllPages from "../../utils/grab-all-pages"; import serverPostBuildFn from "./server-post-build-fn"; import { log } from "../../utils/log"; import { logBuildErrorsOnce } from "../../utils/build-error-log"; const { ROOT_DIR } = grabDirNames(); // The watchdog writes this file and expects the watcher to report it. It // sits under the dist directory (otherwise ignored), so only this path is // watched. function grabWatchPaths() { const { BUNX_CWD_DIR } = grabDirNames(); const WATCH_PROBE_DIR = path.join(BUNX_CWD_DIR, "watch-probe"); const WATCH_PROBE_FILE = path.join(WATCH_PROBE_DIR, "probe"); return { BUNX_CWD_DIR, WATCH_PROBE_DIR, WATCH_PROBE_FILE }; } // Dedupes near-simultaneous restarts (fullRebuild, watchdog). let last_watcher_restart = 0; // The current watcher finished its initial scan; probes written before // that may not produce events. let watcher_ready = false; // A probe was written and the watcher hasn't reported it yet. let probe_pending = false; /** * Closes and re-creates the FS watcher. Deduped: callers that restart the * watcher around the same time (fullRebuild, reloadWatcher, watchdog) only * trigger a single re-creation. */ export function restartWatcher() { if (!global.BUNEXT_PAGES_SRC_WATCHER) return; if (Date.now() - last_watcher_restart < 1000) return; // Files created between close() and the new watcher's initial scan // completing emit no `add` (ignoreInitial treats them as pre-existing). // Snapshot the page set so the new watcher can detect them on `ready`. const pages_snapshot = grabPagesSnapshot(); try { global.BUNEXT_PAGES_SRC_WATCHER.close(); } catch (error) { } global.BUNEXT_PAGES_SRC_WATCHER = undefined; last_watcher_restart = Date.now(); watcherEsbuildCTX({ pages_snapshot }); } function grabPagesSnapshot() { try { return grabAllPages() .map((p) => p.local_path) .sort() .join("\n"); } catch (error) { return undefined; } } /** * Called periodically by the dev server. Re-creates the watcher only when it * failed to report the probe written on the previous tick, i.e. when it went * stale. A healthy watcher is left alone: re-creating it rescans the whole * project tree. */ export function watcherWatchdogTick() { const { WATCH_PROBE_DIR, WATCH_PROBE_FILE } = grabWatchPaths(); if (!global.BUNEXT_PAGES_SRC_WATCHER || !watcher_ready) return; if (probe_pending) { restartWatcher(); return; } try { mkdirSync(WATCH_PROBE_DIR, { recursive: true }); writeFileSync(WATCH_PROBE_FILE, String(Date.now())); probe_pending = true; } catch (error) { } } function isWatchProbePath(p) { const { WATCH_PROBE_DIR, WATCH_PROBE_FILE } = grabWatchPaths(); return (p === WATCH_PROBE_FILE || p === WATCH_PROBE_DIR || isDistDirOrAncestor(p)); } /** * The dist directory and the folders between it and the project root must * stay watched for the watcher to reach the probe, even when an ignore rule * matches them (dot folders, `dist`, a custom `dist_dir`). */ function isDistDirOrAncestor(p) { const { BUNX_CWD_DIR } = grabDirNames(); if (p === ROOT_DIR) return false; return p === BUNX_CWD_DIR || BUNX_CWD_DIR.startsWith(p + path.sep); } function isInDistDir(p) { const { BUNX_CWD_DIR } = grabDirNames(); return p === BUNX_CWD_DIR || p.startsWith(BUNX_CWD_DIR + path.sep); } const TARGET_FILES_MATCH = /\.(tsx?|jsx?|css)$/; // Rebuild coalescing: editors emit bursts of events per save (change + // atomic-swap noise), and edits can land while a rebuild is running. // Events are debounced into a single runner; the runner loops until the // event queue drains, so a mid-build edit is never silently dropped. const REBUILD_DEBOUNCE_MS = 50; let pending_structural = false; let pending_content_file = undefined; let debounce_timer = undefined; // Guards against overlapping runners: BUNEXT_RECOMPILING alone is not enough // since the artifact tracker clears it mid-run. let runner_active = false; function scheduleRebuild() { if (debounce_timer) { clearTimeout(debounce_timer); } debounce_timer = setTimeout(async () => { debounce_timer = undefined; // Another rebuild (fullRebuild, or a runner already looping) owns the // bundler. Retry later rather than dropping the pending events. if (runner_active || global.BUNEXT_RECOMPILING) { scheduleRebuild(); return; } try { await runRebuilds(); } catch (error) { const msg = String(error?.message || error || ""); if (!msg.startsWith("Build failed with")) { logBuildErrorsOnce([{ text: `Watcher rebuild failed: ${msg}` }], (line) => log.error(line)); } } }, REBUILD_DEBOUNCE_MS); } /** * Processes the pending event batch. Structural changes go through the * all-pages bundler (which re-creates esbuild contexts only when the entry * set really changed, so atomic-swap saves resolve to incremental rebuilds). * Content changes rebuild the hydration context incrementally; its artifact * tracker then queues the single-flighted SSR rebuild and pushes HMR updates. */ async function runRebuilds() { runner_active = true; global.BUNEXT_RECOMPILING = true; global.BUNEXT_IS_SERVER_COMPONENT = false; try { while (true) { const structural = pending_structural; const content_file = pending_content_file; pending_structural = false; pending_content_file = undefined; if (!structural && !content_file) { break; } if (structural) { global.BUNEXT_ROUTER.reload(); await allPagesESBuildContextBundler({ post_build_fn: async (params) => { await serverPostBuildFn(params); }, }); } else if (content_file) { if (global.BUNEXT_BUNDLER_CTX && !global.BUNEXT_BUNDLER_CTX_DISPOSED) { await global.BUNEXT_BUNDLER_CTX.rebuild(); } else if (global.BUNEXT_BUNDLER_CTX_DISPOSED) { await fullRebuild({ msg: `Restarting Bundler ...`, }); break; } if (content_file.match(/(404|500)\.tsx?$/)) { for (let i = global.BUNEXT_HMR_CONTROLLERS.length - 1; i >= 0; i--) { const controller = global.BUNEXT_HMR_CONTROLLERS[i]; try { controller?.controller?.enqueue(`event: update\ndata: ${JSON.stringify({ reload: true })}\n\n`); } catch { global.BUNEXT_HMR_CONTROLLERS.splice(i, 1); } } } } } } finally { runner_active = false; global.BUNEXT_RECOMPILING = false; global.BUNEXT_IS_SERVER_COMPONENT = false; } } function isPathExcluded(filename, full_file_path) { const user_patterns = global.BUNEXT_CONFIG?.exclude_watch_patterns; if (!user_patterns) return false; for (let i = 0; i < user_patterns.length; i++) { const watch_pattern = user_patterns[i]; if (watch_pattern instanceof RegExp) { if (watch_pattern.test(filename)) return true; } else { const excluded_path = path.resolve(ROOT_DIR, watch_pattern); if (excluded_path == full_file_path) return true; } } return false; } // Heavy directories are filtered at the watcher level so inotify doesn't // install watches (and deliver events) for trees the bundler never reads. function buildIgnoredPatterns() { const builtin_patterns = [ /(^|[\/\\])\../, /(^|[\/\\])node_modules([\/\\]|$)/, /(^|[\/\\])public([\/\\]|$)/, /(^|[\/\\])dist([\/\\]|$)/, /bun\.lockb$/, ]; const ignored = [ (p) => !isWatchProbePath(p) && (isInDistDir(p) || builtin_patterns.some((re) => re.test(p))), (p) => p.endsWith(AppData["BunextTmpFileExt"]), ]; const user_patterns = global.BUNEXT_CONFIG?.exclude_watch_patterns; if (user_patterns) { for (let i = 0; i < user_patterns.length; i++) { const pattern = user_patterns[i]; if (pattern instanceof RegExp) { ignored.push((p) => pattern.test(path.relative(ROOT_DIR, p))); } else { const excluded_path = path.resolve(ROOT_DIR, pattern); ignored.push((p) => p === excluded_path); } } } return ignored; } export default async function watcherEsbuildCTX(params) { const { WATCH_PROBE_DIR } = grabWatchPaths(); try { mkdirSync(WATCH_PROBE_DIR, { recursive: true }); } catch (error) { } watcher_ready = false; probe_pending = false; const pages_src_watcher = chokidar.watch(ROOT_DIR, { ignored: buildIgnoredPatterns(), persistent: true, ignoreInitial: true, }); watcherHandlers(pages_src_watcher, (event, filePath) => handleEvent(event, filePath)); const pages_snapshot = params?.pages_snapshot; pages_src_watcher.on("ready", () => { if (global.BUNEXT_PAGES_SRC_WATCHER === pages_src_watcher) { watcher_ready = true; } if (pages_snapshot === undefined) return; const current = grabPagesSnapshot(); if (current !== undefined && current !== pages_snapshot) { pending_structural = true; scheduleRebuild(); } }); global.BUNEXT_PAGES_SRC_WATCHER = pages_src_watcher; } function handleEvent(event, filePath) { const { WATCH_PROBE_FILE } = grabWatchPaths(); const { BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames(); if (isWatchProbePath(filePath)) { if (filePath === WATCH_PROBE_FILE) { probe_pending = false; } return; } try { const filename = path.relative(ROOT_DIR, filePath); const full_file_path = filePath; if (isPathExcluded(filename, full_file_path)) { return; } if (existsSync(BUNX_BUNDLER_ERROR_EXIT_FILE)) { fullRebuild(); return; } if (filename.endsWith(AppData["BunextTmpFileExt"])) { return; } if (full_file_path.match(/\/styles$/)) { pending_structural = false; pending_content_file = undefined; if (debounce_timer) clearTimeout(debounce_timer); setTimeout(() => { fullRebuild({ msg: `Detected new \`styles\` directory. Rebuilding ...`, }); }, 1000); return; } if (filename.match(/bunext\.config\.ts/)) { pending_structural = false; pending_content_file = undefined; if (debounce_timer) clearTimeout(debounce_timer); fullRebuild({ msg: `bunext.config.ts file changed. Rebuilding server ...`, }); return; } if (event === "change") { if (!filename.match(TARGET_FILES_MATCH)) { return; } // A page that isn't a hydration entry yet (e.g. created empty, // then given its default export) needs the entry set recomputed; // an incremental rebuild of the existing context would miss it. if (filename.match(/^src\/pages\//) && !filename.match(/^src\/pages\/api\/|\.server\.tsx?$/) && !checkExcludedPatterns({ path: filename }) && !isHydrationEntryPage(full_file_path)) { pending_structural = true; } else { pending_content_file = full_file_path; } scheduleRebuild(); return; } // add / unlink / addDir / unlinkDir const is_dir_event = event === "addDir" || event === "unlinkDir"; const is_file_of_interest = Boolean(filename.match(TARGET_FILES_MATCH)) || is_dir_event; if (!is_file_of_interest) { return; } // Only page routes and stylesheets are structural; anything else // (e.g. a component being added) is covered by the next content // rebuild and needs no context recreation. if (!filename.match(/^src\/pages\/|\.css$/) || checkExcludedPatterns({ path: filename }) || filename.match(/ /)) { return; } pending_structural = true; scheduleRebuild(); } catch (error) { logBuildErrorsOnce([{ text: `Watcher error: ${error?.message || error}` }], (line) => log.error(line)); } } function watcherHandlers(watcher, handleEvent) { watcher .on("add", (p) => handleEvent("add", p)) .on("change", (p) => handleEvent("change", p)) .on("unlink", (p) => handleEvent("unlink", p)) .on("addDir", (p) => handleEvent("addDir", p)) .on("unlinkDir", (p) => handleEvent("unlinkDir", p)); }