@mandujs/core 0.21.0 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/package.json +101 -69
  2. package/src/auth/__tests__/login.test.ts +419 -0
  3. package/src/auth/__tests__/password.test.ts +122 -0
  4. package/src/auth/__tests__/reset.test.ts +296 -0
  5. package/src/auth/__tests__/tokens.test.ts +274 -0
  6. package/src/auth/__tests__/verification.test.ts +274 -0
  7. package/src/auth/index.ts +76 -0
  8. package/src/auth/login.ts +225 -0
  9. package/src/auth/password.ts +120 -0
  10. package/src/auth/reset.ts +243 -0
  11. package/src/auth/tokens.ts +612 -0
  12. package/src/auth/verification.ts +253 -0
  13. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
  14. package/src/bundler/__tests__/cold-start.test.ts +504 -0
  15. package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
  16. package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
  17. package/src/bundler/__tests__/extended-watch.test.ts +710 -0
  18. package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
  19. package/src/bundler/__tests__/hdr.test.ts +353 -0
  20. package/src/bundler/__tests__/hmr-client.test.ts +532 -0
  21. package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
  22. package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
  23. package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
  24. package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
  25. package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
  26. package/src/bundler/build.test.ts +8 -1
  27. package/src/bundler/build.ts +310 -18
  28. package/src/bundler/css.ts +326 -323
  29. package/src/bundler/dev.ts +1611 -59
  30. package/src/bundler/fast-refresh-plugin.ts +307 -0
  31. package/src/bundler/hmr-types.ts +252 -0
  32. package/src/bundler/manifest-schema.ts +301 -0
  33. package/src/bundler/safe-build.test.ts +128 -0
  34. package/src/bundler/safe-build.ts +77 -0
  35. package/src/bundler/scenario-matrix.ts +229 -0
  36. package/src/bundler/types.ts +11 -0
  37. package/src/bundler/vendor-cache-types.ts +130 -0
  38. package/src/bundler/vendor-cache.ts +526 -0
  39. package/src/client/router.ts +214 -56
  40. package/src/db/__tests__/db.test.ts +485 -0
  41. package/src/db/index.ts +513 -0
  42. package/src/db/migrations/__tests__/runner.test.ts +661 -0
  43. package/src/db/migrations/history-table.ts +345 -0
  44. package/src/db/migrations/lock.ts +269 -0
  45. package/src/db/migrations/runner.ts +633 -0
  46. package/src/desktop/__tests__/smoke.test.ts +100 -0
  47. package/src/desktop/__tests__/window.test.ts +172 -0
  48. package/src/desktop/__tests__/worker.test.ts +266 -0
  49. package/src/desktop/index.ts +43 -0
  50. package/src/desktop/types.ts +158 -0
  51. package/src/desktop/window.ts +492 -0
  52. package/src/desktop/worker.ts +180 -0
  53. package/src/email/__tests__/email.test.ts +355 -0
  54. package/src/email/index.ts +282 -0
  55. package/src/email/resend.ts +163 -0
  56. package/src/email/smtp.ts +64 -0
  57. package/src/filling/__tests__/session-sqlite.test.ts +454 -0
  58. package/src/filling/context.ts +72 -78
  59. package/src/filling/cookie-codec.ts +299 -0
  60. package/src/filling/deps.ts +25 -1
  61. package/src/filling/filling.ts +28 -3
  62. package/src/filling/session-sqlite.ts +617 -0
  63. package/src/filling/session.ts +265 -216
  64. package/src/guard/decision-memory.test.ts +52 -22
  65. package/src/id/__tests__/id.test.ts +120 -0
  66. package/src/id/index.ts +105 -0
  67. package/src/kitchen/index.ts +2 -2
  68. package/src/kitchen/kitchen-handler.ts +86 -0
  69. package/src/kitchen/stream/activity-sse.ts +2 -1
  70. package/src/middleware/csrf.ts +328 -0
  71. package/src/middleware/index.ts +40 -0
  72. package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
  73. package/src/middleware/oauth/index.ts +505 -0
  74. package/src/middleware/oauth/providers.ts +115 -0
  75. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
  76. package/src/middleware/rate-limit/index.ts +522 -0
  77. package/src/middleware/rate-limit/sqlite-store.ts +382 -0
  78. package/src/middleware/secure/__tests__/secure.test.ts +360 -0
  79. package/src/middleware/secure/csp.ts +193 -0
  80. package/src/middleware/secure/index.ts +417 -0
  81. package/src/middleware/session.ts +174 -0
  82. package/src/observability/event-bus.ts +81 -79
  83. package/src/paths.ts +37 -0
  84. package/src/perf/hmr-markers.ts +215 -0
  85. package/src/perf/index.ts +104 -0
  86. package/src/resource/__tests__/generator.test.ts +603 -2
  87. package/src/resource/ddl/__tests__/diff.test.ts +639 -0
  88. package/src/resource/ddl/__tests__/emit.test.ts +799 -0
  89. package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
  90. package/src/resource/ddl/diff.ts +392 -0
  91. package/src/resource/ddl/emit.ts +548 -0
  92. package/src/resource/ddl/persistence-types.ts +218 -0
  93. package/src/resource/ddl/snapshot.ts +447 -0
  94. package/src/resource/ddl/type-map.ts +223 -0
  95. package/src/resource/ddl/types.ts +232 -0
  96. package/src/resource/generator-repo.ts +610 -0
  97. package/src/resource/generator-schema.ts +476 -0
  98. package/src/resource/generator.ts +117 -1
  99. package/src/resource/index.ts +17 -1
  100. package/src/resource/schema.ts +30 -0
  101. package/src/router/fs-scanner.ts +3 -0
  102. package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
  103. package/src/runtime/__tests__/hdr-client.test.ts +223 -0
  104. package/src/runtime/__tests__/http-errors.test.ts +117 -0
  105. package/src/runtime/__tests__/not-found.test.ts +152 -0
  106. package/src/runtime/boundary.tsx +21 -1
  107. package/src/runtime/fast-refresh-runtime.ts +322 -0
  108. package/src/runtime/fast-refresh-types.ts +128 -0
  109. package/src/runtime/hmr-client.ts +409 -0
  110. package/src/runtime/http-errors.ts +113 -0
  111. package/src/runtime/index.ts +6 -0
  112. package/src/runtime/logger.ts +678 -677
  113. package/src/runtime/not-found.ts +93 -0
  114. package/src/runtime/redirect.ts +133 -0
  115. package/src/runtime/server.ts +518 -20
  116. package/src/runtime/ssr.ts +340 -10
  117. package/src/runtime/streaming-ssr.ts +222 -19
  118. package/src/scheduler/__tests__/scheduler.test.ts +514 -0
  119. package/src/scheduler/index.ts +343 -0
  120. package/src/storage/s3/__tests__/s3.test.ts +479 -0
  121. package/src/storage/s3/index.ts +412 -0
  122. package/src/testing/index.ts +58 -0
@@ -7,6 +7,14 @@ import type { RoutesManifest, RouteSpec } from "../spec/schema";
7
7
  import { buildClientBundles } from "./build";
8
8
  import type { BundleResult } from "./types";
9
9
  import { PORTS, TIMEOUTS } from "../constants";
10
+ import { mark, measure, withPerf } from "../perf";
11
+ import { HMR_PERF } from "../perf/hmr-markers";
12
+ import type {
13
+ CoalescedChange,
14
+ ViteHMRPayload,
15
+ HMRReplayEnvelope,
16
+ } from "./hmr-types";
17
+ import { MAX_REPLAY_BUFFER, REPLAY_MAX_AGE_MS } from "./hmr-types";
10
18
  import path from "path";
11
19
  import fs from "fs";
12
20
 
