import chokidar from "chokidar"; import path from "path"; import { existsSync } 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, BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames(); // Tracks the last watcher re-creation so the 5s watchdog in start-server // doesn't fight fullRebuild (which also restarts the watcher after a rebuild). let last_watcher_restart = 0; /** * 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; } } /** * True when the watcher was last (re)created long enough ago that the * watchdog should consider re-creating it again. */ export function watcherRestartAvailable() { return Date.now() - last_watcher_restart > 6000; } 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 = 200; 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 ignored = [ /(^|[\/\\])\../, /(^|[\/\\])node_modules([\/\\]|$)/, /(^|[\/\\])public([\/\\]|$)/, /(^|[\/\\])\.bunext([\/\\]|$)/, /(^|[\/\\])\.git([\/\\]|$)/, /(^|[\/\\])dist([\/\\]|$)/, /bun\.lockb$/, (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 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; if (pages_snapshot !== undefined) { pages_src_watcher.on("ready", () => { 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) { 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)); }