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