@@ -37,6 +45,43 @@ export interface DevBundlerOptions {
37
45
  * API 핸들러 재등록이 필요한 경우 호출
38
46
  */
39
47
  onAPIChange?: (filePath: string) => void | Promise<void>;
48
+ /**
49
+ * Phase 7.0 R2 Agent D — Config/env change callback.
50
+ *
51
+ * Fires when `mandu.config.ts` or any `.env*` file at the project root
52
+ * changes. The CLI's `dev.ts` wires this to `restartDevServer()` so the
53
+ * new config values take effect (Node caches `process.env.KEY` per-
54
+ * process, so an auto-restart is the only reliable reload path).
55
+ *
56
+ * Multiple rapid changes in one debounce window fire this ONCE (per-file
57
+ * debounce + `pendingBuildSet` coalescing in `classifyBatch`).
58
+ */
59
+ onConfigReload?: (filePath: string) => void | Promise<void>;
60
+ /**
61
+ * Phase 7.0 R2 Agent D — Contract / Resource change callback.
62
+ *
63
+ * Fires when a contract (`spec/contracts/foo.contract.ts` — nested
64
+ * directories allowed) or resource schema
65
+ * (`spec/resources/user.resource.ts`) file changes. Consumers typically:
66
+ * - Re-run `generateResourceArtifacts` for `.resource.ts` changes
67
+ * (so derived `.mandu/generated/server/contracts`,
68
+ * `types`, `client`, and `spec/slots` stay in sync).
69
+ * - For `.contract.ts`, re-register the route handler that consumed
70
+ * the contract (usually via `onSSRChange(SSR_CHANGE_WILDCARD)`).
71
+ *
72
+ * When both fire in the same batch, `classifyBatch` returns
73
+ * `"resource-regen"` exactly once.
74
+ */
75
+ onResourceChange?: (filePath: string) => void | Promise<void>;
76
+ /**
77
+ * Phase 7.0 R2 Agent D — package.json change notification.
78
+ *
79
+ * We intentionally do NOT auto-restart on `package.json` — dependency
80
+ * installs often write the file multiple times in quick succession, and
81
+ * a restart loop mid-install would be destructive. The callback exists
82
+ * so the CLI can print a "manual restart required" hint to the user.
83
+ */
84
+ onPackageJsonChange?: (filePath: string) => void;
40
85
  /**
41
86
  * 추가 watch 디렉토리 (공통 컴포넌트 등)
42
87
  * 상대 경로 또는 절대 경로 모두 지원
@@ -79,25 +124,233 @@ function normalizeFsPath(p: string): string {
79
124
  return process.platform === "win32" ? resolved.toLowerCase() : resolved;
80
125
  }
81
126
 
82
- // 기본 공통 컴포넌트 디렉토리 목록
127
+ /**
128
+ * 기본 공통 컴포넌트 디렉토리 목록 (B1 fix — Phase 7.0 R1 Agent A).
129
+ *
130
+ * Historical (pre-B1) behavior: only `src/components`, `src/shared`, etc. were
131
+ * watched, which silently ignored `src/foo.ts` (top-level files) — a real
132
+ * regression hit in `demo/starter/src/playground-shell.tsx`. B1 widens the
133
+ * default to include **`src/` itself** (recursive, node_modules-excluded) plus
134
+ * the legacy unprefixed roots so existing projects without an `src/` dir
135
+ * continue to work.
136
+ */
83
137
  const DEFAULT_COMMON_DIRS = [
84
- "src/components",
138
+ "src", // B1 fix — top-level files under `src/` (was missing)
85
139
  "components",
86
- "src/shared",
87
140
  "shared",
88
- "src/lib",
89
141
  "lib",
90
- "src/hooks",
91
142
  "hooks",
92
- "src/utils",
93
143
  "utils",
94
- // Islands & Client 디렉토리
95
- "src/client",
96
144
  "client",
97
- "src/islands",
98
145
  "islands",
99
146
  ];
100
147
 
148
+ /**
149
+ * Path segments excluded from `isInCommonDir` / watcher dispatch.
150
+ *
151
+ * We intentionally use **absolute path segment prefixes** (join-style) so a
152
+ * project file named `dist-nice.ts` is NOT treated as excluded. The check is
153
+ * "contains `/<segment>/`" against the normalized forward-slash path.
154
+ *
155
+ * `pagefile.sys` / `hiberfil.sys` / `DumpStack.log.tmp` are Windows system
156
+ * files that can bubble up into `fs.watch` on the drive root under pathological
157
+ * setups — belt-and-suspenders.
158
+ */
159
+ const WATCH_EXCLUDE_SEGMENTS: readonly string[] = [
160
+ "node_modules",
161
+ ".mandu",
162
+ ".git",
163
+ "dist",
164
+ "build",
165
+ ".next",
166
+ "coverage",
167
+ ".cache",
168
+ ".turbo",
169
+ ];
170
+
171
+ /**
172
+ * Filenames explicitly ignored (Windows system files + editor artifacts).
173
+ *
174
+ * Stored lowercase so the check compares apples-to-apples with
175
+ * `normalizeFsPath`'s win32 lowercasing. On posix the comparison is still
176
+ * lowercase — intentional, since the Windows system files these target
177
+ * never legitimately appear on Linux/mac anyway.
178
+ */
179
+ const WATCH_EXCLUDE_FILENAMES: ReadonlySet<string> = new Set([
180
+ "pagefile.sys",
181
+ "hiberfil.sys",
182
+ "dumpstack.log",
183
+ "dumpstack.log.tmp",
184
+ "swapfile.sys",
185
+ ]);
186
+
187
+ /**
188
+ * Returns true if the given **normalized (forward-slash, lowercase on win32)**
189
+ * path is inside a directory we should ignore (e.g. `node_modules`).
190
+ *
191
+ * Callers must pass paths already through `normalizeFsPath`.
192
+ */
193
+ export function isExcludedPath(normalizedPath: string): boolean {
194
+ // Filename-level ignores. We lowercase the basename BEFORE comparing so the
195
+ // check behaves identically whether the caller ran `normalizeFsPath` (which
196
+ // lowercases only on win32) or not — Windows system files like
197
+ // `DumpStack.log` have no valid posix counterpart, so lowercasing is safe
198
+ // on linux too.
199
+ const basename = (normalizedPath.split("/").pop() ?? "").toLowerCase();
200
+ if (WATCH_EXCLUDE_FILENAMES.has(basename)) return true;
201
+
202
+ // Directory-segment ignores. Wrap with slashes to avoid partial-name matches
203
+ // (e.g. `dist-ribution.ts` must not be excluded by `dist`).
204
+ for (const segment of WATCH_EXCLUDE_SEGMENTS) {
205
+ if (normalizedPath.includes(`/${segment}/`)) return true;
206
+ }
207
+ return false;
208
+ }
209
+
210
+ /**
211
+ * Phase 7.0 R2 Agent D — Config-file predicate.
212
+ *
213
+ * Matches `mandu.config.ts` / `mandu.config.js` / `.env` / `.env.local` /
214
+ * `.env.development` / `.env.production` (and similar `.env.*` variants)
215
+ * at the **project root**. The argument must already be normalized —
216
+ * callers should run `normalizeFsPath` first.
217
+ *
218
+ * We look at the basename (not a full path prefix) so the check is cheap
219
+ * and cross-platform. The caller is responsible for restricting the
220
+ * watch set to the project root — that's where a genuine config lives.
221
+ * An `.env` deep inside `node_modules` (pathological) is already
222
+ * excluded by `isExcludedPath`.
223
+ */
224
+ export function isConfigOrEnvFile(normalizedPath: string): boolean {
225
+ const basename = (normalizedPath.split("/").pop() ?? "").toLowerCase();
226
+ // `mandu.config.ts|js|mjs|cjs` — accept .ts|.js for JS-only projects.
227
+ if (
228
+ basename === "mandu.config.ts" ||
229
+ basename === "mandu.config.js" ||
230
+ basename === "mandu.config.mjs" ||
231
+ basename === "mandu.config.cjs"
232
+ ) {
233
+ return true;
234
+ }
235
+ // `.env` family. `.env` alone is valid; `.env.local`, `.env.development`,
236
+ // `.env.production`, `.env.staging`, `.env.test` etc. all match.
237
+ if (basename === ".env" || basename.startsWith(".env.")) {
238
+ return true;
239
+ }
240
+ return false;
241
+ }
242
+
243
+ /**
244
+ * Phase 7.0 R2 Agent D — Resource/Contract file predicate.
245
+ *
246
+ * Matches `*.resource.ts` (and `*.resource.tsx` for the rare JSX-in-
247
+ * schema case) and `*.contract.ts|tsx`. These are user-authored schema
248
+ * files that drive code-gen (`generateResourceArtifacts`) and Zod-based
249
+ * route handlers.
250
+ *
251
+ * Intentionally NOT restricted to `spec/contracts` / `spec/resources` —
252
+ * some projects keep contracts colocated with the route
253
+ * (`app/api/users/users.contract.ts`). The `classifyBatch` caller
254
+ * already ensures the path is inside the watched tree.
255
+ */
256
+ export function isResourceOrContractFile(normalizedPath: string): boolean {
257
+ return (
258
+ normalizedPath.endsWith(".contract.ts") ||
259
+ normalizedPath.endsWith(".contract.tsx") ||
260
+ normalizedPath.endsWith(".resource.ts") ||
261
+ normalizedPath.endsWith(".resource.tsx")
262
+ );
263
+ }
264
+
265
+ /**
266
+ * Phase 7.0 R2 Agent D — per-route `middleware.ts` predicate.
267
+ *
268
+ * Matches files whose basename is `middleware.ts` / `middleware.tsx`
269
+ * (nested under any `app` subdirectory). Layout-level `middleware.ts`
270
+ * at the project root is handled separately through the existing
271
+ * runtime loader — those changes require a restart because the server
272
+ * loads them at boot, not per-request. Per-route middleware is
273
+ * re-scanned when the route graph is re-registered, so we funnel these
274
+ * through the existing `api-only` rebuild path which already calls
275
+ * `registerHandlers(manifest, true)`.
276
+ */
277
+ export function isRouteMiddlewareFile(normalizedPath: string): boolean {
278
+ return (
279
+ normalizedPath.endsWith("/middleware.ts") ||
280
+ normalizedPath.endsWith("/middleware.tsx")
281
+ );
282
+ }
283
+
284
+ /**
285
+ * Phase 7.0 R2 Agent D — `package.json` predicate.
286
+ *
287
+ * Matches the project-root `package.json`. Restricted to basename only —
288
+ * nested `package.json` files (inside `node_modules`, workspace sub-
289
+ * packages) are caught by `isExcludedPath` in `node_modules` /
290
+ * `.mandu` trees, and workspace changes are outside the dev-time loop.
291
+ */
292
+ export function isPackageJsonFile(normalizedPath: string): boolean {
293
+ return (normalizedPath.split("/").pop() ?? "").toLowerCase() === "package.json";
294
+ }
295
+
296
+ /**
297
+ * Test-only helper: invoke the internal `normalizeFsPath` implementation.
298
+ * Exported so `dev-reliability.test.ts` can assert forward-slash / lower-case
299
+ * normalization without duplicating the logic.
300
+ *
301
+ * Not part of the public API surface — prefixed with `_testOnly_` to signal
302
+ * "do not consume in production code". If you need this elsewhere, lift
303
+ * `normalizeFsPath` to a dedicated module.
304
+ */
305
+ export function _testOnly_normalizeFsPath(p: string): string {
306
+ return normalizeFsPath(p);
307
+ }
308
+
309
+ /** Test-only accessor for the default common-dir list (B1 coverage). */
310
+ export const _testOnly_DEFAULT_COMMON_DIRS = DEFAULT_COMMON_DIRS;
311
+
312
+ /** Test-only accessor for the watch exclude segments (B1 coverage). */
313
+ export const _testOnly_WATCH_EXCLUDE_SEGMENTS = WATCH_EXCLUDE_SEGMENTS;
314
+
315
+ /**
316
+ * Phase 7.0 R2 Agent D — classification helper mirroring the in-bundler
317
+ * `classifyBatch` priority rules WITHOUT the project-specific maps
318
+ * (serverModuleSet / apiModuleSet / clientModuleToRoute / commonWatchDirs).
319
+ *
320
+ * Why a separate export: the in-bundler classifier is a closure over
321
+ * live state (manifest-derived maps). Tests that want to prove the
322
+ * **static** parts of the rule table — "a `.contract.ts` path is
323
+ * resource-regen", "a `.env` path is config-reload", "middleware is
324
+ * api-only" — would otherwise have to spin up a real `startDevBundler`
325
+ * with a tempdir manifest, which slows every assertion to tens of ms.
326
+ *
327
+ * Signature and return type match the live classifier so a future
328
+ * consolidation can swap them without a migration.
329
+ */
330
+ export function _testOnly_classifyFileKind(
331
+ file: string,
332
+ options: { commonDirs?: readonly string[] } = {},
333
+ ): "config-reload" | "resource-regen" | "api-only" | "common-dir" | "mixed" {
334
+ const normalized = (function normalize(p: string): string {
335
+ const resolved = path.resolve(p).replace(/\\/g, "/");
336
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
337
+ })(file);
338
+
339
+ if (isConfigOrEnvFile(normalized)) return "config-reload";
340
+ if (isResourceOrContractFile(normalized)) return "resource-regen";
341
+ if (isRouteMiddlewareFile(normalized)) return "api-only";
342
+ if (options.commonDirs) {
343
+ for (const d of options.commonDirs) {
344
+ const nd = (function n(p: string): string {
345
+ const r = path.resolve(p).replace(/\\/g, "/");
346
+ return process.platform === "win32" ? r.toLowerCase() : r;
347
+ })(d);
348
+ if (normalized === nd || normalized.startsWith(nd + "/")) return "common-dir";
349
+ }
350
+ }
351
+ return "mixed";
352
+ }
353
+
101
354
  /**
102
355
  * 개발 모드 번들러 시작
103
356
  * 파일 변경 감시 및 자동 재빌드
@@ -110,6 +363,9 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
110
363
  onError,
111
364
  onSSRChange,
112
365
  onAPIChange,
366
+ onConfigReload,
367
+ onResourceChange,
368
+ onPackageJsonChange,
113
369
  watchDirs: customWatchDirs = [],
114
370
  disableDefaultWatchDirs = false,
115
371
  } = options;
@@ -169,6 +425,87 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
169
425
  }
170
426
  }
171
427
 
428
+ // Phase 7.1 R1 Agent A — slot dispatch integration (Option B).
429
+ //
430
+ // Prior to Phase 7.1 the bundler ignored `.slot.ts(x)` edits: they
431
+ // hit no classification bucket and silently fell through to a
432
+ // no-op in `_doBuild`. The CLI's chokidar-backed `watchFSRoutes`
433
+ // worked around the gap by re-scanning the manifest, but that
434
+ // path sits OUTSIDE `startDevBundler` and is not exercised by the
435
+ // HMR matrix (see `packages/core/tests/hmr-matrix/matrix.spec.ts`
436
+ // `KNOWN_BUNDLER_GAPS`).
437
+ //
438
+ // Option B — register the slot path into the existing
439
+ // `serverModuleSet`. Semantically a slot IS an SSR-side data
440
+ // loader (it runs before `componentModule` on the server to
441
+ // populate typed props), so co-locating the dispatch with page /
442
+ // layout is consistent. The existing `onSSRChange(filePath)` path
443
+ // downstream in `_doBuild` already delivers the right signal —
444
+ // the CLI's `handleSSRChange` will re-register the route handler
445
+ // and broadcast a full-reload.
446
+ //
447
+ // We also add the slot's directory to `watchDirs` so fs.watch
448
+ // actually delivers the event. For spec/slots/*.slot.ts the
449
+ // `slotsDir` block below already covers this, but user-authored
450
+ // colocated slots (e.g. `app/page.slot.ts`) live in app/ which is
451
+ // picked up via the page's `watchDirs.add(path.dirname(absPath))`
452
+ // line above — still, making the slot add explicit here keeps
453
+ // the dispatch path honest against future manifest topologies.
454
+ // Phase 7.2 R1 Agent C (H3 / L-03 audit): validate slotModule
455
+ // path BEFORE it contributes to serverModuleSet / watchDirs.
456
+ //
457
+ // Before 7.2 the code trusted `route.slotModule` verbatim and a
458
+ // tampered manifest with `slotModule: "../../../etc/passwd"` would
459
+ // pollute `watchDirs` with directories outside the project root.
460
+ // Downstream code (`bundledImport`, `registerHandlers`) already
461
+ // ignored the raw path, but the defense-in-depth cost is tiny so
462
+ // we reject obviously unsafe shapes here and keep the fs.watch
463
+ // surface inside the project tree.
464
+ //
465
+ // Allowed shapes (matches the bundler's own output conventions):
466
+ // - `spec/slots/<id>.slot.ts(x)` (auto-linked, fs-routes)
467
+ // - `app/**/<name>.slot.ts(x)` (colocated user slots)
468
+ // - `[param]` brackets for dynamic routes are preserved
469
+ //
470
+ // Rejected shapes:
471
+ // - absolute paths (leading `/` or Windows `C:\`)
472
+ // - `..` anywhere in the path (traversal)
473
+ // - backslashes (fs-routes emits forward-slash only)
474
+ // - any char outside a conservative allowlist
475
+ //
476
+ // See `docs/security/phase-7-1-audit.md` §L-03.
477
+ if (route.slotModule) {
478
+ const SLOT_PATH_REGEX = /^(?:spec\/slots|app)\/[A-Za-z0-9_\-./\[\]]+\.slots?\.tsx?$/;
479
+ const raw = route.slotModule;
480
+ let accepted = false;
481
+ if (
482
+ typeof raw === "string" &&
483
+ raw.length > 0 &&
484
+ raw.length <= 512 &&
485
+ !raw.includes("..") &&
486
+ !raw.includes("\\") &&
487
+ !raw.startsWith("/") &&
488
+ !/^[A-Za-z]:/.test(raw) &&
489
+ SLOT_PATH_REGEX.test(raw)
490
+ ) {
491
+ const absPath = path.resolve(rootDir, raw);
492
+ // Belt-and-suspenders: canonicalized path must remain inside rootDir.
493
+ const rootWithSep = path.resolve(rootDir) + path.sep;
494
+ if (absPath.startsWith(rootWithSep) || absPath === path.resolve(rootDir)) {
495
+ serverModuleSet.add(normalizeFsPath(absPath));
496
+ watchDirs.add(path.dirname(absPath));
497
+ accepted = true;
498
+ }
499
+ }
500
+ if (!accepted) {
501
+ // eslint-disable-next-line no-console
502
+ console.warn(
503
+ `[Mandu] slotModule rejected for route "${route.id}": ${raw}. ` +
504
+ `Expected (spec/slots|app)/.../<name>.slot.ts(x) with no '..' or absolute prefix.`,
505
+ );
506
+ }
507
+ }
508
+
172
509
  // Track API route modules for hot-reload
173
510
  if (route.kind === "api" && route.module) {
174
511
  const absPath = path.resolve(rootDir, route.module);
@@ -186,6 +523,31 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
186
523
  // slots 디렉토리 없으면 무시
187
524
  }
188
525
 
526
+ // Phase 7.0 R2 Agent D — spec/contracts and spec/resources directories.
527
+ //
528
+ // Pre-R2 behavior: these directories were NOT watched. Editing a Zod
529
+ // schema (`spec/contracts/foo.contract.ts`) or resource definition
530
+ // (`spec/resources/user.resource.ts`) required a manual dev-server
531
+ // restart, because `classifyBatch` had no category for them and
532
+ // `onSSRChange`/`onAPIChange` didn't fire. We add the directories to
533
+ // the main watch set so the existing fs.watch dispatcher delivers
534
+ // events; `classifyBatch` then routes them to `resource-regen`.
535
+ const contractsDir = path.join(rootDir, "spec", "contracts");
536
+ try {
537
+ await fs.promises.access(contractsDir);
538
+ watchDirs.add(contractsDir);
539
+ } catch {
540
+ // Contracts directory is optional — not all projects use Zod contracts.
541
+ }
542
+ const resourcesDir = path.join(rootDir, "spec", "resources");
543
+ try {
544
+ await fs.promises.access(resourcesDir);
545
+ watchDirs.add(resourcesDir);
546
+ } catch {
547
+ // Resources directory is optional — projects without Resource-Centric
548
+ // layer simply never hit this path.
549
+ }
550
+
189
551
  // 공통 컴포넌트 디렉토리 추가 (기본 + 커스텀)
190
552
  const commonDirsToCheck = disableDefaultWatchDirs
191
553
  ? customWatchDirs
@@ -210,50 +572,408 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
210
572
 
211
573
  // 파일 감시 설정
212
574
  const watchers: fs.FSWatcher[] = [];
213
- let debounceTimer: ReturnType<typeof setTimeout> | null = null;
575
+
576
+ /**
577
+ * B6 fix — per-file debounce Map (Phase 7.0 R1 Agent A).
578
+ *
579
+ * Pre-B6 behavior: a single module-scope `debounceTimer` was cleared on EVERY
580
+ * fs event. Two rapid events on different files within `WATCHER_DEBOUNCE`
581
+ * (100 ms) therefore dropped the earlier one. B6 gives each file its own
582
+ * timer so an edit to file A does not cancel a pending edit to file B.
583
+ *
584
+ * Lifecycle: timers are created by `scheduleFileChange`, cleared on flush or
585
+ * on `close()`. We call `.delete(key)` on flush to keep the Map bounded —
586
+ * no leak from editing a single file repeatedly.
587
+ */
588
+ const perFileTimers = new Map<string, ReturnType<typeof setTimeout>>();
589
+
590
+ /**
591
+ * B2 fix — multi-file pending build queue (Phase 7.0 R1 Agent A).
592
+ *
593
+ * Pre-B2 behavior: `pendingBuildFile: string | null` — the second and third
594
+ * rapid-fire changes overwrote each other and were silently dropped. B2 uses
595
+ * a Set so EVERY changed file during an in-flight build is retained and
596
+ * flushed together after completion. Coalesce by `kind` to issue at most one
597
+ * `buildClientBundles` call per batch when possible.
598
+ */
599
+ const pendingBuildSet = new Set<string>();
600
+
214
601
  // 동시 빌드 방지 (#121): 빌드 중에 변경 발생 시 다음 빌드 대기
215
602
  let isBuilding = false;
216
- let pendingBuildFile: string | null = null;
217
603
 
218
- // 파일이 공통 디렉토리에 있는지 확인
604
+ /**
605
+ * Paths already known to be inside a common directory, cached to avoid
606
+ * repeating prefix checks for noisy watchers (IDE autosave bursts).
607
+ */
219
608
  const isInCommonDir = (filePath: string): boolean => {
220
609
  const normalizedFile = normalizeFsPath(filePath);
221
610
  for (const commonDir of commonWatchDirs) {
222
611
  const normalizedCommon = normalizeFsPath(commonDir);
223
- if (normalizedFile.startsWith(normalizedCommon + "/")) {
612
+ if (
613
+ normalizedFile === normalizedCommon ||
614
+ normalizedFile.startsWith(normalizedCommon + "/")
615
+ ) {
224
616
  return true;
225
617
  }
226
618
  }
227
619
  return false;
228
620
  };
229
621
 
230
- const handleFileChange = async (changedFile: string) => {
231
- // 동시 빌드 방지 (#121): 빌드 중이면 대기 큐에 저장
622
+ /**
623
+ * Classify a batched `Set` of changed files for B2 coalescing.
624
+ *
625
+ * Kept simple on purpose — `_doBuild` downstream re-checks fine-grained
626
+ * routing (clientModule / serverModule / API), so we only need the coarse
627
+ * category used by the hmr-types contract.
628
+ */
629
+ const classifyBatch = (files: readonly string[]): CoalescedChange["kind"] => {
630
+ let hasCommon = false;
631
+ let hasSsr = false;
632
+ let hasApi = false;
633
+ let hasIsland = false;
634
+ let hasCss = false;
635
+ // Phase 7.0 R2 Agent D — new classification bits. These are tracked
636
+ // alongside the existing categories so a batch that mixes a config
637
+ // save with an island edit still surfaces the high-priority signal
638
+ // (config-reload always wins — a restart invalidates everything
639
+ // anyway).
640
+ let hasConfigReload = false;
641
+ let hasResourceRegen = false;
642
+
643
+ for (const file of files) {
644
+ const normalized = normalizeFsPath(file);
645
+
646
+ // D — config/env files trump everything else. A restart subsumes
647
+ // any other pending work so we can flag and continue.
648
+ if (isConfigOrEnvFile(normalized)) {
649
+ hasConfigReload = true;
650
+ continue;
651
+ }
652
+
653
+ // D — contract/resource schema files are code-gen inputs. A
654
+ // `.resource.ts` edit must re-run the generator; a `.contract.ts`
655
+ // edit reshapes the Zod validator in SSR handlers. Both are
656
+ // routed through `resource-regen`.
657
+ if (isResourceOrContractFile(normalized)) {
658
+ hasResourceRegen = true;
659
+ continue;
660
+ }
661
+
662
+ if (normalized.endsWith(".css")) {
663
+ hasCss = true;
664
+ continue;
665
+ }
666
+ if (isInCommonDir(file)) {
667
+ hasCommon = true;
668
+ continue;
669
+ }
670
+
671
+ // D — per-route `middleware.ts` is treated as an API-level change
672
+ // (the api-only rebuild path re-registers handlers, which is
673
+ // exactly what a middleware change needs). We fall THROUGH to
674
+ // the existing api category so coalescing logic is unchanged.
675
+ if (isRouteMiddlewareFile(normalized)) {
676
+ hasApi = true;
677
+ continue;
678
+ }
679
+
680
+ if (apiModuleSet.has(normalized)) {
681
+ hasApi = true;
682
+ continue;
683
+ }
684
+ if (serverModuleSet.has(normalized)) {
685
+ hasSsr = true;
686
+ continue;
687
+ }
688
+ if (clientModuleToRoute.has(normalized)) {
689
+ hasIsland = true;
690
+ continue;
691
+ }
692
+ if (
693
+ file.endsWith(".client.ts") ||
694
+ file.endsWith(".client.tsx") ||
695
+ file.endsWith(".island.tsx") ||
696
+ file.endsWith(".island.ts")
697
+ ) {
698
+ hasIsland = true;
699
+ }
700
+ }
701
+
702
+ // Phase 7.0 R2 Agent D — priority gates.
703
+ //
704
+ // 1. `config-reload` beats everything. A process restart will pick
705
+ // up all other changes on the next boot; there's no value in
706
+ // rebuilding before we throw the process away.
707
+ // 2. `resource-regen` beats common-dir because code-gen artifacts
708
+ // feed into common-dir files — running them in reverse order
709
+ // would cause a stale rebuild.
710
+ if (hasConfigReload) return "config-reload";
711
+ if (hasResourceRegen) return "resource-regen";
712
+
713
+ // Common-dir dominates — it already fans out to every island + SSR
714
+ // registry. No point in double-classifying "mixed" when a fan-out fix
715
+ // obsoletes the individual changes.
716
+ if (hasCommon) return "common-dir";
717
+
718
+ const categories = [hasSsr, hasApi, hasIsland, hasCss].filter(Boolean).length;
719
+ if (categories === 0) return "mixed";
720
+ if (categories > 1) return "mixed";
721
+ if (hasSsr) return "ssr-only";
722
+ if (hasApi) return "api-only";
723
+ if (hasIsland) return "islands-only";
724
+ if (hasCss) return "css-only";
725
+ return "mixed";
726
+ };
727
+
728
+ /**
729
+ * Flush the pending build queue as a single coalesced batch. Prefers a
730
+ * common-dir path when any file in the batch triggers one — that already
731
+ * fans out to every island + SSR registry invalidation, so processing the
732
+ * other files individually would be wasted work.
733
+ *
734
+ * Called by `handleFileChange`'s retry loop when `pendingBuildSet` is
735
+ * non-empty; also safe to call directly from watchers if the queue contract
736
+ * evolves.
737
+ */
738
+ const flushPendingBatch = async (): Promise<void> => {
739
+ if (pendingBuildSet.size === 0) return;
740
+ const files = Array.from(pendingBuildSet);
741
+ pendingBuildSet.clear();
742
+
743
+ const kind = classifyBatch(files);
744
+
745
+ // Phase 7.0 R2 Agent D — config-reload: fire ONCE and return. Once a
746
+ // restart has been requested the rest of the batch is moot.
747
+ if (kind === "config-reload") {
748
+ const configFile =
749
+ files.find((f) => isConfigOrEnvFile(normalizeFsPath(f))) ?? files[0];
750
+ await handleConfigReload(configFile);
751
+ return;
752
+ }
753
+
754
+ // Phase 7.0 R2 Agent D — resource-regen: coalesce to ONE generator
755
+ // invocation. If 5 `*.resource.ts` saves arrive in one debounce
756
+ // window we only want `generateResourceArtifacts` run per distinct
757
+ // schema; the coalesce helper dedupes by normalized path.
758
+ if (kind === "resource-regen") {
759
+ const resourceFiles = files.filter((f) =>
760
+ isResourceOrContractFile(normalizeFsPath(f)),
761
+ );
762
+ await handleResourceRegenBatch(resourceFiles);
763
+ return;
764
+ }
765
+
766
+ // Common-dir dominates: one full-reload-adjacent rebuild covers everyone.
767
+ if (kind === "common-dir") {
768
+ await handleFileChange(files.find((f) => isInCommonDir(f)) ?? files[0]);
769
+ return;
770
+ }
771
+
772
+ // Otherwise fan out. Each individual handleFileChange is idempotent —
773
+ // if someone edits 4 siblings the build semaphore serializes them, but
774
+ // none is dropped.
775
+ for (const file of files) {
776
+ try {
777
+ await handleFileChange(file);
778
+ } catch (retryError) {
779
+ console.error(
780
+ "[Mandu HMR] batch flush error:",
781
+ retryError instanceof Error ? retryError.message : String(retryError),
782
+ );
783
+ }
784
+ }
785
+ };
786
+
787
+ /**
788
+ * Phase 7.0 R2 Agent D — dispatch a single config/env change.
789
+ *
790
+ * Fires `onConfigReload` exactly once per batch. The CLI wires this to
791
+ * `restartDevServer()` (`packages/cli/src/commands/dev.ts`) — we don't
792
+ * perform the restart here because the bundler doesn't own the HTTP
793
+ * server lifecycle.
794
+ *
795
+ * Wrapped in `withPerf(HMR_PERF.FILE_DETECT)` so `MANDU_PERF=1` can
796
+ * attribute config-save latency — but the end-to-end "saw save →
797
+ * server ready" marker is owned by the CLI (it knows the restart
798
+ * walltime). No REBUILD_TOTAL marker here — the usual rebuild is
799
+ * replaced by a full restart.
800
+ */
801
+ const handleConfigReload = async (filePath: string): Promise<void> => {
802
+ if (!onConfigReload) {
803
+ console.log(
804
+ `[Mandu HMR] ${path.basename(filePath)} changed — restart required`,
805
+ );
806
+ return;
807
+ }
808
+ mark(HMR_PERF.FILE_DETECT);
809
+ measure(HMR_PERF.FILE_DETECT, HMR_PERF.FILE_DETECT);
810
+ try {
811
+ await Promise.resolve(onConfigReload(filePath));
812
+ } catch (err) {
813
+ console.error(
814
+ `[Mandu HMR] config-reload callback threw:`,
815
+ err instanceof Error ? err.message : String(err),
816
+ );
817
+ }
818
+ };
819
+
820
+ /**
821
+ * Phase 7.0 R2 Agent D — batch-dispatch resource/contract changes.
822
+ *
823
+ * Each distinct file fires `onResourceChange` once. We intentionally
824
+ * call the callback per-file (not once per batch) because the consumer
825
+ * typically needs to:
826
+ * 1. `parseResourceSchema(filePath)` — file-scoped
827
+ * 2. `generateResourceArtifacts(parsed, opts)` — file-scoped
828
+ * 3. re-register the route handlers that depend on the artifacts
829
+ *
830
+ * After all callbacks complete we fire the existing
831
+ * `onSSRChange(SSR_CHANGE_WILDCARD)` once so the SSR registry picks up
832
+ * the regenerated artifacts. This mirrors how common-dir changes
833
+ * already drive the SSR reload path.
834
+ */
835
+ const handleResourceRegenBatch = async (files: readonly string[]): Promise<void> => {
836
+ if (files.length === 0) return;
837
+ const callback = onResourceChange;
838
+ if (callback) {
839
+ await withPerf(HMR_PERF.SSR_HANDLER_RELOAD, async () => {
840
+ // Process sequentially — multiple concurrent `generateResourceArtifacts`
841
+ // racing on the same `.mandu/generated/` tree is a known foot-gun
842
+ // (Bun.write is atomic per file, but the generator writes 4-5
843
+ // sibling files per resource and we don't want partial updates
844
+ // visible to a concurrent SSR handler re-register).
845
+ for (const file of files) {
846
+ try {
847
+ await Promise.resolve(callback(file));
848
+ } catch (err) {
849
+ console.error(
850
+ `[Mandu HMR] resource-change callback threw for ${path.basename(file)}:`,
851
+ err instanceof Error ? err.message : String(err),
852
+ );
853
+ }
854
+ }
855
+ });
856
+ } else {
857
+ console.log(
858
+ `[Mandu HMR] ${files.length} resource/contract file(s) changed — no handler registered`,
859
+ );
860
+ }
861
+ // Fire an SSR invalidation so the routes that consume the regenerated
862
+ // contracts pick up the new Zod schemas. Same signal as common-dir —
863
+ // `ssrChangeQueue` in the CLI serializes this against the resource-
864
+ // change callback above.
865
+ if (onSSRChange) {
866
+ try {
867
+ await Promise.resolve(onSSRChange(SSR_CHANGE_WILDCARD));
868
+ } catch (err) {
869
+ console.error(
870
+ `[Mandu HMR] SSR invalidation after resource regen threw:`,
871
+ err instanceof Error ? err.message : String(err),
872
+ );
873
+ }
874
+ }
875
+ };
876
+
877
+ const handleFileChange = async (changedFile: string): Promise<void> => {
878
+ // 동시 빌드 방지 (#121) — B2 강화: 빌드 중이면 Set에 추가 (drop 방지).
232
879
  if (isBuilding) {
233
- pendingBuildFile = changedFile;
880
+ pendingBuildSet.add(changedFile);
881
+ return;
882
+ }
883
+
884
+ // Phase 7.0 R2 Agent D — pre-dispatch for config/resource/package.json.
885
+ //
886
+ // These go through the SAME per-file debounce (scheduleFileChange →
887
+ // handleFileChange) but bypass `_doBuild` because they don't produce
888
+ // a client bundle. We route them BEFORE setting `isBuilding` so the
889
+ // resource-regen callback is free to enqueue subsequent island edits
890
+ // while it runs — the callback may itself trigger a generator that
891
+ // touches files, and we do not want a recursive isBuilding deadlock.
892
+ const normalized = normalizeFsPath(changedFile);
893
+ if (isConfigOrEnvFile(normalized)) {
894
+ await handleConfigReload(changedFile);
895
+ return;
896
+ }
897
+ if (isResourceOrContractFile(normalized)) {
898
+ await handleResourceRegenBatch([changedFile]);
899
+ return;
900
+ }
901
+ if (isPackageJsonFile(normalized)) {
902
+ // Advisory notification only — a `package.json` save on npm install
903
+ // fires multiple times in <100 ms, so auto-restart would loop. The
904
+ // callback prints a hint but takes no action.
905
+ if (onPackageJsonChange) {
906
+ try {
907
+ onPackageJsonChange(changedFile);
908
+ } catch (err) {
909
+ console.error(
910
+ `[Mandu HMR] package-json callback threw:`,
911
+ err instanceof Error ? err.message : String(err),
912
+ );
913
+ }
914
+ } else {
915
+ console.log(
916
+ `[Mandu HMR] package.json changed — run 'r' to restart when dependencies settle`,
917
+ );
918
+ }
234
919
  return;
235
920
  }
236
921
 
237
922
  isBuilding = true;
923
+ mark("dev:rebuild");
924
+ mark(HMR_PERF.REBUILD_TOTAL);
238
925
  try {
239
926
  await _doBuild(changedFile);
240
927
  } finally {
928
+ measure("dev:rebuild", "dev:rebuild");
929
+ measure(HMR_PERF.REBUILD_TOTAL, HMR_PERF.REBUILD_TOTAL);
241
930
  isBuilding = false;
242
- // 빌드 대기 중인 파일이 있으면 즉시 처리
243
- if (pendingBuildFile) {
244
- const next = pendingBuildFile;
245
- pendingBuildFile = null;
246
- // Catch errors to prevent unhandled promise rejection from killing the watcher (#10)
931
+ // B2: 대기 중인 모든 파일을 batch로 flush.
932
+ if (pendingBuildSet.size > 0) {
247
933
  try {
248
- await handleFileChange(next);
934
+ await flushPendingBatch();
249
935
  } catch (retryError) {
250
- console.error(`❌ Retry build error:`, retryError instanceof Error ? retryError.message : String(retryError));
936
+ console.error(
937
+ `❌ Retry build error:`,
938
+ retryError instanceof Error ? retryError.message : String(retryError),
939
+ );
251
940
  console.log(` ⏳ Waiting for next file change to retry...`);
252
941
  }
253
942
  }
254
943
  }
255
944
  };
256
945
 
946
+ /**
947
+ * Per-file debounce scheduler (B6 fix).
948
+ *
949
+ * Creates or restarts ONE timer keyed by the normalized path. The timer
950
+ * fires `handleFileChange` after `WATCHER_DEBOUNCE` quiet time. If the same
951
+ * file fires again within the window, we reset only that file's timer — a
952
+ * second file keeps its own timeline.
953
+ *
954
+ * Errors from the scheduled handler are caught here to prevent an
955
+ * unhandled promise rejection from killing the watcher loop (#10).
956
+ */
957
+ const scheduleFileChange = (fullPath: string): void => {
958
+ const key = normalizeFsPath(fullPath);
959
+ const existing = perFileTimers.get(key);
960
+ if (existing) clearTimeout(existing);
961
+
962
+ const timer = setTimeout(() => {
963
+ perFileTimers.delete(key);
964
+ mark(HMR_PERF.DEBOUNCE_FLUSH);
965
+ measure(HMR_PERF.DEBOUNCE_FLUSH, HMR_PERF.DEBOUNCE_FLUSH);
966
+ handleFileChange(fullPath).catch((err) => {
967
+ console.error(
968
+ "[Mandu HMR] file-change handler error:",
969
+ err instanceof Error ? err.message : String(err),
970
+ );
971
+ });
972
+ }, TIMEOUTS.WATCHER_DEBOUNCE);
973
+
974
+ perFileTimers.set(key, timer);
975
+ };
976
+
257
977
  const _doBuild = async (changedFile: string) => {
258
978
  const normalizedPath = normalizeFsPath(changedFile);
259
979
 
@@ -340,6 +1060,17 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
340
1060
  if (onAPIChange && apiModuleSet.has(normalizedPath)) {
341
1061
  console.log(`\n🔄 API route changed: ${path.basename(changedFile)}`);
342
1062
  onAPIChange(normalizedPath);
1063
+ return;
1064
+ }
1065
+ // Phase 7.0 R2 Agent D — route middleware change.
1066
+ // `app/**/middleware.ts` isn't in apiModuleSet (not registered as a
1067
+ // route handler) but reuses the API reload path: the underlying
1068
+ // `registerManifestHandlers` re-imports middleware via the bundled
1069
+ // import chain. Falls through to `onAPIChange` so the CLI can
1070
+ // reuse its existing `handleAPIChange` plumbing.
1071
+ if (onAPIChange && isRouteMiddlewareFile(normalizedPath)) {
1072
+ console.log(`\n🔄 Middleware changed: ${path.basename(changedFile)}`);
1073
+ onAPIChange(normalizedPath);
343
1074
  }
344
1075
  return;
345
1076
  }
@@ -385,23 +1116,52 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
385
1116
  }
386
1117
  };
387
1118
 
388
- // 각 디렉토리에 watcher 설정
1119
+ /**
1120
+ * Phase 7.0 R2 Agent D — filter predicate for the main recursive
1121
+ * watchers. Centralized so the config-root watcher (below) and the
1122
+ * directory watchers share the same "is this worth dispatching?"
1123
+ * check. Files that match one of the Agent D kinds are accepted even
1124
+ * though they don't end in `.ts`/`.tsx` — e.g. `.env`.
1125
+ */
1126
+ const shouldDispatch = (normalizedFull: string, filename: string): boolean => {
1127
+ // OS / build-artifact exclusions come first — we never want a
1128
+ // `node_modules` event to even hit the perf marker.
1129
+ if (isExcludedPath(normalizedFull)) return false;
1130
+
1131
+ // Existing contract: TS/TSX files in user source are always eligible.
1132
+ // Agent D kinds extend the accept list to non-TS files so `.env` and
1133
+ // `package.json` can surface.
1134
+ if (filename.endsWith(".ts") || filename.endsWith(".tsx")) return true;
1135
+
1136
+ // Agent D new file kinds.
1137
+ if (
1138
+ isConfigOrEnvFile(normalizedFull) ||
1139
+ isPackageJsonFile(normalizedFull)
1140
+ ) {
1141
+ return true;
1142
+ }
1143
+ return false;
1144
+ };
1145
+
1146
+ // 각 디렉토리에 watcher 설정 — B1/B6 fix
389
1147
  for (const dir of watchDirs) {
390
1148
  try {
391
- const watcher = fs.watch(dir, { recursive: true }, async (event, filename) => {
1149
+ const watcher = fs.watch(dir, { recursive: true }, (event, filename) => {
392
1150
  if (!filename) return;
393
1151
 
394
- // TypeScript/TSX 파일만 감시
395
- if (!filename.endsWith(".ts") && !filename.endsWith(".tsx")) return;
396
-
397
1152
  const fullPath = path.join(dir, filename);
1153
+ const normalizedFull = normalizeFsPath(fullPath);
398
1154
 
399
- // Debounce - 연속 변경 무시
400
- if (debounceTimer) {
401
- clearTimeout(debounceTimer);
402
- }
1155
+ // B1 fix exclude `node_modules`, `.mandu`, `dist`, `build`, OS files.
1156
+ // Must run on the FULL path (filename alone loses directory context when
1157
+ // `recursive:true` reports a deep subpath).
1158
+ if (!shouldDispatch(normalizedFull, filename)) return;
1159
+
1160
+ mark(HMR_PERF.FILE_DETECT);
1161
+ measure(HMR_PERF.FILE_DETECT, HMR_PERF.FILE_DETECT);
403
1162
 
404
- debounceTimer = setTimeout(() => handleFileChange(fullPath), TIMEOUTS.WATCHER_DEBOUNCE);
1163
+ // B6 fix — per-file debounce (replaces global single timer).
1164
+ scheduleFileChange(fullPath);
405
1165
  });
406
1166
 
407
1167
  watchers.push(watcher);
@@ -410,6 +1170,49 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
410
1170
  }
411
1171
  }
412
1172
 
1173
+ /**
1174
+ * Phase 7.0 R2 Agent D — project-root watcher for config/env/package.json.
1175
+ *
1176
+ * Why a SEPARATE watcher:
1177
+ * - `fs.watch(rootDir, { recursive: true })` would fire for every file
1178
+ * in the entire tree — we only want the root-level config files.
1179
+ * The main per-directory watchers already cover `src/`, `app/`,
1180
+ * `spec/`, etc.
1181
+ * - Non-recursive `fs.watch(rootDir)` fires ONLY for direct children,
1182
+ * which is exactly what `mandu.config.ts`, `.env`, `package.json`
1183
+ * need.
1184
+ *
1185
+ * This watcher lives alongside the others in the `watchers` array so
1186
+ * `close()` tears everything down in one pass.
1187
+ */
1188
+ try {
1189
+ const rootWatcher = fs.watch(rootDir, { recursive: false }, (event, filename) => {
1190
+ if (!filename) return;
1191
+
1192
+ const fullPath = path.join(rootDir, filename);
1193
+ const normalizedFull = normalizeFsPath(fullPath);
1194
+
1195
+ // Shared exclusions — even if someone crafts a bizarre `.mandu`
1196
+ // symlink at the project root, the `isExcludedPath` guard catches it.
1197
+ if (isExcludedPath(normalizedFull)) return;
1198
+
1199
+ // Only the three kinds this watcher owns. A random `.md` or
1200
+ // `tsconfig.json` save at the root must NOT be picked up here
1201
+ // (tsconfig is interesting but needs a separate opt-in — outside
1202
+ // this phase's scope).
1203
+ const isConfig = isConfigOrEnvFile(normalizedFull);
1204
+ const isPkg = isPackageJsonFile(normalizedFull);
1205
+ if (!isConfig && !isPkg) return;
1206
+
1207
+ mark(HMR_PERF.FILE_DETECT);
1208
+ measure(HMR_PERF.FILE_DETECT, HMR_PERF.FILE_DETECT);
1209
+ scheduleFileChange(fullPath);
1210
+ });
1211
+ watchers.push(rootWatcher);
1212
+ } catch {
1213
+ console.warn(`⚠️ Cannot watch project root for config/env changes: ${rootDir}`);
1214
+ }
1215
+
413
1216
  if (watchers.length > 0) {
414
1217
  console.log(`👀 Watching ${watchers.length} directories for changes...`);
415
1218
  if (commonWatchDirs.size > 0) {
@@ -423,9 +1226,11 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
423
1226
  return {
424
1227
  initialBuild,
425
1228
  close: () => {
426
- if (debounceTimer) {
427
- clearTimeout(debounceTimer);
1229
+ // B6: clear all per-file timers to release event-loop refs.
1230
+ for (const timer of perFileTimers.values()) {
1231
+ clearTimeout(timer);
428
1232
  }
1233
+ perFileTimers.clear();
429
1234
  for (const watcher of watchers) {
430
1235
  watcher.close();
431
1236
  }
@@ -435,22 +1240,44 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
435
1240
 
436
1241
  /**
437
1242
  * HMR WebSocket 서버
1243
+ *
1244
+ * Phase 7.0 R1 Agent C: added replay buffer (B8), Vite-compat wire format
1245
+ * broadcast, and `?since=<id>` reconnect handshake. The classic
1246
+ * `broadcast(HMRMessage)` API is kept for existing callers; a second
1247
+ * `broadcastVite(ViteHMRPayload)` channel serves external devtools that
1248
+ * speak the Vite HMR WebSocket protocol.
438
1249
  */
439
1250
  export interface HMRServer {
440
1251
  /** 연결된 클라이언트 수 */
441
1252
  clientCount: number;
442
- /** 모든 클라이언트에게 메시지 전송 */
1253
+ /** 모든 클라이언트에게 메시지 전송 — 내부 Mandu 포맷. */
443
1254
  broadcast: (message: HMRMessage) => void;
1255
+ /**
1256
+ * Broadcast a Vite-compat HMR payload. The payload is queued in the
1257
+ * replay buffer so reconnecting clients can resume with `?since=<id>`
1258
+ * and does not need a Mandu-side wrapper shape. External devtools
1259
+ * that speak the Vite HMR protocol consume this directly.
1260
+ */
1261
+ broadcastVite: (payload: ViteHMRPayload) => HMRReplayEnvelope;
444
1262
  /** 서버 중지 */
445
1263
  close: () => void;
446
1264
  /** 재시작 핸들러 등록 */
447
1265
  setRestartHandler: (handler: () => Promise<void>) => void;
1266
+ /**
1267
+ * Diagnostics accessor — current replay-buffer length and the last
1268
+ * broadcast envelope id. Exposed for unit tests; production code
1269
+ * should use `broadcast` / `broadcastVite`.
1270
+ */
1271
+ _inspectReplayBuffer: () => { size: number; lastId: number; oldestId: number | null };
448
1272
  }
449
1273
 
450
1274
  export interface HMRMessage {
451
1275
  type:
452
1276
  | "connected"
453
1277
  | "reload"
1278
+ | "full-reload" // Phase 7.0 — Vite-compat escalation path
1279
+ | "update" // Phase 7.0 — granular update (js / css)
1280
+ | "invalidate" // Phase 7.0 — module requested full reload
454
1281
  | "island-update"
455
1282
  | "layout-update"
456
1283
  | "css-update"
@@ -470,25 +1297,217 @@ export interface HMRMessage {
470
1297
  changeType?: "add" | "change" | "delete";
471
1298
  action?: "approve" | "reject";
472
1299
  ruleId?: string;
1300
+ /** Vite-compat updates array — populated when `type === "update"`. */
1301
+ updates?: Array<{ type: "js-update" | "css-update"; path: string; acceptedPath: string; timestamp: number }>;
1302
+ /** `full-reload` / `invalidate` optional path hint. */
1303
+ path?: string;
1304
+ /** Last rebuild id assigned by the replay buffer (if any). */
1305
+ id?: number;
473
1306
  };
474
1307
  }
475
1308
 
476
1309
  /**
477
1310
  * HMR WebSocket 서버 생성
1311
+ *
1312
+ * Phase 7.0 R1 Agent C additions:
1313
+ * - **Replay buffer (B8)**: every `broadcastVite` payload is enqueued with
1314
+ * a monotonic `id`. Clients reconnect with `?since=<id>` and the server
1315
+ * re-sends anything they missed. Buffer is bounded by
1316
+ * `MAX_REPLAY_BUFFER` entries and `REPLAY_MAX_AGE_MS` age — older
1317
+ * envelopes are pruned, and too-old `since` values trigger a
1318
+ * `full-reload` as the safe fallback.
1319
+ * - **Vite-compat wire format**: `broadcastVite(ViteHMRPayload)` sends
1320
+ * the byte-equivalent of what Vite would emit, so external devtools
1321
+ * / editor plugins that speak Vite's HMR protocol work unchanged.
1322
+ * - **layout-update**: callers (Agent A's `onSSRChange` path) invoke
1323
+ * `broadcast({ type: "layout-update", ... })` when a `layout.tsx`
1324
+ * changes; the client handler forces a full reload.
1325
+ *
1326
+ * The classic `broadcast(HMRMessage)` API is preserved as the internal
1327
+ * Mandu format; both broadcast channels share the same WebSocket.
478
1328
  */
479
- export function createHMRServer(port: number): HMRServer {
1329
+
1330
+ /**
1331
+ * Phase 7.0.S — HMR security options (C-01 / C-03 / C-04 defense).
1332
+ *
1333
+ * The dev HMR WebSocket + `/restart` endpoint bind to loopback by default
1334
+ * and reject cross-origin connections. Remote-dev scenarios (container,
1335
+ * VM, tunnel) go through the Phase 7.1+ explicit-token path.
1336
+ */
1337
+ export interface HMRServerOptions {
1338
+ /**
1339
+ * Network interface to bind. Defaults to "localhost" (loopback only).
1340
+ * Override to "0.0.0.0" ONLY for remote-dev scenarios paired with
1341
+ * explicit `allowedOrigins`.
1342
+ */
1343
+ hostname?: string;
1344
+ /**
1345
+ * Additional origins (beyond `http://localhost:${port}` and
1346
+ * `http://127.0.0.1:${port}`) that may establish WebSocket connections
1347
+ * or POST /restart. Required when binding to non-loopback.
1348
+ */
1349
+ allowedOrigins?: readonly string[];
1350
+ }
1351
+
1352
+ export function createHMRServer(
1353
+ port: number,
1354
+ options: HMRServerOptions = {},
1355
+ ): HMRServer {
480
1356
  const clients = new Set<{ send: (data: string) => void; close: () => void }>();
481
1357
  const hmrPort = port + PORTS.HMR_OFFSET;
1358
+ const hostname = options.hostname ?? "localhost";
1359
+ // Build Origin allowlist. Same-origin (main dev server) is always allowed.
1360
+ // Both `localhost` and `127.0.0.1` forms are included because browsers
1361
+ // resolve `localhost` ambiguously (IPv4 vs IPv6) and some OS stacks
1362
+ // return one vs the other.
1363
+ const allowedOrigins = new Set<string>([
1364
+ `http://localhost:${port}`,
1365
+ `http://127.0.0.1:${port}`,
1366
+ ...(options.allowedOrigins ?? []),
1367
+ ]);
482
1368
  let restartHandler: (() => Promise<void>) | null = null;
483
1369
 
1370
+ // ─── Replay buffer (B8) ────────────────────────────────────────────────
1371
+ //
1372
+ // Monotonic counter; resets to 0 on server boot (restart is a full
1373
+ // reload anyway so clients can't meaningfully resume across it).
1374
+ let lastRebuildId = 0;
1375
+ const replayBuffer: HMRReplayEnvelope[] = [];
1376
+
1377
+ /** Drop envelopes older than `REPLAY_MAX_AGE_MS`. Called opportunistically. */
1378
+ const pruneOldReplays = (): void => {
1379
+ const cutoff = Date.now() - REPLAY_MAX_AGE_MS;
1380
+ // Buffer is chronological (push-only, shift-from-front), so one-pass prune.
1381
+ while (replayBuffer.length > 0 && replayBuffer[0]!.timestamp < cutoff) {
1382
+ replayBuffer.shift();
1383
+ }
1384
+ };
1385
+
1386
+ /**
1387
+ * Append a Vite payload to the replay buffer. Returns the envelope
1388
+ * that was queued so the caller can inspect its id (used in tests and
1389
+ * by the `broadcast` path to echo the id into the internal message).
1390
+ */
1391
+ const enqueueReplay = (payload: ViteHMRPayload): HMRReplayEnvelope => {
1392
+ mark(HMR_PERF.HMR_REPLAY_ENQUEUE);
1393
+ lastRebuildId += 1;
1394
+ const envelope: HMRReplayEnvelope = {
1395
+ id: lastRebuildId,
1396
+ timestamp: Date.now(),
1397
+ payload,
1398
+ };
1399
+ replayBuffer.push(envelope);
1400
+ // Bound by size first (cheap), then by age (slightly more work but
1401
+ // still O(n) amortized across inserts).
1402
+ while (replayBuffer.length > MAX_REPLAY_BUFFER) {
1403
+ replayBuffer.shift();
1404
+ }
1405
+ pruneOldReplays();
1406
+ measure(HMR_PERF.HMR_REPLAY_ENQUEUE, HMR_PERF.HMR_REPLAY_ENQUEUE);
1407
+ return envelope;
1408
+ };
1409
+
1410
+ /**
1411
+ * Parse `?since=<id>` from the upgrade URL. Returns `null` for missing
1412
+ * or malformed values (negative / non-numeric / NaN). Treating a
1413
+ * malformed value as `null` is the safe choice — the client simply
1414
+ * gets the default `connected` handshake.
1415
+ */
1416
+ const parseSince = (url: URL): number | null => {
1417
+ const raw = url.searchParams.get("since");
1418
+ if (raw === null || raw === "") return null;
1419
+ const n = Number(raw);
1420
+ if (!Number.isFinite(n) || n < 0) return null;
1421
+ return Math.floor(n);
1422
+ };
1423
+
1424
+ /**
1425
+ * Handle the post-upgrade replay flush. The three branches:
1426
+ *
1427
+ * 1. `since === null` → new client, send `connected` only.
1428
+ * 2. `since >= lastRebuildId` → client is already current, send
1429
+ * `connected` and nothing else.
1430
+ * 3. `since < oldestId` → client missed more than the buffer holds,
1431
+ * force a `full-reload`.
1432
+ * 4. otherwise → re-send every envelope with `id > since`.
1433
+ *
1434
+ * The caller (WS `open` handler) only knows the raw `since`; we do
1435
+ * the dispatch here so the logic stays co-located with the buffer.
1436
+ */
1437
+ const flushReplayToClient = (
1438
+ ws: { send: (data: string) => void },
1439
+ since: number | null,
1440
+ ): void => {
1441
+ if (since === null) {
1442
+ ws.send(
1443
+ JSON.stringify({ type: "connected", data: { timestamp: Date.now(), id: lastRebuildId } }),
1444
+ );
1445
+ return;
1446
+ }
1447
+ // Already caught up — nothing to replay but still greet.
1448
+ if (since >= lastRebuildId) {
1449
+ ws.send(
1450
+ JSON.stringify({ type: "connected", data: { timestamp: Date.now(), id: lastRebuildId } }),
1451
+ );
1452
+ return;
1453
+ }
1454
+ pruneOldReplays();
1455
+ const oldestId = replayBuffer.length > 0 ? replayBuffer[0]!.id : null;
1456
+ if (oldestId === null || since < oldestId) {
1457
+ // Missed too much — force a full reload.
1458
+ mark(HMR_PERF.HMR_REPLAY_FLUSH);
1459
+ ws.send(
1460
+ JSON.stringify({
1461
+ type: "full-reload",
1462
+ data: { timestamp: Date.now(), message: "replay-buffer-exhausted" },
1463
+ }),
1464
+ );
1465
+ measure(HMR_PERF.HMR_REPLAY_FLUSH, HMR_PERF.HMR_REPLAY_FLUSH);
1466
+ return;
1467
+ }
1468
+ // Replay every envelope strictly newer than `since`.
1469
+ mark(HMR_PERF.HMR_REPLAY_FLUSH);
1470
+ ws.send(
1471
+ JSON.stringify({ type: "connected", data: { timestamp: Date.now(), id: lastRebuildId } }),
1472
+ );
1473
+ for (const env of replayBuffer) {
1474
+ if (env.id <= since) continue;
1475
+ // Wrap in a thin envelope so the client can see the id; keep the
1476
+ // Vite payload verbatim for external consumers.
1477
+ ws.send(
1478
+ JSON.stringify({
1479
+ type: "vite-replay",
1480
+ data: { id: env.id, timestamp: env.timestamp },
1481
+ payload: env.payload,
1482
+ }),
1483
+ );
1484
+ }
1485
+ measure(HMR_PERF.HMR_REPLAY_FLUSH, HMR_PERF.HMR_REPLAY_FLUSH);
1486
+ };
1487
+
484
1488
  const corsHeaders: Record<string, string> = {
485
1489
  "Access-Control-Allow-Origin": `http://localhost:${port}`,
486
1490
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
487
1491
  "Access-Control-Allow-Headers": "Content-Type",
488
1492
  };
489
1493
 
490
- const server = Bun.serve({
1494
+ // Type parameter carries the `?since=<id>` value from the upgrade
1495
+ // request to the WebSocket `open` handler via Bun's per-connection
1496
+ // data slot. Without the generic, `server.upgrade(req, { data })`
1497
+ // types `data` as `undefined` — Bun.serve has no runtime inference.
1498
+ interface WSData {
1499
+ since: number | null;
1500
+ }
1501
+
1502
+ // Phase 7.0.S — per-connection `invalidate` rate limit state.
1503
+ // WeakMap keyed by the WS object so entries are GC'd when the socket
1504
+ // closes; no manual cleanup needed. Per-connection (not global) so one
1505
+ // abusive client cannot DoS the rate limit for legitimate ones.
1506
+ const invalidateCounters = new WeakMap<object, { count: number; windowStart: number }>();
1507
+
1508
+ const server = Bun.serve<WSData, never>({
491
1509
  port: hmrPort,
1510
+ hostname, // Phase 7.0.S C-03 fix: bind to loopback by default.
492
1511
  async fetch(req, server) {
493
1512
  const url = new URL(req.url);
494
1513
 
@@ -497,6 +1516,25 @@ export function createHMRServer(port: number): HMRServer {
497
1516
  return new Response(null, { status: 204, headers: corsHeaders });
498
1517
  }
499
1518
 
1519
+ // Phase 7.0.S Origin allowlist check (C-01 / C-04 defense).
1520
+ //
1521
+ // CSWSH (Cross-Site WebSocket Hijacking) defense: browsers DO NOT
1522
+ // enforce same-origin for WebSocket connections. Any cross-origin
1523
+ // page that reaches this port (C-03 fix narrows that to loopback)
1524
+ // could open a WS and exfiltrate HMR events or trigger reloads
1525
+ // without this check.
1526
+ //
1527
+ // We accept missing Origin (null / absent) — native clients (curl,
1528
+ // test WebSocket connections, CLI devtools) legitimately omit it and
1529
+ // the loopback binding (C-03) is the primary defense for them.
1530
+ const origin = req.headers.get("origin");
1531
+ if (origin !== null && !allowedOrigins.has(origin)) {
1532
+ return new Response(
1533
+ JSON.stringify({ error: "origin not allowed" }),
1534
+ { status: 403, headers: { ...corsHeaders, "Content-Type": "application/json" } }
1535
+ );
1536
+ }
1537
+
500
1538
  // POST /restart → 재시작 핸들러 호출
501
1539
  if (req.method === "POST" && url.pathname === "/restart") {
502
1540
  if (!restartHandler) {
@@ -522,8 +1560,10 @@ export function createHMRServer(port: number): HMRServer {
522
1560
  }
523
1561
  }
524
1562
 
525
- // WebSocket 업그레이드
526
- if (server.upgrade(req)) {
1563
+ // WebSocket 업그레이드 — stash `since` in per-connection data so the
1564
+ // `open` handler has access to it.
1565
+ const since = parseSince(url);
1566
+ if (server.upgrade(req, { data: { since } })) {
527
1567
  return;
528
1568
  }
529
1569
 
@@ -533,6 +1573,8 @@ export function createHMRServer(port: number): HMRServer {
533
1573
  status: "ok",
534
1574
  clients: clients.size,
535
1575
  port: hmrPort,
1576
+ lastRebuildId,
1577
+ replayBufferSize: replayBuffer.length,
536
1578
  }),
537
1579
  {
538
1580
  headers: { ...corsHeaders, "Content-Type": "application/json" },
@@ -542,45 +1584,167 @@ export function createHMRServer(port: number): HMRServer {
542
1584
  websocket: {
543
1585
  open(ws) {
544
1586
  clients.add(ws);
545
- ws.send(
546
- JSON.stringify({
547
- type: "connected",
548
- data: { timestamp: Date.now() },
549
- })
550
- );
1587
+ // `since` is attached by the upgrade handler (typed via WSData).
1588
+ // It's `null` when the client didn't supply `?since=` (new tab).
1589
+ const since = ws.data?.since ?? null;
1590
+ flushReplayToClient(ws, since);
551
1591
  },
552
1592
  close(ws) {
553
1593
  clients.delete(ws);
554
1594
  },
555
1595
  message(ws, message) {
556
- // 클라이언트로부터의 ping 처리
1596
+ // 클라이언트로부터의 ping 처리 + invalidate 수신.
557
1597
  try {
558
1598
  const data = JSON.parse(String(message));
559
1599
  if (data.type === "ping") {
560
1600
  ws.send(JSON.stringify({ type: "pong", data: { timestamp: Date.now() } }));
1601
+ return;
1602
+ }
1603
+ if (data.type === "invalidate") {
1604
+ // Phase 7.0.S — per-connection rate limit (C-02 / H-01 defense).
1605
+ // A malicious same-machine process (even with C-01 + C-03 in
1606
+ // place) could flood `invalidate` messages to DoS connected
1607
+ // browsers via broadcast. 10 invalidates per 10-second window
1608
+ // is >100× what legitimate HMR usage produces.
1609
+ const now = Date.now();
1610
+ let counter = invalidateCounters.get(ws as object);
1611
+ if (!counter || now - counter.windowStart > 10_000) {
1612
+ counter = { count: 0, windowStart: now };
1613
+ invalidateCounters.set(ws as object, counter);
1614
+ }
1615
+ counter.count += 1;
1616
+ if (counter.count > 10) {
1617
+ // Silent drop — do not reply to abusive clients.
1618
+ return;
1619
+ }
1620
+ // Reject oversized fields. 10 KB message / 2 KB moduleUrl is
1621
+ // plenty for diagnostics; anything larger is amplification abuse.
1622
+ if (
1623
+ (typeof data.message === "string" && data.message.length > 10_000) ||
1624
+ (typeof data.moduleUrl === "string" && data.moduleUrl.length > 2_000)
1625
+ ) {
1626
+ return;
1627
+ }
1628
+ // A module called `import.meta.hot.invalidate()` in the
1629
+ // browser. Phase 7.0 v0.1 response: escalate to full reload
1630
+ // on the module that invalidated. Broadcasting through
1631
+ // `broadcastVite` puts it in the replay buffer too so other
1632
+ // tabs observe the same reload.
1633
+ const path =
1634
+ typeof data.moduleUrl === "string" ? data.moduleUrl : undefined;
1635
+ const payload: ViteHMRPayload = { type: "full-reload", path };
1636
+ const envelope = enqueueReplay(payload);
1637
+ const wire = JSON.stringify({
1638
+ type: "full-reload",
1639
+ data: {
1640
+ timestamp: envelope.timestamp,
1641
+ id: envelope.id,
1642
+ path,
1643
+ message:
1644
+ typeof data.message === "string" ? data.message : undefined,
1645
+ },
1646
+ });
1647
+ for (const client of clients) {
1648
+ try {
1649
+ client.send(wire);
1650
+ } catch {
1651
+ clients.delete(client);
1652
+ }
1653
+ }
561
1654
  }
562
1655
  } catch {
563
- // 무시
1656
+ // 무시 — malformed JSON from the client is never fatal.
564
1657
  }
565
1658
  },
566
1659
  },
567
1660
  });
568
1661
 
569
- console.log(`🔥 HMR server running on ws://localhost:${hmrPort}`);
1662
+ console.log(`🔥 HMR server running on ws://${hostname}:${hmrPort}`);
1663
+
1664
+ /**
1665
+ * Send a payload string to every connected client, pruning dead
1666
+ * sockets as a side effect. Factored out so `broadcast` and
1667
+ * `broadcastVite` share the fan-out loop exactly.
1668
+ */
1669
+ const fanout = (payload: string): void => {
1670
+ for (const client of clients) {
1671
+ try {
1672
+ client.send(payload);
1673
+ } catch {
1674
+ clients.delete(client);
1675
+ }
1676
+ }
1677
+ };
570
1678
 
571
1679
  return {
572
1680
  get clientCount() {
573
1681
  return clients.size;
574
1682
  },
575
1683
  broadcast: (message: HMRMessage) => {
576
- const payload = JSON.stringify(message);
577
- for (const client of clients) {
578
- try {
579
- client.send(payload);
580
- } catch {
581
- clients.delete(client);
582
- }
1684
+ mark(HMR_PERF.HMR_BROADCAST);
1685
+ // For message types that map to a Vite payload, enqueue a replay
1686
+ // envelope so reconnecting clients also see the event. The mapping
1687
+ // is conservative — only canonical cases get queued; devtools and
1688
+ // guard-violation events are ephemeral.
1689
+ let envelopeId: number | undefined;
1690
+ if (message.type === "reload" || message.type === "full-reload") {
1691
+ const envelope = enqueueReplay({ type: "full-reload", path: message.data?.path });
1692
+ envelopeId = envelope.id;
1693
+ } else if (message.type === "island-update" || message.type === "layout-update") {
1694
+ // These are Mandu-internal shapes; record a generic `update`
1695
+ // envelope so reconnecting clients at least know something
1696
+ // changed. Full-fidelity replay of Mandu messages is intentional
1697
+ // future work — we'd need a second buffer per payload shape.
1698
+ const envelope = enqueueReplay({
1699
+ type: "update",
1700
+ updates: [
1701
+ {
1702
+ type: "js-update",
1703
+ path: message.data?.layoutPath ?? message.data?.routeId ?? "?",
1704
+ acceptedPath: message.data?.layoutPath ?? message.data?.routeId ?? "?",
1705
+ timestamp: Date.now(),
1706
+ },
1707
+ ],
1708
+ });
1709
+ envelopeId = envelope.id;
1710
+ } else if (message.type === "css-update") {
1711
+ const envelope = enqueueReplay({
1712
+ type: "update",
1713
+ updates: [
1714
+ {
1715
+ type: "css-update",
1716
+ path: message.data?.cssPath ?? "/.mandu/client/globals.css",
1717
+ acceptedPath: message.data?.cssPath ?? "/.mandu/client/globals.css",
1718
+ timestamp: Date.now(),
1719
+ },
1720
+ ],
1721
+ });
1722
+ envelopeId = envelope.id;
583
1723
  }
1724
+
1725
+ const outgoing: HMRMessage =
1726
+ envelopeId !== undefined
1727
+ ? { ...message, data: { ...(message.data ?? {}), id: envelopeId } }
1728
+ : message;
1729
+ const payload = JSON.stringify(outgoing);
1730
+ fanout(payload);
1731
+ measure(HMR_PERF.HMR_BROADCAST, HMR_PERF.HMR_BROADCAST);
1732
+ },
1733
+ broadcastVite: (payload: ViteHMRPayload): HMRReplayEnvelope => {
1734
+ mark(HMR_PERF.HMR_BROADCAST);
1735
+ const envelope = enqueueReplay(payload);
1736
+ // Wire format: wrap with the envelope id so replayed and live
1737
+ // messages are indistinguishable on the client side. External
1738
+ // devtools that only care about the raw Vite payload can read
1739
+ // `payload` directly.
1740
+ const wire = JSON.stringify({
1741
+ type: "vite",
1742
+ data: { id: envelope.id, timestamp: envelope.timestamp },
1743
+ payload,
1744
+ });
1745
+ fanout(wire);
1746
+ measure(HMR_PERF.HMR_BROADCAST, HMR_PERF.HMR_BROADCAST);
1747
+ return envelope;
584
1748
  },
585
1749
  close: () => {
586
1750
  for (const client of clients) {
@@ -596,12 +1760,138 @@ export function createHMRServer(port: number): HMRServer {
596
1760
  setRestartHandler: (handler: () => Promise<void>) => {
597
1761
  restartHandler = handler;
598
1762
  },
1763
+ _inspectReplayBuffer: () => ({
1764
+ size: replayBuffer.length,
1765
+ lastId: lastRebuildId,
1766
+ oldestId: replayBuffer.length > 0 ? replayBuffer[0]!.id : null,
1767
+ }),
599
1768
  };
600
1769
  }
601
1770
 
1771
+ /**
1772
+ * Phase 7.1 B-3 — HTML preamble for React Fast Refresh.
1773
+ *
1774
+ * Emitted by the SSR renderer in dev mode, **before** any island JS
1775
+ * evaluates. Two concerns, one <script>:
1776
+ *
1777
+ * 1. Install inert stubs for `$RefreshReg$` / `$RefreshSig$` on
1778
+ * `window`. Bun's `reactFastRefresh: true` transform inserts calls
1779
+ * to these at the top of every transformed module; if they are
1780
+ * undefined when the module body runs, we get a runtime error and
1781
+ * the island never hydrates. Vite's preamble does the same inline
1782
+ * stub install for the same reason.
1783
+ *
1784
+ * 2. Fire a dynamic `import()` of the bundled glue (`_fast-refresh-
1785
+ * runtime.js`), which in turn `await`s the real `react-refresh/
1786
+ * runtime`, installs `window.__MANDU_HMR__`, and upgrades the
1787
+ * stubs to live wrappers that forward to the refresh runtime. The
1788
+ * race between "module evaluates and calls `$RefreshReg$`" and
1789
+ * "glue has upgraded the stubs" is benign — registrations that
1790
+ * land on the stub are simply no-ops, which at worst means the
1791
+ * very first mount isn't tracked. Subsequent hot swaps land on
1792
+ * the live wrappers and work normally.
1793
+ *
1794
+ * The emitted script is **inline** (no `type="module"`, no external
1795
+ * src). This is deliberate: the stubs must exist before *any* module
1796
+ * script runs, and inline execution blocks the parser. The dynamic
1797
+ * import inside the inline script is non-blocking so we don't stall
1798
+ * First Contentful Paint.
1799
+ *
1800
+ * CSP note: the inline <script> uses no `eval` or `new Function`; it
1801
+ * only calls `Object.assign`, defines functions, and initiates an
1802
+ * `import()`. All of these are permitted under `script-src 'self'
1803
+ * 'unsafe-inline'` which is Mandu's default dev CSP (production CSP
1804
+ * forbids `unsafe-inline`, but this preamble is dev-only).
1805
+ *
1806
+ * `glueUrl` and `runtimeUrl` come from the build manifest's
1807
+ * `shared.fastRefresh` block (populated only in dev). Both must be
1808
+ * absolute URLs served from the same origin as the HTML, which our
1809
+ * bundler always guarantees (`/.mandu/client/...`).
1810
+ */
1811
+ export function generateFastRefreshPreamble(
1812
+ glueUrl: string,
1813
+ runtimeUrl: string,
1814
+ ): string {
1815
+ // Both URLs must be non-empty. If either is missing (e.g. vendor
1816
+ // shim build failed), the caller (ssr.ts) should skip this preamble
1817
+ // entirely — defensive guard here keeps the output valid regardless.
1818
+ if (!glueUrl || !runtimeUrl) {
1819
+ return `<script>/* Mandu Fast Refresh: missing runtime assets, preamble skipped */</script>`;
1820
+ }
1821
+ // JSON.stringify escapes the URLs safely for inline `<script>`:
1822
+ // - quotes produce a valid JS string literal
1823
+ // - forward-slashes / backslashes are handled
1824
+ // We also `split('</')` to avoid a stray `</script>` sequence in the
1825
+ // URL bytes breaking the enclosing tag. This is the same defense
1826
+ // Vite uses in its own preamble emitter.
1827
+ const glueLit = JSON.stringify(glueUrl).split("</").join('<"+"/');
1828
+ const runtimeLit = JSON.stringify(runtimeUrl).split("</").join('<"+"/');
1829
+ return `<script>
1830
+ // Phase 7.1 B-3 React Fast Refresh preamble (Mandu dev-only)
1831
+ (function () {
1832
+ if (typeof window === "undefined") return;
1833
+ // Install inert stubs so transformed modules that run BEFORE the
1834
+ // async runtime upgrade don't hit ReferenceError on $RefreshReg$.
1835
+ if (!window.$RefreshReg$) window.$RefreshReg$ = function () {};
1836
+ if (!window.$RefreshSig$) window.$RefreshSig$ = function () { return function (t) { return t; }; };
1837
+ // Async-load the glue; failures are reported but never throw out of
1838
+ // the preamble — a missing runtime degrades to full-reload HMR.
1839
+ import(${glueLit})
1840
+ .then(function (mod) {
1841
+ var runtimeImport = function () { return import(${runtimeLit}); };
1842
+ if (mod && typeof mod.installGlobal === "function") {
1843
+ return mod.installGlobal({ runtimeImport: runtimeImport });
1844
+ }
1845
+ })
1846
+ .catch(function (err) {
1847
+ console.error("[Mandu Fast Refresh] preamble failed:", err);
1848
+ });
1849
+ })();
1850
+ </script>`;
1851
+ }
1852
+
602
1853
  /**
603
1854
  * HMR 클라이언트 스크립트 생성
604
- * 브라우저에서 실행되어 HMR 서버와 연결
1855
+ * 브라우저에서 실행되어 HMR 서버와 연결.
1856
+ *
1857
+ * Phase 7.0 R1 Agent C additions:
1858
+ * - **`?since=<lastSeenId>` on reconnect**: the client tracks the id of
1859
+ * the last envelope it processed (from `data.id`). On reconnect it
1860
+ * appends `?since=<id>` to the WS URL so the server can replay
1861
+ * anything missed while the socket was down.
1862
+ * - **Vite-compat payload handling**: messages of shape
1863
+ * `{type:"vite", payload:<ViteHMRPayload>}` and `{type:"vite-replay", payload:<...>}`
1864
+ * are dispatched through the same code path — both deliver a Vite
1865
+ * update wrapped with an envelope id.
1866
+ * - **`full-reload` type**: emitted when a module invalidates or the
1867
+ * replay buffer is exhausted; force a full page reload.
1868
+ * - **`layout-update`**: unchanged behavior (full reload) — the server
1869
+ * now actually broadcasts this (A's `onSSRChange` path).
1870
+ * - **`import.meta.hot.invalidate()` upstream channel**: the runtime
1871
+ * calls into `window.__MANDU_HMR_SEND__({type:"invalidate", moduleUrl})`
1872
+ * which we forward on the socket. This is the only place the client
1873
+ * sends non-ping frames.
1874
+ * - **Vite event dispatch**: `vite:beforeUpdate` fires before an
1875
+ * `update` or `vite` payload is applied; `vite:afterUpdate` after;
1876
+ * `vite:beforeFullReload` before `full-reload`; `vite:error` for
1877
+ * errors. Listeners are registered in `ManduHot.on()` (runtime).
1878
+ *
1879
+ * Phase 7.2 Agent B additions (HDR — Hot Data Revalidation):
1880
+ * - **`slot-refetch` message type**: when a `.slot.ts` file changes, the
1881
+ * CLI side broadcasts `{ type: "slot-refetch", data: { routeId,
1882
+ * slotPath, id, timestamp } }`. The client script checks whether the
1883
+ * current browser location belongs to that `routeId`; if so it fetches
1884
+ * the current URL with `X-Mandu-HDR: 1`, receives JSON loader data,
1885
+ * then calls `window.__MANDU_ROUTER_REVALIDATE__(routeId, loaderData)`
1886
+ * wrapped in `React.startTransition`. Form inputs / scroll / focus
1887
+ * survive because the React tree never unmounts.
1888
+ * - **Fallback semantics**: if the route doesn't match, the router
1889
+ * revalidate hook is missing, the fetch fails, `MANDU_HDR=0` is set,
1890
+ * or any other failure path — the client falls back to
1891
+ * `location.reload()`. This preserves the Phase 7.1 "always-safe"
1892
+ * invariant: a broken HDR path never leaves the user on a stale page.
1893
+ * - **`hdr:refetch` perf marker**: fires on successful HDR apply for
1894
+ * Agent F's bench script to aggregate. P95 target ≤150 ms.
605
1895
  */
606
1896
  export function generateHMRClientScript(port: number): string {
607
1897
  const hmrPort = port + PORTS.HMR_OFFSET;
@@ -612,16 +1902,53 @@ export function generateHMRClientScript(port: number): string {
612
1902
  const HMR_PORT = ${hmrPort};
613
1903
  let ws = null;
614
1904
  let reconnectAttempts = 0;
1905
+ // Last envelope id we successfully applied. Used in the ?since= query
1906
+ // on reconnect. Starts at 0 (means "no envelopes seen"); the server
1907
+ // interprets 0 as "replay everything that's still in the buffer".
1908
+ let lastSeenId = 0;
615
1909
  const maxReconnectAttempts = ${TIMEOUTS.HMR_MAX_RECONNECT};
616
1910
  const reconnectDelay = ${TIMEOUTS.HMR_RECONNECT_DELAY};
617
1911
  const staleIslands = new Set();
618
1912
 
1913
+ // Vite-compat event listeners. Registered by the runtime hmr-client.ts
1914
+ // via \`window.__MANDU_HMR_EVENT__(event, cb)\`; we fan out here because
1915
+ // dispatchEvent() in the runtime walks every module's listener set,
1916
+ // which the client script cannot directly import.
1917
+ const viteListeners = Object.create(null);
1918
+ window.__MANDU_HMR_EVENT__ = function(event, cb) {
1919
+ if (!viteListeners[event]) viteListeners[event] = new Set();
1920
+ viteListeners[event].add(cb);
1921
+ return function off() {
1922
+ if (viteListeners[event]) viteListeners[event].delete(cb);
1923
+ };
1924
+ };
1925
+ function fireViteEvent(event, payload) {
1926
+ var set = viteListeners[event];
1927
+ if (!set) return;
1928
+ set.forEach(function(cb) {
1929
+ try { cb(payload); } catch (e) { console.error('[Mandu HMR]', event, 'listener threw:', e); }
1930
+ });
1931
+ }
1932
+
1933
+ // Upstream channel: user code calls invalidate() in the runtime, which
1934
+ // asks the client script to push a message back to the server.
1935
+ window.__MANDU_HMR_SEND__ = function(payload) {
1936
+ if (ws && ws.readyState === 1 /* OPEN */) {
1937
+ try {
1938
+ ws.send(JSON.stringify(payload));
1939
+ } catch (e) {
1940
+ console.error('[Mandu HMR] send failed:', e);
1941
+ }
1942
+ }
1943
+ };
1944
+
619
1945
  function connect() {
620
1946
  try {
621
- ws = new WebSocket('ws://' + window.location.hostname + ':' + HMR_PORT);
1947
+ var qs = lastSeenId > 0 ? '?since=' + lastSeenId : '';
1948
+ ws = new WebSocket('ws://' + window.location.hostname + ':' + HMR_PORT + '/' + qs);
622
1949
 
623
1950
  ws.onopen = function() {
624
- console.log('[Mandu HMR] Connected');
1951
+ console.log('[Mandu HMR] Connected' + (lastSeenId > 0 ? ' (since ' + lastSeenId + ')' : ''));
625
1952
  reconnectAttempts = 0;
626
1953
  };
627
1954
 
@@ -657,18 +1984,227 @@ export function generateHMRClientScript(port: number): string {
657
1984
  }
658
1985
  }
659
1986
 
1987
+ /**
1988
+ * Update lastSeenId from a message's envelope id. Only accept
1989
+ * monotonically increasing values so out-of-order replay (shouldn't
1990
+ * happen, but be defensive) can't move us backwards.
1991
+ */
1992
+ function recordEnvelopeId(message) {
1993
+ var id = message && message.data && message.data.id;
1994
+ if (typeof id === 'number' && id > lastSeenId) lastSeenId = id;
1995
+ }
1996
+
1997
+ function applyViteUpdate(payload) {
1998
+ // payload is a ViteHMRPayload shape. Phase 7.0 v0.1 handles 'update'
1999
+ // as a CSS swap for the css-update sub-type and falls back to a
2000
+ // full reload for js-update (until Fast Refresh lands). 'full-reload'
2001
+ // / 'error' / 'connected' are handled inline.
2002
+ if (!payload || !payload.type) return;
2003
+ switch (payload.type) {
2004
+ case 'connected':
2005
+ // Already greeted inline; nothing else to do.
2006
+ return;
2007
+ case 'update':
2008
+ fireViteEvent('vite:beforeUpdate', payload);
2009
+ if (Array.isArray(payload.updates)) {
2010
+ for (var i = 0; i < payload.updates.length; i++) {
2011
+ var u = payload.updates[i];
2012
+ if (u.type === 'css-update') {
2013
+ // Re-timestamp any matching <link>.
2014
+ var links = document.querySelectorAll('link[rel="stylesheet"]');
2015
+ links.forEach(function(link) {
2016
+ var href = link.getAttribute('href') || '';
2017
+ var baseHref = href.split('?')[0];
2018
+ if (baseHref === u.path || href.includes('.mandu/client')) {
2019
+ link.setAttribute('href', baseHref + '?t=' + Date.now());
2020
+ }
2021
+ });
2022
+ }
2023
+ }
2024
+ }
2025
+ fireViteEvent('vite:afterUpdate', payload);
2026
+ return;
2027
+ case 'full-reload':
2028
+ fireViteEvent('vite:beforeFullReload', payload);
2029
+ location.reload();
2030
+ return;
2031
+ case 'prune':
2032
+ // Phase 7.1+ — ignore for now.
2033
+ return;
2034
+ case 'error':
2035
+ fireViteEvent('vite:error', payload);
2036
+ if (payload.err) showErrorOverlay(payload.err.message || 'Build error');
2037
+ return;
2038
+ case 'custom':
2039
+ // Plugin custom events — route known events to their handlers.
2040
+ // Phase 7.2 HDR: slot-refetch rides this channel so we don't
2041
+ // have to extend HMRMessage (which lives outside this section
2042
+ // of the file).
2043
+ if (payload.event === 'mandu:slot-refetch') {
2044
+ handleSlotRefetch(payload.data || {});
2045
+ return;
2046
+ }
2047
+ // Unknown custom events are dropped silently — Vite's own
2048
+ // plugin ecosystem may emit anything.
2049
+ return;
2050
+ }
2051
+ }
2052
+
2053
+ // ─── Phase 7.2 HDR (Hot Data Revalidation) ──────────────────────────
2054
+ //
2055
+ // When a .slot.ts file changes the server broadcasts
2056
+ // { type: 'slot-refetch', data: { routeId, slotPath, id, timestamp } }
2057
+ // instead of the legacy 'reload'. We refetch the current URL with
2058
+ // X-Mandu-HDR: 1 to get JSON loader data, then hand it to a
2059
+ // framework revalidate hook that wraps the props update in
2060
+ // React.startTransition so form state / scroll / focus survive.
2061
+ //
2062
+ // The hook path:
2063
+ // 1. window.__MANDU_ROUTER_REVALIDATE__(routeId, loaderData) — the
2064
+ // framework router installs this at boot. If absent we fall back
2065
+ // to full reload (minimum-viable-HDR path).
2066
+ // 2. window.__MANDU_HDR__.perfMark(name) — optional perf hook.
2067
+ // Bench script reads HMR_PERF.HDR_REFETCH ('hdr:refetch').
2068
+ function getCurrentRouteId() {
2069
+ // SSR injects __MANDU_ROUTE__ on the window. Router state (if
2070
+ // client-side navigation happened) lives under __MANDU_ROUTER_STATE__.
2071
+ // Prefer the router's state when both are present.
2072
+ var routerState = window.__MANDU_ROUTER_STATE__;
2073
+ if (routerState && routerState.currentRoute && routerState.currentRoute.id) {
2074
+ return String(routerState.currentRoute.id);
2075
+ }
2076
+ var route = window.__MANDU_ROUTE__;
2077
+ if (route && route.id) return String(route.id);
2078
+ return null;
2079
+ }
2080
+
2081
+ function hdrDisabled() {
2082
+ // Opt-out: projects with unusual CSP / environments may disable HDR
2083
+ // via a global flag. The bundler sets this from MANDU_HDR=0 (see
2084
+ // the bootScript path in SSR rendering).
2085
+ return window.__MANDU_HDR_DISABLED__ === true;
2086
+ }
2087
+
2088
+ function hdrFallbackFullReload(reason) {
2089
+ console.log('[Mandu HDR] Fallback full reload' + (reason ? ' (' + reason + ')' : ''));
2090
+ location.reload();
2091
+ }
2092
+
2093
+ function hdrMark(name, data) {
2094
+ try {
2095
+ if (window.__MANDU_HDR__ && typeof window.__MANDU_HDR__.perfMark === 'function') {
2096
+ window.__MANDU_HDR__.perfMark(name, data);
2097
+ }
2098
+ } catch (_) {}
2099
+ }
2100
+
2101
+ function handleSlotRefetch(data) {
2102
+ var routeId = data && typeof data.routeId === 'string' ? data.routeId : null;
2103
+ if (!routeId) {
2104
+ hdrFallbackFullReload('no-routeId');
2105
+ return;
2106
+ }
2107
+ if (hdrDisabled()) {
2108
+ hdrFallbackFullReload('disabled');
2109
+ return;
2110
+ }
2111
+ var currentId = getCurrentRouteId();
2112
+ if (currentId !== routeId) {
2113
+ // Not on the affected route — nothing to revalidate, no reload
2114
+ // either. The next navigation will pick up the fresh loader.
2115
+ console.log('[Mandu HDR] slot-refetch for ' + routeId + ' ignored (current route: ' + currentId + ')');
2116
+ return;
2117
+ }
2118
+ var started = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();
2119
+ hdrMark('hdr:refetch-start', { routeId: routeId, slotPath: data.slotPath });
2120
+ // Build the data URL: current pathname + query + _data=1 marker,
2121
+ // with X-Mandu-HDR header so the server can log + treat it as an
2122
+ // HDR request. _data=1 is the existing SPA navigation contract so
2123
+ // we reuse it here — the server returns
2124
+ // { routeId, pattern, params, loaderData, timestamp }.
2125
+ var url = window.location.pathname + window.location.search;
2126
+ var sep = url.indexOf('?') >= 0 ? '&' : '?';
2127
+ var dataUrl = url + sep + '_data=1';
2128
+ fetch(dataUrl, {
2129
+ credentials: 'same-origin',
2130
+ headers: { 'X-Mandu-HDR': '1' },
2131
+ })
2132
+ .then(function (res) {
2133
+ if (!res.ok) {
2134
+ hdrFallbackFullReload('status-' + res.status);
2135
+ return null;
2136
+ }
2137
+ return res.json();
2138
+ })
2139
+ .then(function (payload) {
2140
+ if (!payload) return;
2141
+ // Revalidate hook. The router installs this from island glue.
2142
+ var revalidate = window.__MANDU_ROUTER_REVALIDATE__;
2143
+ if (typeof revalidate !== 'function') {
2144
+ // Minimum-viable-HDR fallback: the framework router isn't
2145
+ // installed on this page (e.g. pure-SSR with no client
2146
+ // router). Full reload is the honest degrade.
2147
+ hdrFallbackFullReload('no-router');
2148
+ return;
2149
+ }
2150
+ // Apply inside React.startTransition so the prop update
2151
+ // doesn't tear down focus / form inputs / scroll position.
2152
+ // The router hook is responsible for wrapping; we just call it.
2153
+ try {
2154
+ revalidate(routeId, payload.loaderData);
2155
+ var elapsed = (typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now()) - started;
2156
+ console.log('[Mandu HDR] Applied loader data for ' + routeId + ' in ' + elapsed.toFixed(0) + 'ms');
2157
+ hdrMark('hdr:refetch', { routeId: routeId, slotPath: data.slotPath, elapsed: elapsed });
2158
+ } catch (err) {
2159
+ console.error('[Mandu HDR] Revalidate threw:', err);
2160
+ hdrFallbackFullReload('revalidate-throw');
2161
+ }
2162
+ })
2163
+ .catch(function (err) {
2164
+ console.error('[Mandu HDR] Fetch failed:', err);
2165
+ hdrFallbackFullReload('fetch-failed');
2166
+ });
2167
+ }
2168
+
660
2169
  function handleMessage(message) {
2170
+ // Vite-compat envelope. Two shapes: live broadcast ('vite') and
2171
+ // replayed-after-reconnect ('vite-replay'). They differ only in the
2172
+ // type tag — behavior is identical.
2173
+ if (message.type === 'vite' || message.type === 'vite-replay') {
2174
+ recordEnvelopeId(message);
2175
+ applyViteUpdate(message.payload);
2176
+ return;
2177
+ }
2178
+
661
2179
  switch (message.type) {
662
2180
  case 'connected':
2181
+ recordEnvelopeId(message);
663
2182
  console.log('[Mandu HMR] Ready');
664
2183
  break;
665
2184
 
666
2185
  case 'reload':
2186
+ case 'full-reload':
2187
+ recordEnvelopeId(message);
2188
+ fireViteEvent('vite:beforeFullReload', message);
667
2189
  console.log('[Mandu HMR] Full reload requested');
668
2190
  location.reload();
669
2191
  break;
670
2192
 
2193
+ case 'invalidate':
2194
+ // Server echoed an invalidate — same outcome as full reload.
2195
+ recordEnvelopeId(message);
2196
+ fireViteEvent('vite:beforeFullReload', message);
2197
+ location.reload();
2198
+ break;
2199
+
2200
+ case 'update':
2201
+ // Mandu-internal 'update' mirrors the Vite payload shape.
2202
+ recordEnvelopeId(message);
2203
+ applyViteUpdate({ type: 'update', updates: (message.data && message.data.updates) || [] });
2204
+ break;
2205
+
671
2206
  case 'island-update':
2207
+ recordEnvelopeId(message);
672
2208
  const routeId = message.data?.routeId;
673
2209
  console.log('[Mandu HMR] Island updated:', routeId);
674
2210
  staleIslands.add(routeId);
@@ -676,20 +2212,34 @@ export function generateHMRClientScript(port: number): string {
676
2212
  // 현재 페이지의 island인지 확인
677
2213
  const island = document.querySelector('[data-mandu-island="' + routeId + '"]');
678
2214
  if (island) {
2215
+ fireViteEvent('vite:beforeFullReload', message);
679
2216
  console.log('[Mandu HMR] Reloading page for island update');
680
2217
  location.reload();
681
2218
  }
682
2219
  break;
683
2220
 
684
2221
  case 'layout-update':
2222
+ recordEnvelopeId(message);
685
2223
  const layoutPath = message.data?.layoutPath;
686
2224
  console.log('[Mandu HMR] Layout updated:', layoutPath);
2225
+ fireViteEvent('vite:beforeFullReload', message);
687
2226
  // Layout 변경은 항상 전체 리로드
688
2227
  location.reload();
689
2228
  break;
690
2229
 
2230
+ case 'slot-refetch':
2231
+ // Phase 7.2 HDR — slot (.slot.ts) changed. Try to refetch loader
2232
+ // data without remounting the React tree. Falls back to a full
2233
+ // reload on any failure so the user is never stranded on stale
2234
+ // state. Fire-and-forget (no return from handleMessage itself).
2235
+ recordEnvelopeId(message);
2236
+ handleSlotRefetch(message.data || {});
2237
+ break;
2238
+
691
2239
  case 'css-update':
2240
+ recordEnvelopeId(message);
692
2241
  console.log('[Mandu HMR] CSS updated');
2242
+ fireViteEvent('vite:beforeUpdate', message);
693
2243
  // CSS 핫 리로드 (페이지 새로고침 없이 스타일시트만 교체)
694
2244
  var targetCssPath = message.data?.cssPath || '/.mandu/client/globals.css';
695
2245
  var links = document.querySelectorAll('link[rel="stylesheet"]');
@@ -701,10 +2251,12 @@ export function generateHMRClientScript(port: number): string {
701
2251
  link.setAttribute('href', baseHref + '?t=' + Date.now());
702
2252
  }
703
2253
  });
2254
+ fireViteEvent('vite:afterUpdate', message);
704
2255
  break;
705
2256
 
706
2257
  case 'error':
707
2258
  console.error('[Mandu HMR] Build error:', message.data?.message);
2259
+ fireViteEvent('vite:error', message);
708
2260
  showErrorOverlay(message.data?.message);
709
2261
  break;
710
2262