@mandujs/core 0.20.10 → 0.22.0

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 (127) hide show
  1. package/README.md +2 -1
  2. package/package.json +28 -3
  3. package/src/auth/__tests__/login.test.ts +419 -0
  4. package/src/auth/__tests__/password.test.ts +122 -0
  5. package/src/auth/__tests__/reset.test.ts +296 -0
  6. package/src/auth/__tests__/tokens.test.ts +274 -0
  7. package/src/auth/__tests__/verification.test.ts +274 -0
  8. package/src/auth/index.ts +76 -0
  9. package/src/auth/login.ts +225 -0
  10. package/src/auth/password.ts +120 -0
  11. package/src/auth/reset.ts +243 -0
  12. package/src/auth/tokens.ts +612 -0
  13. package/src/auth/verification.ts +253 -0
  14. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
  15. package/src/bundler/__tests__/cold-start.test.ts +504 -0
  16. package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
  17. package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
  18. package/src/bundler/__tests__/extended-watch.test.ts +710 -0
  19. package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
  20. package/src/bundler/__tests__/hdr.test.ts +353 -0
  21. package/src/bundler/__tests__/hmr-client.test.ts +532 -0
  22. package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
  23. package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
  24. package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
  25. package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
  26. package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
  27. package/src/bundler/build.test.ts +8 -1
  28. package/src/bundler/build.ts +495 -37
  29. package/src/bundler/css.ts +326 -323
  30. package/src/bundler/dev.ts +1671 -80
  31. package/src/bundler/fast-refresh-plugin.ts +307 -0
  32. package/src/bundler/hmr-types.ts +252 -0
  33. package/src/bundler/manifest-schema.ts +301 -0
  34. package/src/bundler/safe-build.test.ts +128 -0
  35. package/src/bundler/safe-build.ts +77 -0
  36. package/src/bundler/scenario-matrix.ts +229 -0
  37. package/src/bundler/types.ts +19 -0
  38. package/src/bundler/vendor-cache-types.ts +130 -0
  39. package/src/bundler/vendor-cache.ts +526 -0
  40. package/src/client/router.ts +214 -56
  41. package/src/config/validate.ts +1 -0
  42. package/src/db/__tests__/db.test.ts +485 -0
  43. package/src/db/index.ts +513 -0
  44. package/src/db/migrations/__tests__/runner.test.ts +661 -0
  45. package/src/db/migrations/history-table.ts +345 -0
  46. package/src/db/migrations/lock.ts +269 -0
  47. package/src/db/migrations/runner.ts +633 -0
  48. package/src/desktop/__tests__/smoke.test.ts +100 -0
  49. package/src/desktop/__tests__/window.test.ts +172 -0
  50. package/src/desktop/__tests__/worker.test.ts +266 -0
  51. package/src/desktop/index.ts +43 -0
  52. package/src/desktop/types.ts +158 -0
  53. package/src/desktop/window.ts +492 -0
  54. package/src/desktop/worker.ts +180 -0
  55. package/src/devtools/ai/mcp-connector.ts +18 -16
  56. package/src/devtools/client/components/mandu-character.tsx +4 -1
  57. package/src/devtools/client/components/panel/panel-container.tsx +20 -5
  58. package/src/email/__tests__/email.test.ts +355 -0
  59. package/src/email/index.ts +282 -0
  60. package/src/email/resend.ts +163 -0
  61. package/src/email/smtp.ts +64 -0
  62. package/src/filling/__tests__/session-sqlite.test.ts +454 -0
  63. package/src/filling/context.ts +72 -78
  64. package/src/filling/cookie-codec.ts +299 -0
  65. package/src/filling/deps.ts +25 -1
  66. package/src/filling/filling.ts +28 -3
  67. package/src/filling/session-sqlite.ts +617 -0
  68. package/src/filling/session.ts +265 -216
  69. package/src/guard/decision-memory.test.ts +52 -22
  70. package/src/id/__tests__/id.test.ts +120 -0
  71. package/src/id/index.ts +105 -0
  72. package/src/kitchen/index.ts +2 -2
  73. package/src/kitchen/kitchen-handler.ts +86 -0
  74. package/src/kitchen/stream/activity-sse.ts +2 -1
  75. package/src/middleware/csrf.ts +328 -0
  76. package/src/middleware/index.ts +40 -0
  77. package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
  78. package/src/middleware/oauth/index.ts +505 -0
  79. package/src/middleware/oauth/providers.ts +115 -0
  80. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
  81. package/src/middleware/rate-limit/index.ts +522 -0
  82. package/src/middleware/rate-limit/sqlite-store.ts +382 -0
  83. package/src/middleware/secure/__tests__/secure.test.ts +360 -0
  84. package/src/middleware/secure/csp.ts +193 -0
  85. package/src/middleware/secure/index.ts +417 -0
  86. package/src/middleware/session.ts +174 -0
  87. package/src/observability/event-bus.ts +81 -79
  88. package/src/paths.ts +37 -0
  89. package/src/perf/hmr-markers.ts +215 -0
  90. package/src/perf/index.ts +104 -0
  91. package/src/resource/__tests__/generator.test.ts +603 -2
  92. package/src/resource/ddl/__tests__/diff.test.ts +639 -0
  93. package/src/resource/ddl/__tests__/emit.test.ts +799 -0
  94. package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
  95. package/src/resource/ddl/diff.ts +392 -0
  96. package/src/resource/ddl/emit.ts +548 -0
  97. package/src/resource/ddl/persistence-types.ts +218 -0
  98. package/src/resource/ddl/snapshot.ts +447 -0
  99. package/src/resource/ddl/type-map.ts +223 -0
  100. package/src/resource/ddl/types.ts +232 -0
  101. package/src/resource/generator-repo.ts +610 -0
  102. package/src/resource/generator-schema.ts +476 -0
  103. package/src/resource/generator.ts +117 -1
  104. package/src/resource/index.ts +17 -1
  105. package/src/resource/schema.ts +30 -0
  106. package/src/router/fs-scanner.ts +3 -0
  107. package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
  108. package/src/runtime/__tests__/hdr-client.test.ts +223 -0
  109. package/src/runtime/__tests__/http-errors.test.ts +117 -0
  110. package/src/runtime/__tests__/not-found.test.ts +152 -0
  111. package/src/runtime/boundary.tsx +21 -1
  112. package/src/runtime/fast-refresh-runtime.ts +322 -0
  113. package/src/runtime/fast-refresh-types.ts +128 -0
  114. package/src/runtime/hmr-client.ts +409 -0
  115. package/src/runtime/http-errors.ts +113 -0
  116. package/src/runtime/index.ts +6 -0
  117. package/src/runtime/logger.ts +678 -677
  118. package/src/runtime/not-found.ts +93 -0
  119. package/src/runtime/redirect.ts +133 -0
  120. package/src/runtime/server.ts +679 -23
  121. package/src/runtime/ssr.ts +340 -10
  122. package/src/runtime/streaming-ssr.ts +222 -19
  123. package/src/scheduler/__tests__/scheduler.test.ts +514 -0
  124. package/src/scheduler/index.ts +343 -0
  125. package/src/storage/s3/__tests__/s3.test.ts +479 -0
  126. package/src/storage/s3/index.ts +412 -0
  127. package/src/testing/index.ts +247 -189
