import type * as __ManduContentCollectionTypes0 from "../content/collection"; /** * Mandu Dev Bundler πŸ”₯ * 개발 λͺ¨λ“œ λ²ˆλ“€λ§ + HMR (Hot Module Replacement) */ import type { RoutesManifest } from "../spec/schema"; import { buildClientBundles } from "./build"; import type { BundleResult } from "./types"; import { PORTS, TIMEOUTS } from "../constants"; import { mark, measure, withPerf } from "../perf"; import { HMR_PERF } from "../perf/hmr-markers"; import type { CoalescedChange, ViteHMRPayload, HMRReplayEnvelope, } from "./hmr-types"; import { MAX_REPLAY_BUFFER, REPLAY_MAX_AGE_MS } from "./hmr-types"; import { ReverseImportGraph, scanFileImports, DEFAULT_MAX_CLOSURE_DEPTH, } from "./reverse-import-graph"; import path from "path"; import fs from "fs"; import { LRUCache } from "../utils/lru-cache"; import { registerCacheSize, unregisterCacheSize } from "../observability/metrics"; export { generateFastRefreshPreamble } from "./fast-refresh-preamble"; /** * #184: 곡톡 디렉토리 λ³€κ²½ μ‹œ μ‚¬μš©ν•˜λŠ” sentinel. * `onSSRChange`에 νŠΉμ • 파일 경둜 λŒ€μ‹  이 μƒμˆ˜λ₯Ό μ „λ‹¬ν•˜λ©΄ "전체 SSR λ ˆμ§€μŠ€νŠΈλ¦¬ invalidate" 의미. */ export const SSR_CHANGE_WILDCARD = "*"; export interface DevBundlerOptions { /** ν”„λ‘œμ νŠΈ 루트 */ rootDir: string; /** 라우트 λ§€λ‹ˆνŽ˜μŠ€νŠΈ */ manifest: RoutesManifest; /** μž¬λΉŒλ“œ 콜백 */ onRebuild?: (result: RebuildResult) => void; /** μ—λŸ¬ 콜백 */ onError?: (error: Error, routeId?: string) => void; /** * SSR 파일 λ³€κ²½ 콜백 (page.tsx, layout.tsx λ“±) * ν΄λΌμ΄μ–ΈνŠΈ λ²ˆλ“€ λ¦¬λΉŒλ“œ 없이 μ„œλ²„ ν•Έλ“€λŸ¬ μž¬λ“±λ‘μ΄ ν•„μš”ν•œ 경우 호좜. * `SSR_CHANGE_WILDCARD` ("*")λ₯Ό λ°›μœΌλ©΄ 전체 λ ˆμ§€μŠ€νŠΈλ¦¬ invalidate 의미 (#184). * Promise λ°˜ν™˜ μ‹œ await λ˜λ―€λ‘œ λ ˆμ§€μŠ€νŠΈλ¦¬ clearκ°€ μ™„λ£Œλœ ν›„ HMR reload broadcast κ°€λŠ₯. */ onSSRChange?: (filePath: string) => void | Promise; /** * API route 파일 λ³€κ²½ 콜백 (route.ts λ“±) * API ν•Έλ“€λŸ¬ μž¬λ“±λ‘μ΄ ν•„μš”ν•œ 경우 호좜 */ onAPIChange?: (filePath: string) => void | Promise; /** * Phase 7.0 R2 Agent D β€” Config/env change callback. * * Fires when `mandu.config.ts` or any `.env*` file at the project root * changes. The CLI's `dev.ts` wires this to `restartDevServer()` so the * new config values take effect (Node caches `process.env.KEY` per- * process, so an auto-restart is the only reliable reload path). * * Multiple rapid changes in one debounce window fire this ONCE (per-file * debounce + `pendingBuildSet` coalescing in `classifyBatch`). */ onConfigReload?: (filePath: string) => void | Promise; /** * Phase 7.0 R2 Agent D β€” Contract / Resource change callback. * * Fires when a contract (`spec/contracts/foo.contract.ts` β€” nested * directories allowed) or resource schema * (`spec/resources/user.resource.ts`) file changes. Consumers typically: * - Re-run `generateResourceArtifacts` for `.resource.ts` changes * (so derived `.mandu/generated/server/contracts`, * `types`, `client`, and `spec/slots` stay in sync). * - For `.contract.ts`, re-register the route handler that consumed * the contract (usually via `onSSRChange(SSR_CHANGE_WILDCARD)`). * * When both fire in the same batch, `classifyBatch` returns * `"resource-regen"` exactly once. */ onResourceChange?: (filePath: string) => void | Promise; /** * Issue #242 β€” content collection change callback. * * Fires when an MDX/MD/YAML/JSON file under `content/` is added, * edited, or removed. Before invoking the callback the bundler has * already called `invalidateAllCollections()` so the Collection API's * in-memory cache is clear; the next `.all()` / `.get()` call will * rescan disk. * * The CLI typically wires this to the HMR server's `broadcast` to * trigger a full browser reload so sidebars / route trees pick up the * added file. Without a callback the invalidation still happens β€” * a manual refresh will surface the change. */ onContentChange?: (filePath: string) => void | Promise; /** * Issue #240 β€” React Compiler opt-in. Forwarded verbatim to each * `buildClientBundles()` invocation (initial build + every rebuild * triggered by `handleFileChange`). Off by default. SSR paths are * never affected regardless of this flag. */ reactCompiler?: { enabled?: boolean; compilerConfig?: Record; }; /** * Phase 7.0 R2 Agent D β€” package.json change notification. * * We intentionally do NOT auto-restart on `package.json` β€” dependency * installs often write the file multiple times in quick succession, and * a restart loop mid-install would be destructive. The callback exists * so the CLI can print a "manual restart required" hint to the user. */ onPackageJsonChange?: (filePath: string) => void; /** * μΆ”κ°€ watch 디렉토리 (곡톡 μ»΄ν¬λ„ŒνŠΈ λ“±) * μƒλŒ€ 경둜 λ˜λŠ” μ ˆλŒ€ 경둜 λͺ¨λ‘ 지원 * κΈ°λ³Έκ°’: ["src/components", "components", "src/shared", "shared", "src/lib", "lib", "src/hooks", "hooks", "src/utils", "utils"] */ watchDirs?: string[]; /** * κΈ°λ³Έ watch 디렉토리 λΉ„ν™œμ„±ν™” * true둜 μ„€μ •ν•˜λ©΄ watchDirs만 κ°μ‹œ */ disableDefaultWatchDirs?: boolean; } export interface RebuildResult { routeId: string; success: boolean; buildTime: number; error?: string; } export interface DevBundler { /** 초기 λΉŒλ“œ κ²°κ³Ό */ initialBuild: BundleResult; /** Reverse import graph initial seed completion for deterministic tests. */ reverseGraphReady: Promise; /** 파일 κ°μ‹œ 쀑지 */ close: () => void; } /** * #180: 파일 경둜 비ꡐλ₯Ό μœ„ν•œ μ •κ·œν™”. * - μ ˆλŒ€ 경둜둜 λ³€ν™˜ (path.resolve) * - λ°±μŠ¬λž˜μ‹œ β†’ ν¬μ›Œλ“œμŠ¬λž˜μ‹œ * - Windowsμ—μ„œλŠ” case-insensitive λ§€μΉ­ (μ†Œλ¬Έμžν™”) * * 동적 라우트 폴더(`[lang]` λ“±) λ³€κ²½ 감지가 λˆ„λ½λ˜λ˜ λ¬Έμ œλŠ” watcherκ°€ λ³΄κ³ ν•˜λŠ” * `path.join(dir, filename)`κ³Ό `serverModuleSet` 등둝 μ‹œμ˜ `path.resolve(rootDir, ...)` * κ°€ λ“œλΌμ΄λΈŒ 문자 λŒ€μ†Œλ¬Έμž/μŠ¬λž˜μ‹œ ν‘œκΈ° 차이둜 μ–΄κΈ‹λ‚˜μ„œ λ°œμƒν–ˆμŒ. */ function normalizeFsPath(p: string): string { const resolved = path.resolve(p).replace(/\\/g, "/"); return process.platform === "win32" ? resolved.toLowerCase() : resolved; } /** * κΈ°λ³Έ 곡톡 μ»΄ν¬λ„ŒνŠΈ 디렉토리 λͺ©λ‘ (B1 fix β€” Phase 7.0 R1 Agent A). * * Historical (pre-B1) behavior: only `src/components`, `src/shared`, etc. were * watched, which silently ignored `src/foo.ts` (top-level files) β€” a real * regression hit in `demo/starter/src/playground-shell.tsx`. B1 widens the * default to include **`src/` itself** (recursive, node_modules-excluded) plus * the legacy unprefixed roots so existing projects without an `src/` dir * continue to work. */ const DEFAULT_COMMON_DIRS = [ "src", // B1 fix β€” top-level files under `src/` (was missing) "components", "shared", "lib", "hooks", "utils", "client", "islands", ]; /** * Path segments excluded from `isInCommonDir` / watcher dispatch. * * We intentionally use **absolute path segment prefixes** (join-style) so a * project file named `dist-nice.ts` is NOT treated as excluded. The check is * "contains `//`" against the normalized forward-slash path. * * `pagefile.sys` / `hiberfil.sys` / `DumpStack.log.tmp` are Windows system * files that can bubble up into `fs.watch` on the drive root under pathological * setups β€” belt-and-suspenders. */ const WATCH_EXCLUDE_SEGMENTS: readonly string[] = [ "node_modules", ".mandu", ".git", "dist", "build", ".next", "coverage", ".cache", ".turbo", ]; /** * Filenames explicitly ignored (Windows system files + editor artifacts). * * Stored lowercase so the check compares apples-to-apples with * `normalizeFsPath`'s win32 lowercasing. On posix the comparison is still * lowercase β€” intentional, since the Windows system files these target * never legitimately appear on Linux/mac anyway. */ const WATCH_EXCLUDE_FILENAMES: ReadonlySet = new Set([ "pagefile.sys", "hiberfil.sys", "dumpstack.log", "dumpstack.log.tmp", "swapfile.sys", ]); /** * Returns true if the given **normalized (forward-slash, lowercase on win32)** * path is inside a directory we should ignore (e.g. `node_modules`). * * Callers must pass paths already through `normalizeFsPath`. */ export function isExcludedPath(normalizedPath: string): boolean { // Filename-level ignores. We lowercase the basename BEFORE comparing so the // check behaves identically whether the caller ran `normalizeFsPath` (which // lowercases only on win32) or not β€” Windows system files like // `DumpStack.log` have no valid posix counterpart, so lowercasing is safe // on linux too. const basename = (normalizedPath.split("/").pop() ?? "").toLowerCase(); if (WATCH_EXCLUDE_FILENAMES.has(basename)) return true; // Directory-segment ignores. Wrap with slashes to avoid partial-name matches // (e.g. `dist-ribution.ts` must not be excluded by `dist`). for (const segment of WATCH_EXCLUDE_SEGMENTS) { if (normalizedPath.includes(`/${segment}/`)) return true; } return false; } /** * Phase 7.0 R2 Agent D β€” Config-file predicate. * * Matches `mandu.config.ts` / `mandu.config.js` / `.env` / `.env.local` / * `.env.development` / `.env.production` (and similar `.env.*` variants) * at the **project root**. The argument must already be normalized β€” * callers should run `normalizeFsPath` first. * * We look at the basename (not a full path prefix) so the check is cheap * and cross-platform. The caller is responsible for restricting the * watch set to the project root β€” that's where a genuine config lives. * An `.env` deep inside `node_modules` (pathological) is already * excluded by `isExcludedPath`. */ export function isConfigOrEnvFile(normalizedPath: string): boolean { const basename = (normalizedPath.split("/").pop() ?? "").toLowerCase(); // `mandu.config.ts|js|mjs|cjs` β€” accept .ts|.js for JS-only projects. if ( basename === "mandu.config.ts" || basename === "mandu.config.js" || basename === "mandu.config.mjs" || basename === "mandu.config.cjs" ) { return true; } // `.env` family. `.env` alone is valid; `.env.local`, `.env.development`, // `.env.production`, `.env.staging`, `.env.test` etc. all match. if (basename === ".env" || basename.startsWith(".env.")) { return true; } return false; } /** * Phase 7.0 R2 Agent D β€” Resource/Contract file predicate. * * Matches `*.resource.ts` (and `*.resource.tsx` for the rare JSX-in- * schema case) and `*.contract.ts|tsx`. These are user-authored schema * files that drive code-gen (`generateResourceArtifacts`) and Zod-based * route handlers. * * Intentionally NOT restricted to `spec/contracts` / `spec/resources` β€” * some projects keep contracts colocated with the route * (`app/api/users/users.contract.ts`). The `classifyBatch` caller * already ensures the path is inside the watched tree. */ export function isResourceOrContractFile(normalizedPath: string): boolean { return ( normalizedPath.endsWith(".contract.ts") || normalizedPath.endsWith(".contract.tsx") || normalizedPath.endsWith(".resource.ts") || normalizedPath.endsWith(".resource.tsx") ); } /** * Phase 7.0 R2 Agent D β€” per-route `middleware.ts` predicate. * * Matches files whose basename is `middleware.ts` / `middleware.tsx` * (nested under any `app` subdirectory). Layout-level `middleware.ts` * at the project root is handled separately through the existing * runtime loader β€” those changes require a restart because the server * loads them at boot, not per-request. Per-route middleware is * re-scanned when the route graph is re-registered, so we funnel these * through the existing `api-only` rebuild path which already calls * `registerHandlers(manifest, true)`. */ export function isRouteMiddlewareFile(normalizedPath: string): boolean { return ( normalizedPath.endsWith("/middleware.ts") || normalizedPath.endsWith("/middleware.tsx") ); } /** * Phase 7.0 R2 Agent D β€” `package.json` predicate. * * Matches the project-root `package.json`. Restricted to basename only β€” * nested `package.json` files (inside `node_modules`, workspace sub- * packages) are caught by `isExcludedPath` in `node_modules` / * `.mandu` trees, and workspace changes are outside the dev-time loop. */ export function isPackageJsonFile(normalizedPath: string): boolean { return (normalizedPath.split("/").pop() ?? "").toLowerCase() === "package.json"; } /** * Test-only helper: invoke the internal `normalizeFsPath` implementation. * Exported so `dev-reliability.test.ts` can assert forward-slash / lower-case * normalization without duplicating the logic. * * Not part of the public API surface β€” prefixed with `_testOnly_` to signal * "do not consume in production code". If you need this elsewhere, lift * `normalizeFsPath` to a dedicated module. */ export function _testOnly_normalizeFsPath(p: string): string { return normalizeFsPath(p); } /** Test-only accessor for the default common-dir list (B1 coverage). */ export const _testOnly_DEFAULT_COMMON_DIRS = DEFAULT_COMMON_DIRS; /** Test-only accessor for the watch exclude segments (B1 coverage). */ export const _testOnly_WATCH_EXCLUDE_SEGMENTS = WATCH_EXCLUDE_SEGMENTS; /** Test-only re-export for #189 reverse import-graph coverage. */ export { ReverseImportGraph, scanFileImports } from "./reverse-import-graph"; /** * Phase 7.0 R2 Agent D β€” classification helper mirroring the in-bundler * `classifyBatch` priority rules WITHOUT the project-specific maps * (serverModuleSet / apiModuleSet / clientModuleToRoute / commonWatchDirs). * * Why a separate export: the in-bundler classifier is a closure over * live state (manifest-derived maps). Tests that want to prove the * **static** parts of the rule table β€” "a `.contract.ts` path is * resource-regen", "a `.env` path is config-reload", "middleware is * api-only" β€” would otherwise have to spin up a real `startDevBundler` * with a tempdir manifest, which slows every assertion to tens of ms. * * Signature and return type match the live classifier so a future * consolidation can swap them without a migration. */ export function _testOnly_classifyFileKind( file: string, options: { commonDirs?: readonly string[] } = {}, ): "config-reload" | "resource-regen" | "api-only" | "common-dir" | "mixed" { const normalized = (function normalize(p: string): string { const resolved = path.resolve(p).replace(/\\/g, "/"); return process.platform === "win32" ? resolved.toLowerCase() : resolved; })(file); if (isConfigOrEnvFile(normalized)) return "config-reload"; if (isResourceOrContractFile(normalized)) return "resource-regen"; if (isRouteMiddlewareFile(normalized)) return "api-only"; if (options.commonDirs) { for (const d of options.commonDirs) { const nd = (function n(p: string): string { const r = path.resolve(p).replace(/\\/g, "/"); return process.platform === "win32" ? r.toLowerCase() : r; })(d); if (normalized === nd || normalized.startsWith(nd + "/")) return "common-dir"; } } return "mixed"; } /** * 개발 λͺ¨λ“œ λ²ˆλ“€λŸ¬ μ‹œμž‘ * 파일 λ³€κ²½ κ°μ‹œ 및 μžλ™ μž¬λΉŒλ“œ */ export async function startDevBundler(options: DevBundlerOptions): Promise { const { rootDir, manifest, onRebuild, onError, onSSRChange, onAPIChange, onConfigReload, onResourceChange, onContentChange, onPackageJsonChange, reactCompiler, watchDirs: customWatchDirs = [], disableDefaultWatchDirs = false, } = options; // 초기 λΉŒλ“œ console.log("πŸ”¨ Initial client bundle build..."); const initialBuild = await buildClientBundles(manifest, rootDir, { mode: "development", minify: false, sourcemap: true, reactCompiler, }); if (initialBuild.success) { console.log(`βœ… Built ${initialBuild.stats.bundleCount} islands`); } else { console.error("⚠️ Initial build had errors:", initialBuild.errors); } // clientModule κ²½λ‘œμ—μ„œ routeId λ§€ν•‘ 생성 const clientModuleToRoute = new Map(); const serverModuleSet = new Set(); // SSR λͺ¨λ“ˆ (page.tsx, layout.tsx) const apiModuleSet = new Set(); // API λͺ¨λ“ˆ (route.ts) const watchDirs = new Set(); const commonWatchDirs = new Set(); // 곡톡 디렉토리 (전체 μž¬λΉŒλ“œ 트리거) for (const route of manifest.routes) { if (route.clientModule) { const absPath = path.resolve(rootDir, route.clientModule); const normalizedPath = normalizeFsPath(absPath); clientModuleToRoute.set(normalizedPath, route.id); // Also register *.client.tsx/ts files in the same directory (#140) // e.g. if clientModule is app/page.island.tsx, also map app/page.client.tsx β†’ same routeId const dir = path.dirname(absPath); const baseStem = path.basename(absPath).replace(/\.(island|client)\.(tsx?|jsx?)$/, ""); for (const ext of [".client.tsx", ".client.ts", ".client.jsx", ".client.js"]) { const clientPath = normalizeFsPath(path.join(dir, baseStem + ext)); if (clientPath !== normalizedPath) { clientModuleToRoute.set(clientPath, route.id); } } // κ°μ‹œν•  디렉토리 μΆ”κ°€ watchDirs.add(dir); } // SSR λͺ¨λ“ˆ 등둝 (page.tsx, layout.tsx) β€” #151 if (route.componentModule) { const absPath = path.resolve(rootDir, route.componentModule); serverModuleSet.add(normalizeFsPath(absPath)); watchDirs.add(path.dirname(absPath)); } if (route.layoutChain) { for (const layoutPath of route.layoutChain) { const absPath = path.resolve(rootDir, layoutPath); serverModuleSet.add(normalizeFsPath(absPath)); watchDirs.add(path.dirname(absPath)); } } // Phase 7.1 R1 Agent A β€” slot dispatch integration (Option B). // // Prior to Phase 7.1 the bundler ignored `.slot.ts(x)` edits: they // hit no classification bucket and silently fell through to a // no-op in `_doBuild`. The CLI's chokidar-backed `watchFSRoutes` // worked around the gap by re-scanning the manifest, but that // path sits OUTSIDE `startDevBundler` and is not exercised by the // HMR matrix (see `packages/core/tests/hmr-matrix/matrix.spec.ts` // `KNOWN_BUNDLER_GAPS`). // // Option B β€” register the slot path into the existing // `serverModuleSet`. Semantically a slot IS an SSR-side data // loader (it runs before `componentModule` on the server to // populate typed props), so co-locating the dispatch with page / // layout is consistent. The existing `onSSRChange(filePath)` path // downstream in `_doBuild` already delivers the right signal β€” // the CLI's `handleSSRChange` will re-register the route handler // and broadcast a full-reload. // // We also add the slot's directory to `watchDirs` so fs.watch // actually delivers the event. For spec/slots/*.slot.ts the // `slotsDir` block below already covers this, but user-authored // colocated slots (e.g. `app/page.slot.ts`) live in app/ which is // picked up via the page's `watchDirs.add(path.dirname(absPath))` // line above β€” still, making the slot add explicit here keeps // the dispatch path honest against future manifest topologies. // Phase 7.2 R1 Agent C (H3 / L-03 audit): validate slotModule // path BEFORE it contributes to serverModuleSet / watchDirs. // // Before 7.2 the code trusted `route.slotModule` verbatim and a // tampered manifest with `slotModule: "../../../etc/passwd"` would // pollute `watchDirs` with directories outside the project root. // Downstream code (`bundledImport`, `registerHandlers`) already // ignored the raw path, but the defense-in-depth cost is tiny so // we reject obviously unsafe shapes here and keep the fs.watch // surface inside the project tree. // // Allowed shapes (matches the bundler's own output conventions): // - `spec/slots/.slot.ts(x)` (auto-linked, fs-routes) // - `app/**/.slot.ts(x)` (colocated user slots) // - `[param]` brackets for dynamic routes are preserved // // Rejected shapes: // - absolute paths (leading `/` or Windows `C:\`) // - `..` anywhere in the path (traversal) // - backslashes (fs-routes emits forward-slash only) // - any char outside a conservative allowlist // // See `docs/security/phase-7-1-audit.md` Β§L-03. if (route.slotModule) { const SLOT_PATH_REGEX = /^(?:spec\/slots|app)\/[A-Za-z0-9_\-./\[\]]+\.slots?\.tsx?$/; const raw = route.slotModule; let accepted = false; if ( typeof raw === "string" && raw.length > 0 && raw.length <= 512 && !raw.includes("..") && !raw.includes("\\") && !raw.startsWith("/") && !/^[A-Za-z]:/.test(raw) && SLOT_PATH_REGEX.test(raw) ) { const absPath = path.resolve(rootDir, raw); // Belt-and-suspenders: canonicalized path must remain inside rootDir. const rootWithSep = path.resolve(rootDir) + path.sep; if (absPath.startsWith(rootWithSep) || absPath === path.resolve(rootDir)) { serverModuleSet.add(normalizeFsPath(absPath)); watchDirs.add(path.dirname(absPath)); accepted = true; } } if (!accepted) { // eslint-disable-next-line no-console console.warn( `[Mandu] slotModule rejected for route "${route.id}": ${raw}. ` + `Expected (spec/slots|app)/.../.slot.ts(x) with no '..' or absolute prefix.`, ); } } // Track API route modules for hot-reload if (route.kind === "api" && route.module) { const absPath = path.resolve(rootDir, route.module); apiModuleSet.add(normalizeFsPath(absPath)); watchDirs.add(path.dirname(absPath)); } // Track metadata-route modules so edits to `app/sitemap.ts` // etc. trigger the same hot-reload pipeline as API routes. if (route.kind === "metadata" && route.module) { const absPath = path.resolve(rootDir, route.module); apiModuleSet.add(normalizeFsPath(absPath)); watchDirs.add(path.dirname(absPath)); } } // spec/slots 디렉토리도 μΆ”κ°€ const slotsDir = path.join(rootDir, "spec", "slots"); try { await fs.promises.access(slotsDir); watchDirs.add(slotsDir); } catch { // slots 디렉토리 μ—†μœΌλ©΄ λ¬΄μ‹œ } // Phase 7.0 R2 Agent D β€” spec/contracts and spec/resources directories. // // Pre-R2 behavior: these directories were NOT watched. Editing a Zod // schema (`spec/contracts/foo.contract.ts`) or resource definition // (`spec/resources/user.resource.ts`) required a manual dev-server // restart, because `classifyBatch` had no category for them and // `onSSRChange`/`onAPIChange` didn't fire. We add the directories to // the main watch set so the existing fs.watch dispatcher delivers // events; `classifyBatch` then routes them to `resource-regen`. const contractsDir = path.join(rootDir, "spec", "contracts"); try { await fs.promises.access(contractsDir); watchDirs.add(contractsDir); } catch { // Contracts directory is optional β€” not all projects use Zod contracts. } const resourcesDir = path.join(rootDir, "spec", "resources"); try { await fs.promises.access(resourcesDir); watchDirs.add(resourcesDir); } catch { // Resources directory is optional β€” projects without Resource-Centric // layer simply never hit this path. } // Issue #242 β€” content collections (MDX/MD/YAML/JSON) directory. The // Collection API caches scan results in-process, so adding a new // `content/docs/**/foo.mdx` at runtime is invisible until we invalidate // the cache. The `content/` directory is the conventional root used by // `defineCollection({ path: "content/..." })`; projects that put // collection roots elsewhere simply won't match and fall back to a full // manual restart, which is the prior behaviour. const contentDir = path.join(rootDir, "content"); try { await fs.promises.access(contentDir); watchDirs.add(contentDir); } catch { // No content/ directory β€” most projects without docs / blogs. } // 곡톡 μ»΄ν¬λ„ŒνŠΈ 디렉토리 μΆ”κ°€ (κΈ°λ³Έ + μ»€μŠ€ν…€) const commonDirsToCheck = disableDefaultWatchDirs ? customWatchDirs : [...DEFAULT_COMMON_DIRS, ...customWatchDirs]; const addCommonDir = async (dir: string): Promise => { const absPath = path.isAbsolute(dir) ? dir : path.join(rootDir, dir); try { const stat = await fs.promises.stat(absPath); const watchPath = stat.isDirectory() ? absPath : path.dirname(absPath); await fs.promises.access(watchPath); commonWatchDirs.add(watchPath); watchDirs.add(watchPath); } catch { // 디렉토리 μ—†μœΌλ©΄ λ¬΄μ‹œ } }; for (const dir of commonDirsToCheck) { await addCommonDir(dir); } // #189 β€” reverse import-graph for transitive invalidation. // // Built-time index of `importee -> Set` edges for every // known SSR / API / client / common-dir root. On an unknown-file // change (a leaf utility that none of our sets recognize) we walk // the transitive importer closure and re-dispatch the change // against every ancestor that IS known β€” so a deep edit to // `app/_utils/translations/ko.ts` still re-evaluates the barrel // in `app/_utils/translations/index.ts` that imported it. // // The initial population is best-effort (filesystem scan may // surface typed-only stubs that have no source yet); misses simply // keep the legacy "silent drop" behavior so no project is made // worse by turning this on. const reverseGraph = new ReverseImportGraph(); let reverseGraphSeedPromise: Promise | null = null; /** * Re-scan a single file's imports and update its row in the * reverse graph. Called on startup for every known root, and * again each time a file change is dispatched so the graph * tracks refactors (an import being added / removed) in real * time. * * Errors are swallowed β€” a missing file / permission issue must * not break the HMR dispatch for the rest of the project. */ const refreshReverseGraphEdges = async (filePath: string): Promise => { try { const imports = await scanFileImports(filePath); reverseGraph.update(filePath, imports); } catch { // scanFileImports already swallows fs errors; this extra guard // catches anything pathological (e.g. a symlink loop). } }; /** * Populate the reverse graph transitively from every known root. * * The BFS walks each root's outgoing imports, scans every * discovered intermediate, and keeps going until the frontier is * empty or we hit the depth cap. Without this transitive seed, * a deep edit (root -> barrel -> leaf) would see * `reverseGraph.directImporters(leaf) === {}` because only the * root's edges got recorded β€” exactly the bug #189 describes. * * The walk is I/O-bound but bounded: each node is visited at most * once (`visited` set) and we cap the seeding at * `DEFAULT_MAX_CLOSURE_DEPTH` hops to keep startup cost predictable * on projects with dense import graphs. Subsequent edits still * extend the graph via `refreshReverseGraphEdges` in `_doBuild`. */ const seedReverseGraph = async (): Promise => { const roots = new Set(); for (const rootPath of serverModuleSet) roots.add(rootPath); for (const rootPath of apiModuleSet) roots.add(rootPath); for (const rootPath of clientModuleToRoute.keys()) roots.add(rootPath); const visited = new Set(); let frontier = Array.from(roots); for ( let depth = 0; depth < DEFAULT_MAX_CLOSURE_DEPTH && frontier.length > 0; depth++ ) { // Scan the current frontier in parallel β€” these are independent // file reads. The scan returns the RESOLVED absolute importee // paths, which become the next frontier. const scans = await Promise.all( frontier.map(async (filePath) => { if (visited.has(filePath)) return []; visited.add(filePath); try { const imports = await scanFileImports(filePath); reverseGraph.update(filePath, imports); return imports; } catch { return []; } }), ); const next: string[] = []; for (const group of scans) { for (const dep of group) { if (!visited.has(dep)) next.push(dep); } } frontier = next; } }; // 파일 κ°μ‹œ μ„€μ • const watchers: fs.FSWatcher[] = []; /** * B6 fix β€” per-file debounce Map (Phase 7.0 R1 Agent A). * * Pre-B6 behavior: a single module-scope `debounceTimer` was cleared on EVERY * fs event. Two rapid events on different files within `WATCHER_DEBOUNCE` * (100 ms) therefore dropped the earlier one. B6 gives each file its own * timer so an edit to file A does not cancel a pending edit to file B. * * Lifecycle: timers are created by `scheduleFileChange`, cleared on flush or * on `close()`. We call `.delete(key)` on flush to keep the Map bounded β€” * no leak from editing a single file repeatedly. * * Phase 17 β€” upgraded to `LRUCache` with an `onEvict` hook so * (a) a pathological watcher (millions of distinct files) cannot leak * (b) evicted entries still get `clearTimeout` called (no dangling refs) * (c) the size is registered with `/_mandu/metrics` * Max 2000 entries is generous for even large monorepos (each entry is a * single in-flight debounce token; flush cycle is 100 ms). */ const perFileTimers = new LRUCache>({ maxSize: 2000, onEvict: (_key, timer) => clearTimeout(timer), }); registerCacheSize("perFileTimers", () => perFileTimers.size); /** * B2 fix β€” multi-file pending build queue (Phase 7.0 R1 Agent A). * * Pre-B2 behavior: `pendingBuildFile: string | null` β€” the second and third * rapid-fire changes overwrote each other and were silently dropped. B2 uses * a Set so EVERY changed file during an in-flight build is retained and * flushed together after completion. Coalesce by `kind` to issue at most one * `buildClientBundles` call per batch when possible. */ const pendingBuildSet = new Set(); // λ™μ‹œ λΉŒλ“œ λ°©μ§€ (#121): λΉŒλ“œ 쀑에 λ³€κ²½ λ°œμƒ μ‹œ λ‹€μŒ λΉŒλ“œ λŒ€κΈ° let isBuilding = false; /** * Paths already known to be inside a common directory, cached to avoid * repeating prefix checks for noisy watchers (IDE autosave bursts). */ const isInCommonDir = (filePath: string): boolean => { const normalizedFile = normalizeFsPath(filePath); for (const commonDir of commonWatchDirs) { const normalizedCommon = normalizeFsPath(commonDir); if ( normalizedFile === normalizedCommon || normalizedFile.startsWith(normalizedCommon + "/") ) { return true; } } return false; }; /** * Classify a batched `Set` of changed files for B2 coalescing. * * Kept simple on purpose β€” `_doBuild` downstream re-checks fine-grained * routing (clientModule / serverModule / API), so we only need the coarse * category used by the hmr-types contract. */ const classifyBatch = (files: readonly string[]): CoalescedChange["kind"] => { let hasCommon = false; let hasSsr = false; let hasApi = false; let hasIsland = false; let hasCss = false; // Phase 7.0 R2 Agent D β€” new classification bits. These are tracked // alongside the existing categories so a batch that mixes a config // save with an island edit still surfaces the high-priority signal // (config-reload always wins β€” a restart invalidates everything // anyway). let hasConfigReload = false; let hasResourceRegen = false; // Issue #242 β€” content collection edits (MDX/MD/YAML/JSON under // `content/`). Caught here so a new file triggers `invalidateAll` + // full-reload instead of falling through to "unknown" and never // reaching the Collection cache. let hasContent = false; for (const file of files) { const normalized = normalizeFsPath(file); // D β€” config/env files trump everything else. A restart subsumes // any other pending work so we can flag and continue. if (isConfigOrEnvFile(normalized)) { hasConfigReload = true; continue; } // D β€” contract/resource schema files are code-gen inputs. A // `.resource.ts` edit must re-run the generator; a `.contract.ts` // edit reshapes the Zod validator in SSR handlers. Both are // routed through `resource-regen`. if (isResourceOrContractFile(normalized)) { hasResourceRegen = true; continue; } // Issue #242 β€” content-collection sources. Anything under the // project's `content/` directory that's plausibly an entry file. // The Collection API accepts `.md`/`.mdx` for MDX entries and // `.json`/`.yaml`/`.yml` for data collections β€” cover all four. if ( normalized.includes("/content/") && /\.(mdx?|ya?ml|json)$/.test(normalized) ) { hasContent = true; continue; } if (normalized.endsWith(".css")) { hasCss = true; continue; } if (isInCommonDir(file)) { hasCommon = true; continue; } // D β€” per-route `middleware.ts` is treated as an API-level change // (the api-only rebuild path re-registers handlers, which is // exactly what a middleware change needs). We fall THROUGH to // the existing api category so coalescing logic is unchanged. if (isRouteMiddlewareFile(normalized)) { hasApi = true; continue; } if (apiModuleSet.has(normalized)) { hasApi = true; continue; } if (serverModuleSet.has(normalized)) { hasSsr = true; continue; } if (clientModuleToRoute.has(normalized)) { hasIsland = true; continue; } if ( file.endsWith(".client.ts") || file.endsWith(".client.tsx") || file.endsWith(".island.tsx") || file.endsWith(".island.ts") ) { hasIsland = true; } } // Phase 7.0 R2 Agent D β€” priority gates. // // 1. `config-reload` beats everything. A process restart will pick // up all other changes on the next boot; there's no value in // rebuilding before we throw the process away. // 2. `resource-regen` beats common-dir because code-gen artifacts // feed into common-dir files β€” running them in reverse order // would cause a stale rebuild. if (hasConfigReload) return "config-reload"; if (hasResourceRegen) return "resource-regen"; // Issue #242 β€” content changes are a leaf category: they never // produce code changes, only cache invalidation + a browser refresh. // Rank them above common-dir so a mixed batch that touches both // still reaches the content path (otherwise common-dir would // rebuild but the collection cache would stay stale). if (hasContent) return "content-change"; // Common-dir dominates β€” it already fans out to every island + SSR // registry. No point in double-classifying "mixed" when a fan-out fix // obsoletes the individual changes. if (hasCommon) return "common-dir"; const categories = [hasSsr, hasApi, hasIsland, hasCss].filter(Boolean).length; if (categories === 0) return "mixed"; if (categories > 1) return "mixed"; if (hasSsr) return "ssr-only"; if (hasApi) return "api-only"; if (hasIsland) return "islands-only"; if (hasCss) return "css-only"; return "mixed"; }; /** * Flush the pending build queue as a single coalesced batch. Prefers a * common-dir path when any file in the batch triggers one β€” that already * fans out to every island + SSR registry invalidation, so processing the * other files individually would be wasted work. * * Called by `handleFileChange`'s retry loop when `pendingBuildSet` is * non-empty; also safe to call directly from watchers if the queue contract * evolves. */ const flushPendingBatch = async (): Promise => { if (pendingBuildSet.size === 0) return; const files = Array.from(pendingBuildSet); pendingBuildSet.clear(); const kind = classifyBatch(files); // Phase 7.0 R2 Agent D β€” config-reload: fire ONCE and return. Once a // restart has been requested the rest of the batch is moot. if (kind === "config-reload") { const configFile = files.find((f) => isConfigOrEnvFile(normalizeFsPath(f))) ?? files[0]; await handleConfigReload(configFile); return; } // Phase 7.0 R2 Agent D β€” resource-regen: coalesce to ONE generator // invocation. If 5 `*.resource.ts` saves arrive in one debounce // window we only want `generateResourceArtifacts` run per distinct // schema; the coalesce helper dedupes by normalized path. if (kind === "resource-regen") { const resourceFiles = files.filter((f) => isResourceOrContractFile(normalizeFsPath(f)), ); await handleResourceRegenBatch(resourceFiles); return; } // Issue #242 β€” content collection changes. Invalidate the shared // Collection cache and notify the optional callback; no rebuild // required. One invocation per debounce window. if (kind === "content-change") { const contentFiles = files.filter((f) => /\.(mdx?|ya?ml|json)$/.test(normalizeFsPath(f)), ); await handleContentChange(contentFiles); return; } // Common-dir dominates: one full-reload-adjacent rebuild covers everyone. if (kind === "common-dir") { await handleFileChange(files.find((f) => isInCommonDir(f)) ?? files[0]); return; } // Otherwise fan out. Each individual handleFileChange is idempotent β€” // if someone edits 4 siblings the build semaphore serializes them, but // none is dropped. for (const file of files) { try { await handleFileChange(file); } catch (retryError) { console.error( "[Mandu HMR] batch flush error:", retryError instanceof Error ? retryError.message : String(retryError), ); } } }; /** * Phase 7.0 R2 Agent D β€” dispatch a single config/env change. * * Fires `onConfigReload` exactly once per batch. The CLI wires this to * `restartDevServer()` (`packages/cli/src/commands/dev.ts`) β€” we don't * perform the restart here because the bundler doesn't own the HTTP * server lifecycle. * * Wrapped in `withPerf(HMR_PERF.FILE_DETECT)` so `MANDU_PERF=1` can * attribute config-save latency β€” but the end-to-end "saw save β†’ * server ready" marker is owned by the CLI (it knows the restart * walltime). No REBUILD_TOTAL marker here β€” the usual rebuild is * replaced by a full restart. */ const handleConfigReload = async (filePath: string): Promise => { if (!onConfigReload) { console.log( `[Mandu HMR] ${path.basename(filePath)} changed β€” restart required`, ); return; } mark(HMR_PERF.FILE_DETECT); measure(HMR_PERF.FILE_DETECT, HMR_PERF.FILE_DETECT); try { await Promise.resolve(onConfigReload(filePath)); } catch (err) { console.error( `[Mandu HMR] config-reload callback threw:`, err instanceof Error ? err.message : String(err), ); } }; /** * Phase 7.0 R2 Agent D β€” batch-dispatch resource/contract changes. * * Each distinct file fires `onResourceChange` once. We intentionally * call the callback per-file (not once per batch) because the consumer * typically needs to: * 1. `parseResourceSchema(filePath)` β€” file-scoped * 2. `generateResourceArtifacts(parsed, opts)` β€” file-scoped * 3. re-register the route handlers that depend on the artifacts * * After all callbacks complete we fire the existing * `onSSRChange(SSR_CHANGE_WILDCARD)` once so the SSR registry picks up * the regenerated artifacts. This mirrors how common-dir changes * already drive the SSR reload path. */ const handleResourceRegenBatch = async (files: readonly string[]): Promise => { if (files.length === 0) return; const callback = onResourceChange; if (callback) { await withPerf(HMR_PERF.SSR_HANDLER_RELOAD, async () => { // Process sequentially β€” multiple concurrent `generateResourceArtifacts` // racing on the same `.mandu/generated/` tree is a known foot-gun // (Bun.write is atomic per file, but the generator writes 4-5 // sibling files per resource and we don't want partial updates // visible to a concurrent SSR handler re-register). for (const file of files) { try { await Promise.resolve(callback(file)); } catch (err) { console.error( `[Mandu HMR] resource-change callback threw for ${path.basename(file)}:`, err instanceof Error ? err.message : String(err), ); } } }); } else { console.log( `[Mandu HMR] ${files.length} resource/contract file(s) changed β€” no handler registered`, ); } // Fire an SSR invalidation so the routes that consume the regenerated // contracts pick up the new Zod schemas. Same signal as common-dir β€” // `ssrChangeQueue` in the CLI serializes this against the resource- // change callback above. if (onSSRChange) { try { await Promise.resolve(onSSRChange(SSR_CHANGE_WILDCARD)); } catch (err) { console.error( `[Mandu HMR] SSR invalidation after resource regen threw:`, err instanceof Error ? err.message : String(err), ); } } }; /** * Issue #242 β€” dispatch a batch of content collection changes. * * Clears every registered `Collection`'s in-memory cache via * `invalidateAllCollections()` and then invokes `onContentChange` once * per file so the CLI can broadcast a full browser reload. Dynamic * import keeps the bundler module tree free of a hard content-layer * dependency (projects without `@mandujs/core/content` in the import * closure simply get a no-op). */ const handleContentChange = async (files: readonly string[]): Promise => { if (files.length === 0) return; try { const mod: typeof __ManduContentCollectionTypes0 = await import("../content/collection"); if (typeof mod.invalidateAllCollections === "function") { mod.invalidateAllCollections(); } } catch { // Content layer not in the closure β€” harmless. } if (onContentChange) { for (const file of files) { try { await Promise.resolve(onContentChange(file)); } catch (err) { console.error( `[Mandu HMR] content-change callback threw for ${path.basename(file)}:`, err instanceof Error ? err.message : String(err), ); } } } else { console.log( `[Mandu HMR] ${files.length} content file(s) changed β€” collection cache invalidated`, ); } }; const handleFileChange = async (changedFile: string): Promise => { // λ™μ‹œ λΉŒλ“œ λ°©μ§€ (#121) β€” B2 κ°•ν™”: λΉŒλ“œ 쀑이면 Set에 μΆ”κ°€ (drop λ°©μ§€). if (isBuilding) { pendingBuildSet.add(changedFile); return; } // Phase 7.0 R2 Agent D β€” pre-dispatch for config/resource/package.json. // // These go through the SAME per-file debounce (scheduleFileChange β†’ // handleFileChange) but bypass `_doBuild` because they don't produce // a client bundle. We route them BEFORE setting `isBuilding` so the // resource-regen callback is free to enqueue subsequent island edits // while it runs β€” the callback may itself trigger a generator that // touches files, and we do not want a recursive isBuilding deadlock. const normalized = normalizeFsPath(changedFile); if (isConfigOrEnvFile(normalized)) { await handleConfigReload(changedFile); return; } if (isResourceOrContractFile(normalized)) { await handleResourceRegenBatch([changedFile]); return; } if ( normalized.includes("/content/") && /\.(mdx?|ya?ml|json)$/.test(normalized) ) { await handleContentChange([changedFile]); return; } if (isPackageJsonFile(normalized)) { // Advisory notification only β€” a `package.json` save on npm install // fires multiple times in <100 ms, so auto-restart would loop. The // callback prints a hint but takes no action. if (onPackageJsonChange) { try { onPackageJsonChange(changedFile); } catch (err) { console.error( `[Mandu HMR] package-json callback threw:`, err instanceof Error ? err.message : String(err), ); } } else { console.log( `[Mandu HMR] package.json changed β€” run 'r' to restart when dependencies settle`, ); } return; } isBuilding = true; mark("dev:rebuild"); mark(HMR_PERF.REBUILD_TOTAL); try { await _doBuild(changedFile); } finally { measure("dev:rebuild", "dev:rebuild"); measure(HMR_PERF.REBUILD_TOTAL, HMR_PERF.REBUILD_TOTAL); isBuilding = false; // B2: λŒ€κΈ° 쀑인 λͺ¨λ“  νŒŒμΌμ„ batch둜 flush. if (pendingBuildSet.size > 0) { try { await flushPendingBatch(); } catch (retryError) { console.error( `❌ Retry build error:`, retryError instanceof Error ? retryError.message : String(retryError), ); console.log(` ⏳ Waiting for next file change to retry...`); } } } }; /** * Per-file debounce scheduler (B6 fix). * * Creates or restarts ONE timer keyed by the normalized path. The timer * fires `handleFileChange` after `WATCHER_DEBOUNCE` quiet time. If the same * file fires again within the window, we reset only that file's timer β€” a * second file keeps its own timeline. * * Errors from the scheduled handler are caught here to prevent an * unhandled promise rejection from killing the watcher loop (#10). */ const scheduleFileChange = (fullPath: string): void => { const key = normalizeFsPath(fullPath); const existing = perFileTimers.get(key); if (existing) clearTimeout(existing); const timer = setTimeout(() => { perFileTimers.delete(key); mark(HMR_PERF.DEBOUNCE_FLUSH); measure(HMR_PERF.DEBOUNCE_FLUSH, HMR_PERF.DEBOUNCE_FLUSH); handleFileChange(fullPath).catch((err) => { console.error( "[Mandu HMR] file-change handler error:", err instanceof Error ? err.message : String(err), ); }); }, TIMEOUTS.WATCHER_DEBOUNCE); perFileTimers.set(key, timer); }; /** * #189 β€” dispatch an unknown-file change against every known root * that transitively imports it. * * The reverse graph answers "which SSR / API / client modules end * up depending on this file?" via a bounded BFS closure. Each * matched root is re-dispatched through the SAME callbacks a * direct change would fire (`onSSRChange`, `onAPIChange`, or an * island rebuild), so downstream behavior is identical to a * direct edit. This closes the "transitive ESM cache" gap * described in issue #189 by making sure every ancestor that * consumed the leaf gets its bundle rebuilt / handler re- * registered. * * Returns the number of roots actually dispatched so the caller * can decide whether to log a "no transitive importers" line or * stay silent. Dispatch is idempotent β€” each root is touched at * most once per change via the `dispatched` set. */ const dispatchByTransitiveImporters = async ( changedFile: string, ): Promise => { const absChanged = path.resolve(rootDir, changedFile); if (reverseGraphSeedPromise) { await reverseGraphSeedPromise; } // Refresh the changed file's OWN imports first. A leaf edit can // legitimately add or remove imports (e.g. switching a barrel // from `./en` to `./ko`); the reverse graph must track that so // the NEXT unknown-file change uses the correct set. await refreshReverseGraphEdges(absChanged); const importers = reverseGraph.transitiveImporters( absChanged, DEFAULT_MAX_CLOSURE_DEPTH, ); if (importers.size === 0) return 0; // Partition importers by which known-root bucket they belong // to. A single file can be in multiple buckets (e.g. a shared // `.client.tsx` that is also referenced by a server module) β€” // iterate in priority order: SSR first (heaviest signal), then // API, then client islands. const ssrRoots: string[] = []; const apiRoots: string[] = []; const islandRoots: Array<{ routeId: string; path: string }> = []; for (const importerAbs of importers) { if (serverModuleSet.has(importerAbs)) { ssrRoots.push(importerAbs); continue; } if (apiModuleSet.has(importerAbs)) { apiRoots.push(importerAbs); continue; } const islandRouteId = clientModuleToRoute.get(importerAbs); if (islandRouteId) { islandRoots.push({ routeId: islandRouteId, path: importerAbs }); } } const totalMatched = ssrRoots.length + apiRoots.length + islandRoots.length; if (totalMatched === 0) return 0; const relFile = path.relative(rootDir, absChanged).replace(/\\/g, "/"); console.log( `\nπŸ”„ ${path.basename(changedFile)} changed β€” invalidating ${totalMatched} transitive importer(s) (${relFile})`, ); // SSR: fire once per distinct importer path. if (onSSRChange) { for (const ssrRoot of ssrRoots) { try { await Promise.resolve(onSSRChange(ssrRoot)); } catch (err) { console.error( "[Mandu HMR] transitive onSSRChange threw:", err instanceof Error ? err.message : String(err), ); } } } // API: same pattern. if (onAPIChange) { for (const apiRoot of apiRoots) { try { await Promise.resolve(onAPIChange(apiRoot)); } catch (err) { console.error( "[Mandu HMR] transitive onAPIChange threw:", err instanceof Error ? err.message : String(err), ); } } } // Islands: coalesce to a single build call when multiple routes // share the changed file. `buildClientBundles` with a // `targetRouteIds` list already dedupes internally. if (islandRoots.length > 0) { const targetIds = Array.from(new Set(islandRoots.map((r) => r.routeId))); const startTime = performance.now(); try { const result = await buildClientBundles(manifest, rootDir, { mode: "development", minify: false, sourcemap: true, targetRouteIds: targetIds, reactCompiler, }); const buildTime = performance.now() - startTime; if (result.success) { console.log( `βœ… Rebuilt ${targetIds.length} island(s) in ${buildTime.toFixed(0)}ms`, ); for (const targetId of targetIds) { onRebuild?.({ routeId: targetId, success: true, buildTime, }); } } else { console.error("❌ Transitive island rebuild failed:", result.errors); for (const targetId of targetIds) { onRebuild?.({ routeId: targetId, success: false, buildTime, error: result.errors.join(", "), }); } } } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); console.error("❌ Transitive island rebuild error:", err.message); for (const { routeId: targetId } of islandRoots) { onError?.(err, targetId); } } } return totalMatched; }; const _doBuild = async (changedFile: string) => { const normalizedPath = normalizeFsPath(changedFile); // #189 β€” refresh the changed file's imports so the reverse graph // tracks refactors regardless of which dispatch path fires below. // Fire-and-forget because the scan is I/O-bound and the dispatch // path cannot stall on it. void refreshReverseGraphEdges(path.resolve(rootDir, changedFile)); // 곡톡 μ»΄ν¬λ„ŒνŠΈ 디렉토리 λ³€κ²½ β†’ Island만 μž¬λΉŒλ“œ + SSR λ ˆμ§€μŠ€νŠΈλ¦¬ invalidate (#184, #185) if (isInCommonDir(changedFile)) { console.log(`\nπŸ”„ Common file changed: ${path.basename(changedFile)}`); console.log(` Rebuilding islands (framework bundles skipped)...`); const startTime = performance.now(); try { // #185: framework λ²ˆλ“€ (runtime/router/vendor/devtools) μŠ€ν‚΅ β€” μ‚¬μš©μž μ½”λ“œ λ³€κ²½ μ‹œ λΆˆν•„μš” const result = await buildClientBundles(manifest, rootDir, { mode: "development", minify: false, sourcemap: true, skipFrameworkBundles: true, reactCompiler, }); const buildTime = performance.now() - startTime; if (result.success) { // #184: common dir 변경은 SSR λͺ¨λ“ˆ μΊμ‹œ invalidation이 ν•„μš” β€” wildcard μ‹œκ·Έλ„ // λΉŒλ“œ μ„±κ³΅ν•œ κ²½μš°μ—λ§Œ SSR λ ˆμ§€μŠ€νŠΈλ¦¬λ₯Ό clear (μ‹€νŒ¨ μ‹œ λ§ˆμ§€λ§‰ good state μœ μ§€) // 주의: Bun의 transitive ESM μΊμ‹œλŠ” ν”„λ‘œμ„ΈμŠ€ 레벨이라 이 μ‹œκ·Έλ„λ§ŒμœΌλ‘œλŠ” // `src/shared/**`을 transitiveν•˜κ²Œ importν•˜λŠ” SSR λͺ¨λ“ˆκΉŒμ§€ μ™„μ „νžˆ κ°±μ‹ λ˜μ§€ μ•ŠμŒ. // μ§„μ§œ 해결은 subprocess/worker 기반 SSR eval이 ν•„μš” (follow-up 이슈). if (onSSRChange) { try { await Promise.resolve(onSSRChange(SSR_CHANGE_WILDCARD)); } catch (ssrError) { console.warn(`⚠️ SSR invalidation failed:`, ssrError instanceof Error ? ssrError.message : ssrError); } } console.log(`βœ… Rebuilt ${result.stats.bundleCount} islands in ${buildTime.toFixed(0)}ms`); onRebuild?.({ routeId: "*", // 전체 μž¬λΉŒλ“œ ν‘œμ‹œ success: true, buildTime, }); } else { console.error(`❌ Build failed:`, result.errors); console.log(` ⏳ SSR registry not invalidated (keeping last good state)`); onRebuild?.({ routeId: "*", success: false, buildTime, error: result.errors.join(", "), }); } } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); console.error(`❌ Build error:`, err.message); console.log(` ⏳ Waiting for next file change to retry...`); onError?.(err, "*"); } return; } // clientModule λ§€ν•‘μ—μ„œ routeId μ°ΎκΈ° let routeId = clientModuleToRoute.get(normalizedPath); // Fallback for *.client.tsx/ts: find route whose clientModule is in the same directory (#140) // basename matching (e.g. "page" !== "index") is unreliable β€” use directory-based matching instead if (!routeId && (changedFile.endsWith(".client.ts") || changedFile.endsWith(".client.tsx"))) { const changedDir = normalizeFsPath(path.dirname(path.resolve(rootDir, changedFile))); const matchedRoute = manifest.routes.find((r) => { if (!r.clientModule) return false; const routeDir = normalizeFsPath(path.dirname(path.resolve(rootDir, r.clientModule))); return routeDir === changedDir; }); if (matchedRoute) { routeId = matchedRoute.id; } } if (!routeId) { // SSR λͺ¨λ“ˆ λ³€κ²½ 감지 (page.tsx, layout.tsx) β€” #151 if (onSSRChange && serverModuleSet.has(normalizedPath)) { console.log(`\nπŸ”„ SSR file changed: ${path.basename(changedFile)}`); // Refresh the changed file's imports so the reverse graph // tracks refactors (a new import added to an SSR module). await refreshReverseGraphEdges(path.resolve(rootDir, changedFile)); await Promise.resolve(onSSRChange(normalizedPath)); return; } // API λͺ¨λ“ˆ λ³€κ²½ 감지 (route.ts) if (onAPIChange && apiModuleSet.has(normalizedPath)) { console.log(`\nπŸ”„ API route changed: ${path.basename(changedFile)}`); await refreshReverseGraphEdges(path.resolve(rootDir, changedFile)); await Promise.resolve(onAPIChange(normalizedPath)); return; } // Phase 7.0 R2 Agent D β€” route middleware change. // `app/**/middleware.ts` isn't in apiModuleSet (not registered as a // route handler) but reuses the API reload path: the underlying // `registerManifestHandlers` re-imports middleware via the bundled // import chain. Falls through to `onAPIChange` so the CLI can // reuse its existing `handleAPIChange` plumbing. if (onAPIChange && isRouteMiddlewareFile(normalizedPath)) { console.log(`\nπŸ”„ Middleware changed: ${path.basename(changedFile)}`); await refreshReverseGraphEdges(path.resolve(rootDir, changedFile)); await Promise.resolve(onAPIChange(normalizedPath)); return; } // #189 β€” reverse import-graph fallback. // // The changed file matched none of our direct dispatch sets. // Before silently dropping (legacy behavior), walk the // reverse graph to see which known roots transitively import // this file and re-dispatch against each one. This is what // makes a deep leaf edit (e.g. a barrel's static-map entry) // propagate without a manual restart. const dispatched = await dispatchByTransitiveImporters(changedFile); if (dispatched === 0) { // No known importer β€” preserve the legacy silent-drop path // so truly unrelated file changes (editor backups, tmp // files that slipped through the filter) stay cheap. } return; } const route = manifest.routes.find((r) => r.id === routeId); if (!route || !route.clientModule) return; // #189 β€” refresh the client island's outgoing imports so the // reverse graph tracks newly added / removed imports in the // island file itself. Fire-and-forget so it cannot stall the // rebuild. void refreshReverseGraphEdges(path.resolve(rootDir, changedFile)); console.log(`\nπŸ”„ Rebuilding island: ${routeId}`); const startTime = performance.now(); try { // 단일 island만 μž¬λΉŒλ“œ (Runtime/Router/Vendor μŠ€ν‚΅, #122) const result = await buildClientBundles(manifest, rootDir, { mode: "development", minify: false, sourcemap: true, targetRouteIds: [routeId], reactCompiler, }); const buildTime = performance.now() - startTime; if (result.success) { console.log(`βœ… Rebuilt in ${buildTime.toFixed(0)}ms`); onRebuild?.({ routeId, success: true, buildTime, }); } else { console.error(`❌ Build failed:`, result.errors); console.log(` ⏳ Previous bundle preserved. Waiting for next file change to retry...`); onRebuild?.({ routeId, success: false, buildTime, error: result.errors.join(", "), }); } } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); console.error(`❌ Build error:`, err.message); console.log(` ⏳ Previous bundle preserved. Waiting for next file change to retry...`); onError?.(err, routeId); } }; /** * Phase 7.0 R2 Agent D β€” filter predicate for the main recursive * watchers. Centralized so the config-root watcher (below) and the * directory watchers share the same "is this worth dispatching?" * check. Files that match one of the Agent D kinds are accepted even * though they don't end in `.ts`/`.tsx` β€” e.g. `.env`. */ const shouldDispatch = (normalizedFull: string, filename: string): boolean => { // OS / build-artifact exclusions come first β€” we never want a // `node_modules` event to even hit the perf marker. if (isExcludedPath(normalizedFull)) return false; // Existing contract: TS/TSX files in user source are always eligible. // Agent D kinds extend the accept list to non-TS files so `.env` and // `package.json` can surface. if (filename.endsWith(".ts") || filename.endsWith(".tsx")) return true; // Agent D new file kinds. if ( isConfigOrEnvFile(normalizedFull) || isPackageJsonFile(normalizedFull) ) { return true; } return false; }; // 각 디렉토리에 watcher μ„€μ • β€” B1/B6 fix for (const dir of watchDirs) { try { const watcher = fs.watch(dir, { recursive: true }, (event, filename) => { if (!filename) return; const fullPath = path.join(dir, filename); const normalizedFull = normalizeFsPath(fullPath); // B1 fix β€” exclude `node_modules`, `.mandu`, `dist`, `build`, OS files. // Must run on the FULL path (filename alone loses directory context when // `recursive:true` reports a deep subpath). if (!shouldDispatch(normalizedFull, filename)) return; mark(HMR_PERF.FILE_DETECT); measure(HMR_PERF.FILE_DETECT, HMR_PERF.FILE_DETECT); // B6 fix β€” per-file debounce (replaces global single timer). scheduleFileChange(fullPath); }); watchers.push(watcher); } catch { console.warn(`⚠️ Cannot watch directory: ${dir}`); } } /** * Phase 7.0 R2 Agent D β€” project-root watcher for config/env/package.json. * * Why a SEPARATE watcher: * - `fs.watch(rootDir, { recursive: true })` would fire for every file * in the entire tree β€” we only want the root-level config files. * The main per-directory watchers already cover `src/`, `app/`, * `spec/`, etc. * - Non-recursive `fs.watch(rootDir)` fires ONLY for direct children, * which is exactly what `mandu.config.ts`, `.env`, `package.json` * need. * * This watcher lives alongside the others in the `watchers` array so * `close()` tears everything down in one pass. */ try { const rootWatcher = fs.watch(rootDir, { recursive: false }, (event, filename) => { if (!filename) return; const fullPath = path.join(rootDir, filename); const normalizedFull = normalizeFsPath(fullPath); // Shared exclusions β€” even if someone crafts a bizarre `.mandu` // symlink at the project root, the `isExcludedPath` guard catches it. if (isExcludedPath(normalizedFull)) return; // Only the three kinds this watcher owns. A random `.md` or // `tsconfig.json` save at the root must NOT be picked up here // (tsconfig is interesting but needs a separate opt-in β€” outside // this phase's scope). const isConfig = isConfigOrEnvFile(normalizedFull); const isPkg = isPackageJsonFile(normalizedFull); if (!isConfig && !isPkg) return; mark(HMR_PERF.FILE_DETECT); measure(HMR_PERF.FILE_DETECT, HMR_PERF.FILE_DETECT); scheduleFileChange(fullPath); }); watchers.push(rootWatcher); } catch { console.warn(`⚠️ Cannot watch project root for config/env changes: ${rootDir}`); } if (watchers.length > 0) { console.log(`πŸ‘€ Watching ${watchers.length} directories for changes...`); if (commonWatchDirs.size > 0) { const commonDirNames = Array.from(commonWatchDirs) .map(d => (path.relative(rootDir, d) || ".").replace(/\\/g, "/")) .join(", "); console.log(`πŸ“¦ Common dirs (full rebuild): ${commonDirNames}`); } } // #189 β€” seed the reverse import-graph AFTER watchers are wired so // any edit that lands during the scan still triggers `handleFileChange`. // The scan is fire-and-forget; the initial build is already complete // and the first user edit can't race the seed (first event goes // through the 100 ms debounce, by which point the scan has finished // for any realistic project size). reverseGraphSeedPromise = seedReverseGraph().catch((err) => { console.warn( "[Mandu HMR] reverse import-graph seed skipped:", err instanceof Error ? err.message : String(err), ); }); return { initialBuild, reverseGraphReady: reverseGraphSeedPromise, close: () => { // B6: clear all per-file timers to release event-loop refs. // Phase 17 β€” `LRUCache.clear()` fires the registered `onEvict` // (`clearTimeout(timer)`) for every entry before dropping them, // so we no longer need an explicit loop. perFileTimers.clear(); unregisterCacheSize("perFileTimers"); for (const watcher of watchers) { watcher.close(); } }, }; } /** * HMR WebSocket μ„œλ²„ * * Phase 7.0 R1 Agent C: added replay buffer (B8), Vite-compat wire format * broadcast, and `?since=` reconnect handshake. The classic * `broadcast(HMRMessage)` API is kept for existing callers; a second * `broadcastVite(ViteHMRPayload)` channel serves external devtools that * speak the Vite HMR WebSocket protocol. */ export interface HMRServer { /** μ—°κ²°λœ ν΄λΌμ΄μ–ΈνŠΈ 수 */ clientCount: number; /** λͺ¨λ“  ν΄λΌμ΄μ–ΈνŠΈμ—κ²Œ λ©”μ‹œμ§€ 전솑 β€” λ‚΄λΆ€ Mandu 포맷. */ broadcast: (message: HMRMessage) => void; /** * Broadcast a Vite-compat HMR payload. The payload is queued in the * replay buffer so reconnecting clients can resume with `?since=` * and does not need a Mandu-side wrapper shape. External devtools * that speak the Vite HMR protocol consume this directly. */ broadcastVite: (payload: ViteHMRPayload) => HMRReplayEnvelope; /** μ„œλ²„ 쀑지 */ close: () => void; /** μž¬μ‹œμž‘ ν•Έλ“€λŸ¬ 등둝 */ setRestartHandler: (handler: () => Promise) => void; /** * Diagnostics accessor β€” current replay-buffer length and the last * broadcast envelope id. Exposed for unit tests; production code * should use `broadcast` / `broadcastVite`. */ _inspectReplayBuffer: () => { size: number; lastId: number; oldestId: number | null }; } export interface HMRMessage { type: | "connected" | "reload" | "full-reload" // Phase 7.0 β€” Vite-compat escalation path | "update" // Phase 7.0 β€” granular update (js / css) | "invalidate" // Phase 7.0 β€” module requested full reload | "island-update" | "layout-update" | "css-update" | "error" | "ping" | "guard-violation" | "kitchen:file-change" | "kitchen:guard-decision"; data?: { routeId?: string; layoutPath?: string; cssPath?: string; message?: string; timestamp?: number; file?: string; violations?: Array<{ line: number; message: string }>; changeType?: "add" | "change" | "delete"; action?: "approve" | "reject"; ruleId?: string; /** Vite-compat updates array β€” populated when `type === "update"`. */ updates?: Array<{ type: "js-update" | "css-update"; path: string; acceptedPath: string; timestamp: number }>; /** `full-reload` / `invalidate` optional path hint. */ path?: string; /** Last rebuild id assigned by the replay buffer (if any). */ id?: number; }; } /** * HMR WebSocket μ„œλ²„ 생성 * * Phase 7.0 R1 Agent C additions: * - **Replay buffer (B8)**: every `broadcastVite` payload is enqueued with * a monotonic `id`. Clients reconnect with `?since=` and the server * re-sends anything they missed. Buffer is bounded by * `MAX_REPLAY_BUFFER` entries and `REPLAY_MAX_AGE_MS` age β€” older * envelopes are pruned, and too-old `since` values trigger a * `full-reload` as the safe fallback. * - **Vite-compat wire format**: `broadcastVite(ViteHMRPayload)` sends * the byte-equivalent of what Vite would emit, so external devtools * / editor plugins that speak Vite's HMR protocol work unchanged. * - **layout-update**: callers (Agent A's `onSSRChange` path) invoke * `broadcast({ type: "layout-update", ... })` when a `layout.tsx` * changes; the client handler forces a full reload. * * The classic `broadcast(HMRMessage)` API is preserved as the internal * Mandu format; both broadcast channels share the same WebSocket. */ /** * Phase 7.0.S β€” HMR security options (C-01 / C-03 / C-04 defense). * * The dev HMR WebSocket + `/restart` endpoint bind to loopback by default * and reject cross-origin connections. Remote-dev scenarios (container, * VM, tunnel) go through the Phase 7.1+ explicit-token path. */ export interface HMRServerOptions { /** * Network interface to bind. Defaults to "localhost" (loopback only). * Override to "0.0.0.0" ONLY for remote-dev scenarios paired with * explicit `allowedOrigins`. */ hostname?: string; /** * Additional origins (beyond `http://localhost:${port}` and * `http://127.0.0.1:${port}`) that may establish WebSocket connections * or POST /restart. Required when binding to non-loopback. */ allowedOrigins?: readonly string[]; } export function createHMRServer( port: number, options: HMRServerOptions = {}, ): HMRServer { const clients = new Set<{ send: (data: string) => void; close: () => void }>(); const hmrPort = port + PORTS.HMR_OFFSET; const hostname = options.hostname ?? "localhost"; // Build Origin allowlist. Same-origin (main dev server) is always allowed. // Both `localhost` and `127.0.0.1` forms are included because browsers // resolve `localhost` ambiguously (IPv4 vs IPv6) and some OS stacks // return one vs the other. const allowedOrigins = new Set([ `http://localhost:${port}`, `http://127.0.0.1:${port}`, ...(options.allowedOrigins ?? []), ]); let restartHandler: (() => Promise) | null = null; // ─── Replay buffer (B8) ──────────────────────────────────────────────── // // Monotonic counter; resets to 0 on server boot (restart is a full // reload anyway so clients can't meaningfully resume across it). let lastRebuildId = 0; const replayBuffer: HMRReplayEnvelope[] = []; /** Drop envelopes older than `REPLAY_MAX_AGE_MS`. Called opportunistically. */ const pruneOldReplays = (): void => { const cutoff = Date.now() - REPLAY_MAX_AGE_MS; // Buffer is chronological (push-only, shift-from-front), so one-pass prune. while (replayBuffer.length > 0 && replayBuffer[0]!.timestamp < cutoff) { replayBuffer.shift(); } }; /** * Append a Vite payload to the replay buffer. Returns the envelope * that was queued so the caller can inspect its id (used in tests and * by the `broadcast` path to echo the id into the internal message). */ const enqueueReplay = (payload: ViteHMRPayload): HMRReplayEnvelope => { mark(HMR_PERF.HMR_REPLAY_ENQUEUE); lastRebuildId += 1; const envelope: HMRReplayEnvelope = { id: lastRebuildId, timestamp: Date.now(), payload, }; replayBuffer.push(envelope); // Bound by size first (cheap), then by age (slightly more work but // still O(n) amortized across inserts). while (replayBuffer.length > MAX_REPLAY_BUFFER) { replayBuffer.shift(); } pruneOldReplays(); measure(HMR_PERF.HMR_REPLAY_ENQUEUE, HMR_PERF.HMR_REPLAY_ENQUEUE); return envelope; }; /** * Parse `?since=` from the upgrade URL. Returns `null` for missing * or malformed values (negative / non-numeric / NaN). Treating a * malformed value as `null` is the safe choice β€” the client simply * gets the default `connected` handshake. */ const parseSince = (url: URL): number | null => { const raw = url.searchParams.get("since"); if (raw === null || raw === "") return null; const n = Number(raw); if (!Number.isFinite(n) || n < 0) return null; return Math.floor(n); }; /** * Handle the post-upgrade replay flush. The three branches: * * 1. `since === null` β†’ new client, send `connected` only. * 2. `since >= lastRebuildId` β†’ client is already current, send * `connected` and nothing else. * 3. `since < oldestId` β†’ client missed more than the buffer holds, * force a `full-reload`. * 4. otherwise β†’ re-send every envelope with `id > since`. * * The caller (WS `open` handler) only knows the raw `since`; we do * the dispatch here so the logic stays co-located with the buffer. */ const flushReplayToClient = ( ws: { send: (data: string) => void }, since: number | null, ): void => { if (since === null) { ws.send( JSON.stringify({ type: "connected", data: { timestamp: Date.now(), id: lastRebuildId } }), ); return; } // Already caught up β€” nothing to replay but still greet. if (since >= lastRebuildId) { ws.send( JSON.stringify({ type: "connected", data: { timestamp: Date.now(), id: lastRebuildId } }), ); return; } pruneOldReplays(); const oldestId = replayBuffer.length > 0 ? replayBuffer[0]!.id : null; if (oldestId === null || since < oldestId) { // Missed too much β€” force a full reload. mark(HMR_PERF.HMR_REPLAY_FLUSH); ws.send( JSON.stringify({ type: "full-reload", data: { timestamp: Date.now(), message: "replay-buffer-exhausted" }, }), ); measure(HMR_PERF.HMR_REPLAY_FLUSH, HMR_PERF.HMR_REPLAY_FLUSH); return; } // Replay every envelope strictly newer than `since`. mark(HMR_PERF.HMR_REPLAY_FLUSH); ws.send( JSON.stringify({ type: "connected", data: { timestamp: Date.now(), id: lastRebuildId } }), ); for (const env of replayBuffer) { if (env.id <= since) continue; // Wrap in a thin envelope so the client can see the id; keep the // Vite payload verbatim for external consumers. ws.send( JSON.stringify({ type: "vite-replay", data: { id: env.id, timestamp: env.timestamp }, payload: env.payload, }), ); } measure(HMR_PERF.HMR_REPLAY_FLUSH, HMR_PERF.HMR_REPLAY_FLUSH); }; const corsHeaders: Record = { "Access-Control-Allow-Origin": `http://localhost:${port}`, "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", }; // Type parameter carries the `?since=` value from the upgrade // request to the WebSocket `open` handler via Bun's per-connection // data slot. Without the generic, `server.upgrade(req, { data })` // types `data` as `undefined` β€” Bun.serve has no runtime inference. interface WSData { since: number | null; } // Phase 7.0.S β€” per-connection `invalidate` rate limit state. // WeakMap keyed by the WS object so entries are GC'd when the socket // closes; no manual cleanup needed. Per-connection (not global) so one // abusive client cannot DoS the rate limit for legitimate ones. const invalidateCounters = new WeakMap(); const server = Bun.serve({ port: hmrPort, hostname, // Phase 7.0.S C-03 fix: bind to loopback by default. async fetch(req, server) { const url = new URL(req.url); // CORS preflight if (req.method === "OPTIONS") { return new Response(null, { status: 204, headers: corsHeaders }); } // Phase 7.0.S Origin allowlist check (C-01 / C-04 defense). // // CSWSH (Cross-Site WebSocket Hijacking) defense: browsers DO NOT // enforce same-origin for WebSocket connections. Any cross-origin // page that reaches this port (C-03 fix narrows that to loopback) // could open a WS and exfiltrate HMR events or trigger reloads // without this check. // // We accept missing Origin (null / absent) β€” native clients (curl, // test WebSocket connections, CLI devtools) legitimately omit it and // the loopback binding (C-03) is the primary defense for them. const origin = req.headers.get("origin"); if (origin !== null && !allowedOrigins.has(origin)) { return new Response( JSON.stringify({ error: "origin not allowed" }), { status: 403, headers: { ...corsHeaders, "Content-Type": "application/json" } } ); } // POST /restart β†’ μž¬μ‹œμž‘ ν•Έλ“€λŸ¬ 호좜 if (req.method === "POST" && url.pathname === "/restart") { if (!restartHandler) { return new Response( JSON.stringify({ error: "No restart handler registered" }), { status: 503, headers: { ...corsHeaders, "Content-Type": "application/json" } } ); } try { console.log("πŸ”„ Full restart requested from DevTools"); await restartHandler(); return new Response( JSON.stringify({ status: "restarted" }), { headers: { ...corsHeaders, "Content-Type": "application/json" } } ); } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error("❌ Restart failed:", message); return new Response( JSON.stringify({ error: message }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } ); } } // WebSocket μ—…κ·Έλ ˆμ΄λ“œ β€” stash `since` in per-connection data so the // `open` handler has access to it. const since = parseSince(url); if (server.upgrade(req, { data: { since } })) { return; } // 일반 HTTP μš”μ²­μ€ μƒνƒœ λ°˜ν™˜ return new Response( JSON.stringify({ status: "ok", clients: clients.size, port: hmrPort, lastRebuildId, replayBufferSize: replayBuffer.length, }), { headers: { ...corsHeaders, "Content-Type": "application/json" }, } ); }, websocket: { open(ws) { clients.add(ws); // `since` is attached by the upgrade handler (typed via WSData). // It's `null` when the client didn't supply `?since=` (new tab). const since = ws.data?.since ?? null; flushReplayToClient(ws, since); }, close(ws) { clients.delete(ws); }, message(ws, message) { // ν΄λΌμ΄μ–ΈνŠΈλ‘œλΆ€ν„°μ˜ ping 처리 + invalidate μˆ˜μ‹ . try { const data = JSON.parse(String(message)); if (data.type === "ping") { ws.send(JSON.stringify({ type: "pong", data: { timestamp: Date.now() } })); return; } if (data.type === "invalidate") { // Phase 7.0.S β€” per-connection rate limit (C-02 / H-01 defense). // A malicious same-machine process (even with C-01 + C-03 in // place) could flood `invalidate` messages to DoS connected // browsers via broadcast. 10 invalidates per 10-second window // is >100Γ— what legitimate HMR usage produces. const now = Date.now(); let counter = invalidateCounters.get(ws as object); if (!counter || now - counter.windowStart > 10_000) { counter = { count: 0, windowStart: now }; invalidateCounters.set(ws as object, counter); } counter.count += 1; if (counter.count > 10) { // Silent drop β€” do not reply to abusive clients. return; } // Reject oversized fields. 10 KB message / 2 KB moduleUrl is // plenty for diagnostics; anything larger is amplification abuse. if ( (typeof data.message === "string" && data.message.length > 10_000) || (typeof data.moduleUrl === "string" && data.moduleUrl.length > 2_000) ) { return; } // A module called `import.meta.hot.invalidate()` in the // browser. Phase 7.0 v0.1 response: escalate to full reload // on the module that invalidated. Broadcasting through // `broadcastVite` puts it in the replay buffer too so other // tabs observe the same reload. const path = typeof data.moduleUrl === "string" ? data.moduleUrl : undefined; const payload: ViteHMRPayload = { type: "full-reload", path }; const envelope = enqueueReplay(payload); const wire = JSON.stringify({ type: "full-reload", data: { timestamp: envelope.timestamp, id: envelope.id, path, message: typeof data.message === "string" ? data.message : undefined, }, }); for (const client of clients) { try { client.send(wire); } catch { clients.delete(client); } } } } catch { // λ¬΄μ‹œ β€” malformed JSON from the client is never fatal. } }, }, }); console.log(`πŸ”₯ HMR server running on ws://${hostname}:${hmrPort}`); /** * Send a payload string to every connected client, pruning dead * sockets as a side effect. Factored out so `broadcast` and * `broadcastVite` share the fan-out loop exactly. */ const fanout = (payload: string): void => { for (const client of clients) { try { client.send(payload); } catch { clients.delete(client); } } }; return { get clientCount() { return clients.size; }, broadcast: (message: HMRMessage) => { mark(HMR_PERF.HMR_BROADCAST); // For message types that map to a Vite payload, enqueue a replay // envelope so reconnecting clients also see the event. The mapping // is conservative β€” only canonical cases get queued; devtools and // guard-violation events are ephemeral. let envelopeId: number | undefined; if (message.type === "reload" || message.type === "full-reload") { const envelope = enqueueReplay({ type: "full-reload", path: message.data?.path }); envelopeId = envelope.id; } else if (message.type === "island-update" || message.type === "layout-update") { // These are Mandu-internal shapes; record a generic `update` // envelope so reconnecting clients at least know something // changed. Full-fidelity replay of Mandu messages is intentional // future work β€” we'd need a second buffer per payload shape. const envelope = enqueueReplay({ type: "update", updates: [ { type: "js-update", path: message.data?.layoutPath ?? message.data?.routeId ?? "?", acceptedPath: message.data?.layoutPath ?? message.data?.routeId ?? "?", timestamp: Date.now(), }, ], }); envelopeId = envelope.id; } else if (message.type === "css-update") { const envelope = enqueueReplay({ type: "update", updates: [ { type: "css-update", path: message.data?.cssPath ?? "/.mandu/client/globals.css", acceptedPath: message.data?.cssPath ?? "/.mandu/client/globals.css", timestamp: Date.now(), }, ], }); envelopeId = envelope.id; } const outgoing: HMRMessage = envelopeId !== undefined ? { ...message, data: { ...(message.data ?? {}), id: envelopeId } } : message; const payload = JSON.stringify(outgoing); fanout(payload); measure(HMR_PERF.HMR_BROADCAST, HMR_PERF.HMR_BROADCAST); }, broadcastVite: (payload: ViteHMRPayload): HMRReplayEnvelope => { mark(HMR_PERF.HMR_BROADCAST); const envelope = enqueueReplay(payload); // Wire format: wrap with the envelope id so replayed and live // messages are indistinguishable on the client side. External // devtools that only care about the raw Vite payload can read // `payload` directly. const wire = JSON.stringify({ type: "vite", data: { id: envelope.id, timestamp: envelope.timestamp }, payload, }); fanout(wire); measure(HMR_PERF.HMR_BROADCAST, HMR_PERF.HMR_BROADCAST); return envelope; }, close: () => { for (const client of clients) { try { client.close(); } catch { // λ¬΄μ‹œ } } clients.clear(); void server.stop(); }, setRestartHandler: (handler: () => Promise) => { restartHandler = handler; }, _inspectReplayBuffer: () => ({ size: replayBuffer.length, lastId: lastRebuildId, oldestId: replayBuffer.length > 0 ? replayBuffer[0]!.id : null, }), }; } /** * HMR ν΄λΌμ΄μ–ΈνŠΈ 슀크립트 생성 * λΈŒλΌμš°μ €μ—μ„œ μ‹€ν–‰λ˜μ–΄ HMR μ„œλ²„μ™€ μ—°κ²°. * * Phase 7.0 R1 Agent C additions: * - **`?since=` on reconnect**: the client tracks the id of * the last envelope it processed (from `data.id`). On reconnect it * appends `?since=` to the WS URL so the server can replay * anything missed while the socket was down. * - **Vite-compat payload handling**: messages of shape * `{type:"vite", payload:}` and `{type:"vite-replay", payload:<...>}` * are dispatched through the same code path β€” both deliver a Vite * update wrapped with an envelope id. * - **`full-reload` type**: emitted when a module invalidates or the * replay buffer is exhausted; force a full page reload. * - **`layout-update`**: unchanged behavior (full reload) β€” the server * now actually broadcasts this (A's `onSSRChange` path). * - **`import.meta.hot.invalidate()` upstream channel**: the runtime * calls into `window.__MANDU_HMR_SEND__({type:"invalidate", moduleUrl})` * which we forward on the socket. This is the only place the client * sends non-ping frames. * - **Vite event dispatch**: `vite:beforeUpdate` fires before an * `update` or `vite` payload is applied; `vite:afterUpdate` after; * `vite:beforeFullReload` before `full-reload`; `vite:error` for * errors. Listeners are registered in `ManduHot.on()` (runtime). * * Phase 7.2 Agent B additions (HDR β€” Hot Data Revalidation): * - **`slot-refetch` message type**: when a `.slot.ts` file changes, the * CLI side broadcasts `{ type: "slot-refetch", data: { routeId, * slotPath, id, timestamp } }`. The client script checks whether the * current browser location belongs to that `routeId`; if so it fetches * the current URL with `X-Mandu-HDR: 1`, receives JSON loader data, * then calls `window.__MANDU_ROUTER_REVALIDATE__(routeId, loaderData)` * wrapped in `React.startTransition`. Form inputs / scroll / focus * survive because the React tree never unmounts. * - **Fallback semantics**: if the route doesn't match, the router * revalidate hook is missing, the fetch fails, `MANDU_HDR=0` is set, * or any other failure path β€” the client falls back to * `location.reload()`. This preserves the Phase 7.1 "always-safe" * invariant: a broken HDR path never leaves the user on a stale page. * - **`hdr:refetch` perf marker**: fires on successful HDR apply for * Agent F's bench script to aggregate. P95 target ≀150 ms. */ export function generateHMRClientScript(port: number): string { const hmrPort = port + PORTS.HMR_OFFSET; return ` (function() { window.__MANDU_HMR_PORT__ = ${hmrPort}; const HMR_PORT = ${hmrPort}; let ws = null; let reconnectAttempts = 0; // Last envelope id we successfully applied. Used in the ?since= query // on reconnect. Starts at 0 (means "no envelopes seen"); the server // interprets 0 as "replay everything that's still in the buffer". let lastSeenId = 0; const maxReconnectAttempts = ${TIMEOUTS.HMR_MAX_RECONNECT}; const reconnectDelay = ${TIMEOUTS.HMR_RECONNECT_DELAY}; const staleIslands = new Set(); // Vite-compat event listeners. Registered by the runtime hmr-client.ts // via \`window.__MANDU_HMR_EVENT__(event, cb)\`; we fan out here because // dispatchEvent() in the runtime walks every module's listener set, // which the client script cannot directly import. const viteListeners = Object.create(null); window.__MANDU_HMR_EVENT__ = function(event, cb) { if (!viteListeners[event]) viteListeners[event] = new Set(); viteListeners[event].add(cb); return function off() { if (viteListeners[event]) viteListeners[event].delete(cb); }; }; function fireViteEvent(event, payload) { var set = viteListeners[event]; if (!set) return; set.forEach(function(cb) { try { cb(payload); } catch (e) { console.error('[Mandu HMR]', event, 'listener threw:', e); } }); } // Upstream channel: user code calls invalidate() in the runtime, which // asks the client script to push a message back to the server. window.__MANDU_HMR_SEND__ = function(payload) { if (ws && ws.readyState === 1 /* OPEN */) { try { ws.send(JSON.stringify(payload)); } catch (e) { console.error('[Mandu HMR] send failed:', e); } } }; function connect() { try { var qs = lastSeenId > 0 ? '?since=' + lastSeenId : ''; ws = new WebSocket('ws://' + window.location.hostname + ':' + HMR_PORT + '/' + qs); ws.onopen = function() { console.log('[Mandu HMR] Connected' + (lastSeenId > 0 ? ' (since ' + lastSeenId + ')' : '')); reconnectAttempts = 0; }; ws.onmessage = function(event) { try { const message = JSON.parse(event.data); handleMessage(message); } catch (e) { console.error('[Mandu HMR] Invalid message:', e); } }; ws.onclose = function() { console.log('[Mandu HMR] Disconnected'); scheduleReconnect(); }; ws.onerror = function(error) { console.error('[Mandu HMR] Error:', error); }; } catch (error) { console.error('[Mandu HMR] Connection failed:', error); scheduleReconnect(); } } function scheduleReconnect() { if (reconnectAttempts < maxReconnectAttempts) { reconnectAttempts++; var delay = Math.min(reconnectDelay * Math.pow(2, reconnectAttempts - 1), 30000); console.log('[Mandu HMR] Reconnecting in ' + delay + 'ms (' + reconnectAttempts + '/' + maxReconnectAttempts + ')'); setTimeout(connect, delay); } } /** * Update lastSeenId from a message's envelope id. Only accept * monotonically increasing values so out-of-order replay (shouldn't * happen, but be defensive) can't move us backwards. */ function recordEnvelopeId(message) { var id = message && message.data && message.data.id; if (typeof id === 'number' && id > lastSeenId) lastSeenId = id; } function applyViteUpdate(payload) { // payload is a ViteHMRPayload shape. Phase 7.0 v0.1 handles 'update' // as a CSS swap for the css-update sub-type and falls back to a // full reload for js-update (until Fast Refresh lands). 'full-reload' // / 'error' / 'connected' are handled inline. if (!payload || !payload.type) return; switch (payload.type) { case 'connected': // Already greeted inline; nothing else to do. return; case 'update': fireViteEvent('vite:beforeUpdate', payload); if (Array.isArray(payload.updates)) { for (var i = 0; i < payload.updates.length; i++) { var u = payload.updates[i]; if (u.type === 'css-update') { // Re-timestamp any matching . var links = document.querySelectorAll('link[rel="stylesheet"]'); links.forEach(function(link) { var href = link.getAttribute('href') || ''; var baseHref = href.split('?')[0]; if (baseHref === u.path || href.includes('.mandu/client')) { link.setAttribute('href', baseHref + '?t=' + Date.now()); } }); } } } fireViteEvent('vite:afterUpdate', payload); return; case 'full-reload': fireViteEvent('vite:beforeFullReload', payload); location.reload(); return; case 'prune': // Phase 7.1+ β€” ignore for now. return; case 'error': fireViteEvent('vite:error', payload); if (payload.err) showErrorOverlay(payload.err.message || 'Build error'); return; case 'custom': // Plugin custom events β€” route known events to their handlers. // Phase 7.2 HDR: slot-refetch rides this channel so we don't // have to extend HMRMessage (which lives outside this section // of the file). if (payload.event === 'mandu:slot-refetch') { handleSlotRefetch(payload.data || {}); return; } // Unknown custom events are dropped silently β€” Vite's own // plugin ecosystem may emit anything. return; } } // ─── Phase 7.2 HDR (Hot Data Revalidation) ────────────────────────── // // When a .slot.ts file changes the server broadcasts // { type: 'slot-refetch', data: { routeId, slotPath, id, timestamp } } // instead of the legacy 'reload'. We refetch the current URL with // X-Mandu-HDR: 1 to get JSON loader data, then hand it to a // framework revalidate hook that wraps the props update in // React.startTransition so form state / scroll / focus survive. // // The hook path: // 1. window.__MANDU_ROUTER_REVALIDATE__(routeId, loaderData) β€” the // framework router installs this at boot. If absent we fall back // to full reload (minimum-viable-HDR path). // 2. window.__MANDU_HDR__.perfMark(name) β€” optional perf hook. // Bench script reads HMR_PERF.HDR_REFETCH ('hdr:refetch'). function getCurrentRouteId() { // SSR injects __MANDU_ROUTE__ on the window. Router state (if // client-side navigation happened) lives under __MANDU_ROUTER_STATE__. // Prefer the router's state when both are present. var routerState = window.__MANDU_ROUTER_STATE__; if (routerState && routerState.currentRoute && routerState.currentRoute.id) { return String(routerState.currentRoute.id); } var route = window.__MANDU_ROUTE__; if (route && route.id) return String(route.id); return null; } function hdrDisabled() { // Opt-out: projects with unusual CSP / environments may disable HDR // via a global flag. The bundler sets this from MANDU_HDR=0 (see // the bootScript path in SSR rendering). return window.__MANDU_HDR_DISABLED__ === true; } function hdrFallbackFullReload(reason) { console.log('[Mandu HDR] Fallback full reload' + (reason ? ' (' + reason + ')' : '')); location.reload(); } function hdrMark(name, data) { try { if (window.__MANDU_HDR__ && typeof window.__MANDU_HDR__.perfMark === 'function') { window.__MANDU_HDR__.perfMark(name, data); } } catch (_) {} } function handleSlotRefetch(data) { var routeId = data && typeof data.routeId === 'string' ? data.routeId : null; if (!routeId) { hdrFallbackFullReload('no-routeId'); return; } if (hdrDisabled()) { hdrFallbackFullReload('disabled'); return; } var currentId = getCurrentRouteId(); if (currentId !== routeId) { // Not on the affected route β€” nothing to revalidate, no reload // either. The next navigation will pick up the fresh loader. console.log('[Mandu HDR] slot-refetch for ' + routeId + ' ignored (current route: ' + currentId + ')'); return; } var started = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now(); hdrMark('hdr:refetch-start', { routeId: routeId, slotPath: data.slotPath }); // Build the data URL: current pathname + query + _data=1 marker, // with X-Mandu-HDR header so the server can log + treat it as an // HDR request. _data=1 is the existing SPA navigation contract so // we reuse it here β€” the server returns // { routeId, pattern, params, loaderData, timestamp }. var url = window.location.pathname + window.location.search; var sep = url.indexOf('?') >= 0 ? '&' : '?'; var dataUrl = url + sep + '_data=1'; fetch(dataUrl, { credentials: 'same-origin', headers: { 'X-Mandu-HDR': '1' }, }) .then(function (res) { if (!res.ok) { hdrFallbackFullReload('status-' + res.status); return null; } return res.json(); }) .then(function (payload) { if (!payload) return; // Revalidate hook. The router installs this from island glue. var revalidate = window.__MANDU_ROUTER_REVALIDATE__; if (typeof revalidate !== 'function') { // Minimum-viable-HDR fallback: the framework router isn't // installed on this page (e.g. pure-SSR with no client // router). Full reload is the honest degrade. hdrFallbackFullReload('no-router'); return; } // Apply inside React.startTransition so the prop update // doesn't tear down focus / form inputs / scroll position. // The router hook is responsible for wrapping; we just call it. try { revalidate(routeId, payload.loaderData); var elapsed = (typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now()) - started; console.log('[Mandu HDR] Applied loader data for ' + routeId + ' in ' + elapsed.toFixed(0) + 'ms'); hdrMark('hdr:refetch', { routeId: routeId, slotPath: data.slotPath, elapsed: elapsed }); } catch (err) { console.error('[Mandu HDR] Revalidate threw:', err); hdrFallbackFullReload('revalidate-throw'); } }) .catch(function (err) { console.error('[Mandu HDR] Fetch failed:', err); hdrFallbackFullReload('fetch-failed'); }); } function handleMessage(message) { // Vite-compat envelope. Two shapes: live broadcast ('vite') and // replayed-after-reconnect ('vite-replay'). They differ only in the // type tag β€” behavior is identical. if (message.type === 'vite' || message.type === 'vite-replay') { recordEnvelopeId(message); applyViteUpdate(message.payload); return; } switch (message.type) { case 'connected': recordEnvelopeId(message); console.log('[Mandu HMR] Ready'); break; case 'reload': case 'full-reload': recordEnvelopeId(message); fireViteEvent('vite:beforeFullReload', message); console.log('[Mandu HMR] Full reload requested'); location.reload(); break; case 'invalidate': // Server echoed an invalidate β€” same outcome as full reload. recordEnvelopeId(message); fireViteEvent('vite:beforeFullReload', message); location.reload(); break; case 'update': // Mandu-internal 'update' mirrors the Vite payload shape. recordEnvelopeId(message); applyViteUpdate({ type: 'update', updates: (message.data && message.data.updates) || [] }); break; case 'island-update': recordEnvelopeId(message); const routeId = message.data?.routeId; console.log('[Mandu HMR] Island updated:', routeId); staleIslands.add(routeId); // ν˜„μž¬ νŽ˜μ΄μ§€μ˜ island인지 확인 const island = document.querySelector('[data-mandu-island="' + routeId + '"]'); if (island) { fireViteEvent('vite:beforeFullReload', message); console.log('[Mandu HMR] Reloading page for island update'); location.reload(); } break; case 'layout-update': recordEnvelopeId(message); const layoutPath = message.data?.layoutPath; console.log('[Mandu HMR] Layout updated:', layoutPath); fireViteEvent('vite:beforeFullReload', message); // Layout 변경은 항상 전체 λ¦¬λ‘œλ“œ location.reload(); break; case 'slot-refetch': // Phase 7.2 HDR β€” slot (.slot.ts) changed. Try to refetch loader // data without remounting the React tree. Falls back to a full // reload on any failure so the user is never stranded on stale // state. Fire-and-forget (no return from handleMessage itself). recordEnvelopeId(message); handleSlotRefetch(message.data || {}); break; case 'css-update': recordEnvelopeId(message); console.log('[Mandu HMR] CSS updated'); fireViteEvent('vite:beforeUpdate', message); // CSS ν•« λ¦¬λ‘œλ“œ (νŽ˜μ΄μ§€ μƒˆλ‘œκ³ μΉ¨ 없이 μŠ€νƒ€μΌμ‹œνŠΈλ§Œ ꡐ체) var targetCssPath = message.data?.cssPath || '/.mandu/client/globals.css'; var links = document.querySelectorAll('link[rel="stylesheet"]'); links.forEach(function(link) { var href = link.getAttribute('href') || ''; var baseHref = href.split('?')[0]; // μ •ν™•ν•œ 경둜 λ§€μΉ­ μš°μ„ , fallback으둜 κΈ°μ‘΄ νŒ¨ν„΄ λ§€μΉ­ if (baseHref === targetCssPath || href.includes('globals.css') || href.includes('.mandu/client')) { link.setAttribute('href', baseHref + '?t=' + Date.now()); } }); fireViteEvent('vite:afterUpdate', message); break; case 'error': console.error('[Mandu HMR] Build error:', message.data?.message); fireViteEvent('vite:error', message); showErrorOverlay(message.data?.message); break; case 'guard-violation': console.warn('[Mandu HMR] Guard violation:', message.data?.file); if (window.__MANDU_DEVTOOLS_HOOK__) { window.__MANDU_DEVTOOLS_HOOK__.emit({ type: 'guard:violation', timestamp: Date.now(), data: message.data }); } break; case 'kitchen:file-change': if (window.__MANDU_DEVTOOLS_HOOK__) { window.__MANDU_DEVTOOLS_HOOK__.emit({ type: 'kitchen:file-change', timestamp: Date.now(), data: message.data }); } break; case 'kitchen:guard-decision': if (window.__MANDU_DEVTOOLS_HOOK__) { window.__MANDU_DEVTOOLS_HOOK__.emit({ type: 'kitchen:guard-decision', timestamp: Date.now(), data: message.data }); } break; case 'pong': // μ—°κ²° 확인 break; } } function showErrorOverlay(message) { // κΈ°μ‘΄ μ˜€λ²„λ ˆμ΄ 제거 const existing = document.getElementById('mandu-hmr-error'); if (existing) existing.remove(); const overlay = document.createElement('div'); overlay.id = 'mandu-hmr-error'; overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.9);color:#ff6b6b;font-family:monospace;padding:40px;z-index:99999;overflow:auto;'; const h2 = document.createElement('h2'); h2.style.cssText = 'color:#ff6b6b;margin:0 0 20px;'; h2.textContent = 'πŸ”₯ Build Error'; const pre = document.createElement('pre'); pre.style.cssText = 'white-space:pre-wrap;word-break:break-all;'; pre.textContent = message || 'Unknown error'; const btn = document.createElement('button'); btn.style.cssText = 'position:fixed;top:20px;right:20px;background:#333;color:#fff;border:none;padding:10px 20px;cursor:pointer;'; btn.textContent = 'Close'; btn.onclick = function() { overlay.remove(); }; overlay.appendChild(h2); overlay.appendChild(pre); overlay.appendChild(btn); document.body.appendChild(overlay); } // νŽ˜μ΄μ§€ λ‘œλ“œ μ‹œ μ—°κ²° if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', connect); } else { connect(); } // νŽ˜μ΄μ§€ μ΄νƒˆ μ‹œ 정리 window.addEventListener('beforeunload', function() { if (ws) ws.close(); }); // νŽ˜μ΄μ§€ 이동 μ‹œ stale island 감지 ν›„ λ¦¬λ‘œλ“œ (#115) function checkStaleIslandsOnNavigation() { if (staleIslands.size === 0) return; for (const id of staleIslands) { if (document.querySelector('[data-mandu-island="' + id + '"]')) { console.log('[Mandu HMR] Stale island detected after navigation, reloading...'); location.reload(); return; } } } window.addEventListener('popstate', checkStaleIslandsOnNavigation); window.addEventListener('pageshow', function(e) { if (e.persisted) checkStaleIslandsOnNavigation(); }); // Ping 전솑 (μ—°κ²° μœ μ§€) setInterval(function() { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ping' })); } }, 30000); })(); `; }