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