@@ -7,9 +7,23 @@ 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
 
21
+ /**
22
+ * #184: 공통 디렉토리 변경 시 사용하는 sentinel.
23
+ * `onSSRChange`에 특정 파일 경로 대신 이 상수를 전달하면 "전체 SSR 레지스트리 invalidate" 의미.
24
+ */
25
+ export const SSR_CHANGE_WILDCARD = "*";
26
+
13
27
  export interface DevBundlerOptions {
14
28
  /** 프로젝트 루트 */
15
29
  rootDir: string;
@@ -21,14 +35,53 @@ export interface DevBundlerOptions {
21
35
  onError?: (error: Error, routeId?: string) => void;
22
36
  /**
23
37
  * SSR 파일 변경 콜백 (page.tsx, layout.tsx 등)
24
- * 클라이언트 번들 리빌드 없이 서버 핸들러 재등록이 필요한 경우 호출
38
+ * 클라이언트 번들 리빌드 없이 서버 핸들러 재등록이 필요한 경우 호출.
39
+ * `SSR_CHANGE_WILDCARD` ("*")를 받으면 전체 레지스트리 invalidate 의미 (#184).
40
+ * Promise 반환 시 await 되므로 레지스트리 clear가 완료된 후 HMR reload broadcast 가능.
25
41
  */
26
- onSSRChange?: (filePath: string) => void;
42
+ onSSRChange?: (filePath: string) => void | Promise<void>;
27
43
  /**
28
44
  * API route 파일 변경 콜백 (route.ts 등)
29
45
  * API 핸들러 재등록이 필요한 경우 호출
30
46
  */
31
- onAPIChange?: (filePath: string) => void;
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;
32
85
  /**
33
86
  * 추가 watch 디렉토리 (공통 컴포넌트 등)
34
87
  * 상대 경로 또는 절대 경로 모두 지원
@@ -56,25 +109,248 @@ export interface DevBundler {
56
109
  close: () => void;
57
110
  }
58
111
 
59
- // 기본 공통 컴포넌트 디렉토리 목록
112
+ /**
113
+ * #180: 파일 경로 비교를 위한 정규화.
114
+ * - 절대 경로로 변환 (path.resolve)
115
+ * - 백슬래시 → 포워드슬래시
116
+ * - Windows에서는 case-insensitive 매칭 (소문자화)
117
+ *
118
+ * 동적 라우트 폴더(`[lang]` 등) 변경 감지가 누락되던 문제는 watcher가 보고하는
119
+ * `path.join(dir, filename)`과 `serverModuleSet` 등록 시의 `path.resolve(rootDir, ...)`
120
+ * 가 드라이브 문자 대소문자/슬래시 표기 차이로 어긋나서 발생했음.
121
+ */
122
+ function normalizeFsPath(p: string): string {
123
+ const resolved = path.resolve(p).replace(/\\/g, "/");
124
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
125
+ }
126
+
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
+ */
60
137
  const DEFAULT_COMMON_DIRS = [
61
- "src/components",
138
+ "src", // B1 fix — top-level files under `src/` (was missing)
62
139
  "components",
63
- "src/shared",
64
140
  "shared",
65
- "src/lib",
66
141
  "lib",
67
- "src/hooks",
68
142
  "hooks",
69
- "src/utils",
70
143
  "utils",
71
- // Islands & Client 디렉토리
72
- "src/client",
73
144
  "client",
74
- "src/islands",
75
145
  "islands",
76
146
  ];
77
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
+
78
354
  /**
79
355
  * 개발 모드 번들러 시작
80
356
  * 파일 변경 감시 및 자동 재빌드
@@ -87,6 +363,9 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
87
363
  onError,
88
364
  onSSRChange,
89
365
  onAPIChange,
366
+ onConfigReload,
367
+ onResourceChange,
368
+ onPackageJsonChange,
90
369
  watchDirs: customWatchDirs = [],
91
370
  disableDefaultWatchDirs = false,
92
371
  } = options;
@@ -114,7 +393,7 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
114
393
  for (const route of manifest.routes) {
115
394
  if (route.clientModule) {
116
395
  const absPath = path.resolve(rootDir, route.clientModule);
117
- const normalizedPath = absPath.replace(/\\/g, "/");
396
+ const normalizedPath = normalizeFsPath(absPath);
118
397
  clientModuleToRoute.set(normalizedPath, route.id);
119
398
 
120
399
  // Also register *.client.tsx/ts files in the same directory (#140)
@@ -122,7 +401,7 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
122
401
  const dir = path.dirname(absPath);
123
402
  const baseStem = path.basename(absPath).replace(/\.(island|client)\.(tsx?|jsx?)$/, "");
124
403
  for (const ext of [".client.tsx", ".client.ts", ".client.jsx", ".client.js"]) {
125
- const clientPath = path.join(dir, baseStem + ext).replace(/\\/g, "/");
404
+ const clientPath = normalizeFsPath(path.join(dir, baseStem + ext));
126
405
  if (clientPath !== normalizedPath) {
127
406
  clientModuleToRoute.set(clientPath, route.id);
128
407
  }
@@ -134,23 +413,104 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
134
413
 
135
414
  // SSR 모듈 등록 (page.tsx, layout.tsx) — #151
136
415
  if (route.componentModule) {
137
- const absPath = path.resolve(rootDir, route.componentModule).replace(/\\/g, "/");
138
- serverModuleSet.add(absPath);
139
- watchDirs.add(path.dirname(path.resolve(rootDir, route.componentModule)));
416
+ const absPath = path.resolve(rootDir, route.componentModule);
417
+ serverModuleSet.add(normalizeFsPath(absPath));
418
+ watchDirs.add(path.dirname(absPath));
140
419
  }
141
420
  if (route.layoutChain) {
142
421
  for (const layoutPath of route.layoutChain) {
143
- const absPath = path.resolve(rootDir, layoutPath).replace(/\\/g, "/");
144
- serverModuleSet.add(absPath);
145
- watchDirs.add(path.dirname(path.resolve(rootDir, layoutPath)));
422
+ const absPath = path.resolve(rootDir, layoutPath);
423
+ serverModuleSet.add(normalizeFsPath(absPath));
424
+ watchDirs.add(path.dirname(absPath));
425
+ }
426
+ }
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
+ );
146
506
  }
147
507
  }
148
508
 
149
509
  // Track API route modules for hot-reload
150
510
  if (route.kind === "api" && route.module) {
151
- const absPath = path.resolve(rootDir, route.module).replace(/\\/g, "/");
152
- apiModuleSet.add(absPath);
153
- watchDirs.add(path.dirname(path.resolve(rootDir, route.module)));
511
+ const absPath = path.resolve(rootDir, route.module);
512
+ apiModuleSet.add(normalizeFsPath(absPath));
513
+ watchDirs.add(path.dirname(absPath));
154
514
  }
155
515
  }
156
516
 
@@ -163,6 +523,31 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
163
523
  // slots 디렉토리 없으면 무시
164
524
  }
165
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
+
166
551
  // 공통 컴포넌트 디렉토리 추가 (기본 + 커스텀)
167
552
  const commonDirsToCheck = disableDefaultWatchDirs
168
553
  ? customWatchDirs
@@ -187,68 +572,441 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
187
572
 
188
573
  // 파일 감시 설정
189
574
  const watchers: fs.FSWatcher[] = [];
190
- 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
+
191
601
  // 동시 빌드 방지 (#121): 빌드 중에 변경 발생 시 다음 빌드 대기
192
602
  let isBuilding = false;
193
- let pendingBuildFile: string | null = null;
194
603
 
195
- // 파일이 공통 디렉토리에 있는지 확인
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
+ */
196
608
  const isInCommonDir = (filePath: string): boolean => {
197
- const normalizedFile = path.resolve(filePath).replace(/\\/g, "/");
609
+ const normalizedFile = normalizeFsPath(filePath);
198
610
  for (const commonDir of commonWatchDirs) {
199
- const normalizedCommon = path.resolve(commonDir).replace(/\\/g, "/");
200
- if (normalizedFile.startsWith(normalizedCommon + "/")) {
611
+ const normalizedCommon = normalizeFsPath(commonDir);
612
+ if (
613
+ normalizedFile === normalizedCommon ||
614
+ normalizedFile.startsWith(normalizedCommon + "/")
615
+ ) {
201
616
  return true;
202
617
  }
203
618
  }
204
619
  return false;
205
620
  };
206
621
 
207
- const handleFileChange = async (changedFile: string) => {
208
- // 동시 빌드 방지 (#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 방지).
209
879
  if (isBuilding) {
210
- 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
+ }
211
919
  return;
212
920
  }
213
921
 
214
922
  isBuilding = true;
923
+ mark("dev:rebuild");
924
+ mark(HMR_PERF.REBUILD_TOTAL);
215
925
  try {
216
926
  await _doBuild(changedFile);
217
927
  } finally {
928
+ measure("dev:rebuild", "dev:rebuild");
929
+ measure(HMR_PERF.REBUILD_TOTAL, HMR_PERF.REBUILD_TOTAL);
218
930
  isBuilding = false;
219
- // 빌드 대기 중인 파일이 있으면 즉시 처리
220
- if (pendingBuildFile) {
221
- const next = pendingBuildFile;
222
- pendingBuildFile = null;
223
- // Catch errors to prevent unhandled promise rejection from killing the watcher (#10)
931
+ // B2: 대기 중인 모든 파일을 batch로 flush.
932
+ if (pendingBuildSet.size > 0) {
224
933
  try {
225
- await handleFileChange(next);
934
+ await flushPendingBatch();
226
935
  } catch (retryError) {
227
- 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
+ );
228
940
  console.log(` ⏳ Waiting for next file change to retry...`);
229
941
  }
230
942
  }
231
943
  }
232
944
  };
233
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
+
234
977
  const _doBuild = async (changedFile: string) => {
235
- const normalizedPath = changedFile.replace(/\\/g, "/");
978
+ const normalizedPath = normalizeFsPath(changedFile);
236
979
 
237
- // 공통 컴포넌트 디렉토리 변경 → 전체 재빌드 (targetRouteIds 없이)
980
+ // 공통 컴포넌트 디렉토리 변경 → Island만 재빌드 + SSR 레지스트리 invalidate (#184, #185)
238
981
  if (isInCommonDir(changedFile)) {
239
982
  console.log(`\n🔄 Common file changed: ${path.basename(changedFile)}`);
240
- console.log(` Rebuilding all islands...`);
983
+ console.log(` Rebuilding islands (framework bundles skipped)...`);
241
984
  const startTime = performance.now();
242
985
 
243
986
  try {
987
+ // #185: framework 번들 (runtime/router/vendor/devtools) 스킵 — 사용자 코드 변경 시 불필요
244
988
  const result = await buildClientBundles(manifest, rootDir, {
245
989
  minify: false,
246
990
  sourcemap: true,
991
+ skipFrameworkBundles: true,
247
992
  });
248
993
 
249
994
  const buildTime = performance.now() - startTime;
250
995
 
251
996
  if (result.success) {
997
+ // #184: common dir 변경은 SSR 모듈 캐시 invalidation이 필요 — wildcard 시그널
998
+ // 빌드 성공한 경우에만 SSR 레지스트리를 clear (실패 시 마지막 good state 유지)
999
+ // 주의: Bun의 transitive ESM 캐시는 프로세스 레벨이라 이 시그널만으로는
1000
+ // `src/shared/**`을 transitive하게 import하는 SSR 모듈까지 완전히 갱신되지 않음.
1001
+ // 진짜 해결은 subprocess/worker 기반 SSR eval이 필요 (follow-up 이슈).
1002
+ if (onSSRChange) {
1003
+ try {
1004
+ await Promise.resolve(onSSRChange(SSR_CHANGE_WILDCARD));
1005
+ } catch (ssrError) {
1006
+ console.warn(`⚠️ SSR invalidation failed:`, ssrError instanceof Error ? ssrError.message : ssrError);
1007
+ }
1008
+ }
1009
+
252
1010
  console.log(`✅ Rebuilt ${result.stats.bundleCount} islands in ${buildTime.toFixed(0)}ms`);
253
1011
  onRebuild?.({
254
1012
  routeId: "*", // 전체 재빌드 표시
@@ -257,6 +1015,7 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
257
1015
  });
258
1016
  } else {
259
1017
  console.error(`❌ Build failed:`, result.errors);
1018
+ console.log(` ⏳ SSR registry not invalidated (keeping last good state)`);
260
1019
  onRebuild?.({
261
1020
  routeId: "*",
262
1021
  success: false,
@@ -279,10 +1038,10 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
279
1038
  // Fallback for *.client.tsx/ts: find route whose clientModule is in the same directory (#140)
280
1039
  // basename matching (e.g. "page" !== "index") is unreliable — use directory-based matching instead
281
1040
  if (!routeId && (changedFile.endsWith(".client.ts") || changedFile.endsWith(".client.tsx"))) {
282
- const changedDir = path.dirname(path.resolve(rootDir, changedFile)).replace(/\\/g, "/");
1041
+ const changedDir = normalizeFsPath(path.dirname(path.resolve(rootDir, changedFile)));
283
1042
  const matchedRoute = manifest.routes.find((r) => {
284
1043
  if (!r.clientModule) return false;
285
- const routeDir = path.dirname(path.resolve(rootDir, r.clientModule)).replace(/\\/g, "/");
1044
+ const routeDir = normalizeFsPath(path.dirname(path.resolve(rootDir, r.clientModule)));
286
1045
  return routeDir === changedDir;
287
1046
  });
288
1047
  if (matchedRoute) {
@@ -301,6 +1060,17 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
301
1060
  if (onAPIChange && apiModuleSet.has(normalizedPath)) {
302
1061
  console.log(`\n🔄 API route changed: ${path.basename(changedFile)}`);
303
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);
304
1074
  }
305
1075
  return;
306
1076
  }
@@ -346,23 +1116,52 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
346
1116
  }
347
1117
  };
348
1118
 
349
- // 각 디렉토리에 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
350
1147
  for (const dir of watchDirs) {
351
1148
  try {
352
- const watcher = fs.watch(dir, { recursive: true }, async (event, filename) => {
1149
+ const watcher = fs.watch(dir, { recursive: true }, (event, filename) => {
353
1150
  if (!filename) return;
354
1151
 
355
- // TypeScript/TSX 파일만 감시
356
- if (!filename.endsWith(".ts") && !filename.endsWith(".tsx")) return;
357
-
358
1152
  const fullPath = path.join(dir, filename);
1153
+ const normalizedFull = normalizeFsPath(fullPath);
359
1154
 
360
- // Debounce - 연속 변경 무시
361
- if (debounceTimer) {
362
- clearTimeout(debounceTimer);
363
- }
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);
364
1162
 
365
- debounceTimer = setTimeout(() => handleFileChange(fullPath), TIMEOUTS.WATCHER_DEBOUNCE);
1163
+ // B6 fix — per-file debounce (replaces global single timer).
1164
+ scheduleFileChange(fullPath);
366
1165
  });
367
1166
 
368
1167
  watchers.push(watcher);
@@ -371,6 +1170,49 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
371
1170
  }
372
1171
  }
373
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
+
374
1216
  if (watchers.length > 0) {
375
1217
  console.log(`👀 Watching ${watchers.length} directories for changes...`);
376
1218
  if (commonWatchDirs.size > 0) {
@@ -384,9 +1226,11 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
384
1226
  return {
385
1227
  initialBuild,
386
1228
  close: () => {
387
- if (debounceTimer) {
388
- clearTimeout(debounceTimer);
1229
+ // B6: clear all per-file timers to release event-loop refs.
1230
+ for (const timer of perFileTimers.values()) {
1231
+ clearTimeout(timer);
389
1232
  }
1233
+ perFileTimers.clear();
390
1234
  for (const watcher of watchers) {
391
1235
  watcher.close();
392
1236
  }
@@ -396,22 +1240,44 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
396
1240
 
397
1241
  /**
398
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.
399
1249
  */
400
1250
  export interface HMRServer {
401
1251
  /** 연결된 클라이언트 수 */
402
1252
  clientCount: number;
403
- /** 모든 클라이언트에게 메시지 전송 */
1253
+ /** 모든 클라이언트에게 메시지 전송 — 내부 Mandu 포맷. */
404
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;
405
1262
  /** 서버 중지 */
406
1263
  close: () => void;
407
1264
  /** 재시작 핸들러 등록 */
408
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 };
409
1272
  }
410
1273
 
411
1274
  export interface HMRMessage {
412
1275
  type:
413
1276
  | "connected"
414
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
415
1281
  | "island-update"
416
1282
  | "layout-update"
417
1283
  | "css-update"
@@ -431,25 +1297,217 @@ export interface HMRMessage {
431
1297
  changeType?: "add" | "change" | "delete";
432
1298
  action?: "approve" | "reject";
433
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;
434
1306
  };
435
1307
  }
436
1308
 
437
1309
  /**
438
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.
439
1328
  */
440
- 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 {
441
1356
  const clients = new Set<{ send: (data: string) => void; close: () => void }>();
442
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
+ ]);
443
1368
  let restartHandler: (() => Promise<void>) | null = null;
444
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
+
445
1488
  const corsHeaders: Record<string, string> = {
446
1489
  "Access-Control-Allow-Origin": `http://localhost:${port}`,
447
1490
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
448
1491
  "Access-Control-Allow-Headers": "Content-Type",
449
1492
  };
450
1493
 
451
- 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>({
452
1509
  port: hmrPort,
1510
+ hostname, // Phase 7.0.S C-03 fix: bind to loopback by default.
453
1511
  async fetch(req, server) {
454
1512
  const url = new URL(req.url);
455
1513
 
@@ -458,6 +1516,25 @@ export function createHMRServer(port: number): HMRServer {
458
1516
  return new Response(null, { status: 204, headers: corsHeaders });
459
1517
  }
460
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
+
461
1538
  // POST /restart → 재시작 핸들러 호출
462
1539
  if (req.method === "POST" && url.pathname === "/restart") {
463
1540
  if (!restartHandler) {
@@ -483,8 +1560,10 @@ export function createHMRServer(port: number): HMRServer {
483
1560
  }
484
1561
  }
485
1562
 
486
- // WebSocket 업그레이드
487
- 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 } })) {
488
1567
  return;
489
1568
  }
490
1569
 
@@ -494,6 +1573,8 @@ export function createHMRServer(port: number): HMRServer {
494
1573
  status: "ok",
495
1574
  clients: clients.size,
496
1575
  port: hmrPort,
1576
+ lastRebuildId,
1577
+ replayBufferSize: replayBuffer.length,
497
1578
  }),
498
1579
  {
499
1580
  headers: { ...corsHeaders, "Content-Type": "application/json" },
@@ -503,45 +1584,167 @@ export function createHMRServer(port: number): HMRServer {
503
1584
  websocket: {
504
1585
  open(ws) {
505
1586
  clients.add(ws);
506
- ws.send(
507
- JSON.stringify({
508
- type: "connected",
509
- data: { timestamp: Date.now() },
510
- })
511
- );
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);
512
1591
  },
513
1592
  close(ws) {
514
1593
  clients.delete(ws);
515
1594
  },
516
1595
  message(ws, message) {
517
- // 클라이언트로부터의 ping 처리
1596
+ // 클라이언트로부터의 ping 처리 + invalidate 수신.
518
1597
  try {
519
1598
  const data = JSON.parse(String(message));
520
1599
  if (data.type === "ping") {
521
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
+ }
522
1654
  }
523
1655
  } catch {
524
- // 무시
1656
+ // 무시 — malformed JSON from the client is never fatal.
525
1657
  }
526
1658
  },
527
1659
  },
528
1660
  });
529
1661
 
530
- 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
+ };
531
1678
 
532
1679
  return {
533
1680
  get clientCount() {
534
1681
  return clients.size;
535
1682
  },
536
1683
  broadcast: (message: HMRMessage) => {
537
- const payload = JSON.stringify(message);
538
- for (const client of clients) {
539
- try {
540
- client.send(payload);
541
- } catch {
542
- clients.delete(client);
543
- }
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;
544
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;
545
1748
  },
546
1749
  close: () => {
547
1750
  for (const client of clients) {
@@ -557,12 +1760,138 @@ export function createHMRServer(port: number): HMRServer {
557
1760
  setRestartHandler: (handler: () => Promise<void>) => {
558
1761
  restartHandler = handler;
559
1762
  },
1763
+ _inspectReplayBuffer: () => ({
1764
+ size: replayBuffer.length,
1765
+ lastId: lastRebuildId,
1766
+ oldestId: replayBuffer.length > 0 ? replayBuffer[0]!.id : null,
1767
+ }),
560
1768
  };
561
1769
  }
562
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
+
563
1853
  /**
564
1854
  * HMR 클라이언트 스크립트 생성
565
- * 브라우저에서 실행되어 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.
566
1895
  */
567
1896
  export function generateHMRClientScript(port: number): string {
568
1897
  const hmrPort = port + PORTS.HMR_OFFSET;
@@ -573,16 +1902,53 @@ export function generateHMRClientScript(port: number): string {
573
1902
  const HMR_PORT = ${hmrPort};
574
1903
  let ws = null;
575
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;
576
1909
  const maxReconnectAttempts = ${TIMEOUTS.HMR_MAX_RECONNECT};
577
1910
  const reconnectDelay = ${TIMEOUTS.HMR_RECONNECT_DELAY};
578
1911
  const staleIslands = new Set();
579
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
+
580
1945
  function connect() {
581
1946
  try {
582
- 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);
583
1949
 
584
1950
  ws.onopen = function() {
585
- console.log('[Mandu HMR] Connected');
1951
+ console.log('[Mandu HMR] Connected' + (lastSeenId > 0 ? ' (since ' + lastSeenId + ')' : ''));
586
1952
  reconnectAttempts = 0;
587
1953
  };
588
1954
 
@@ -618,18 +1984,227 @@ export function generateHMRClientScript(port: number): string {
618
1984
  }
619
1985
  }
620
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
+
621
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
+
622
2179
  switch (message.type) {
623
2180
  case 'connected':
2181
+ recordEnvelopeId(message);
624
2182
  console.log('[Mandu HMR] Ready');
625
2183
  break;
626
2184
 
627
2185
  case 'reload':
2186
+ case 'full-reload':
2187
+ recordEnvelopeId(message);
2188
+ fireViteEvent('vite:beforeFullReload', message);
628
2189
  console.log('[Mandu HMR] Full reload requested');
629
2190
  location.reload();
630
2191
  break;
631
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
+
632
2206
  case 'island-update':
2207
+ recordEnvelopeId(message);
633
2208
  const routeId = message.data?.routeId;
634
2209
  console.log('[Mandu HMR] Island updated:', routeId);
635
2210
  staleIslands.add(routeId);
@@ -637,20 +2212,34 @@ export function generateHMRClientScript(port: number): string {
637
2212
  // 현재 페이지의 island인지 확인
638
2213
  const island = document.querySelector('[data-mandu-island="' + routeId + '"]');
639
2214
  if (island) {
2215
+ fireViteEvent('vite:beforeFullReload', message);
640
2216
  console.log('[Mandu HMR] Reloading page for island update');
641
2217
  location.reload();
642
2218
  }
643
2219
  break;
644
2220
 
645
2221
  case 'layout-update':
2222
+ recordEnvelopeId(message);
646
2223
  const layoutPath = message.data?.layoutPath;
647
2224
  console.log('[Mandu HMR] Layout updated:', layoutPath);
2225
+ fireViteEvent('vite:beforeFullReload', message);
648
2226
  // Layout 변경은 항상 전체 리로드
649
2227
  location.reload();
650
2228
  break;
651
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
+
652
2239
  case 'css-update':
2240
+ recordEnvelopeId(message);
653
2241
  console.log('[Mandu HMR] CSS updated');
2242
+ fireViteEvent('vite:beforeUpdate', message);
654
2243
  // CSS 핫 리로드 (페이지 새로고침 없이 스타일시트만 교체)
655
2244
  var targetCssPath = message.data?.cssPath || '/.mandu/client/globals.css';
656
2245
  var links = document.querySelectorAll('link[rel="stylesheet"]');
@@ -662,10 +2251,12 @@ export function generateHMRClientScript(port: number): string {
662
2251
  link.setAttribute('href', baseHref + '?t=' + Date.now());
663
2252
  }
664
2253
  });
2254
+ fireViteEvent('vite:afterUpdate', message);
665
2255
  break;
666
2256
 
667
2257
  case 'error':
668
2258
  console.error('[Mandu HMR] Build error:', message.data?.message);
2259
+ fireViteEvent('vite:error', message);
669
2260
  showErrorOverlay(message.data?.message);
670
2261
  break;
671
2262