@mandujs/core 0.20.10 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.md +2 -1
  2. package/package.json +28 -3
  3. package/src/auth/__tests__/login.test.ts +419 -0
  4. package/src/auth/__tests__/password.test.ts +122 -0
  5. package/src/auth/__tests__/reset.test.ts +296 -0
  6. package/src/auth/__tests__/tokens.test.ts +274 -0
  7. package/src/auth/__tests__/verification.test.ts +274 -0
  8. package/src/auth/index.ts +76 -0
  9. package/src/auth/login.ts +225 -0
  10. package/src/auth/password.ts +120 -0
  11. package/src/auth/reset.ts +243 -0
  12. package/src/auth/tokens.ts +612 -0
  13. package/src/auth/verification.ts +253 -0
  14. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
  15. package/src/bundler/__tests__/cold-start.test.ts +504 -0
  16. package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
  17. package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
  18. package/src/bundler/__tests__/extended-watch.test.ts +710 -0
  19. package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
  20. package/src/bundler/__tests__/hdr.test.ts +353 -0
  21. package/src/bundler/__tests__/hmr-client.test.ts +532 -0
  22. package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
  23. package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
  24. package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
  25. package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
  26. package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
  27. package/src/bundler/build.test.ts +8 -1
  28. package/src/bundler/build.ts +495 -37
  29. package/src/bundler/css.ts +326 -323
  30. package/src/bundler/dev.ts +1671 -80
  31. package/src/bundler/fast-refresh-plugin.ts +307 -0
  32. package/src/bundler/hmr-types.ts +252 -0
  33. package/src/bundler/manifest-schema.ts +301 -0
  34. package/src/bundler/safe-build.test.ts +128 -0
  35. package/src/bundler/safe-build.ts +77 -0
  36. package/src/bundler/scenario-matrix.ts +229 -0
  37. package/src/bundler/types.ts +19 -0
  38. package/src/bundler/vendor-cache-types.ts +130 -0
  39. package/src/bundler/vendor-cache.ts +526 -0
  40. package/src/client/router.ts +214 -56
  41. package/src/config/validate.ts +1 -0
  42. package/src/db/__tests__/db.test.ts +485 -0
  43. package/src/db/index.ts +513 -0
  44. package/src/db/migrations/__tests__/runner.test.ts +661 -0
  45. package/src/db/migrations/history-table.ts +345 -0
  46. package/src/db/migrations/lock.ts +269 -0
  47. package/src/db/migrations/runner.ts +633 -0
  48. package/src/desktop/__tests__/smoke.test.ts +100 -0
  49. package/src/desktop/__tests__/window.test.ts +172 -0
  50. package/src/desktop/__tests__/worker.test.ts +266 -0
  51. package/src/desktop/index.ts +43 -0
  52. package/src/desktop/types.ts +158 -0
  53. package/src/desktop/window.ts +492 -0
  54. package/src/desktop/worker.ts +180 -0
  55. package/src/devtools/ai/mcp-connector.ts +18 -16
  56. package/src/devtools/client/components/mandu-character.tsx +4 -1
  57. package/src/devtools/client/components/panel/panel-container.tsx +20 -5
  58. package/src/email/__tests__/email.test.ts +355 -0
  59. package/src/email/index.ts +282 -0
  60. package/src/email/resend.ts +163 -0
  61. package/src/email/smtp.ts +64 -0
  62. package/src/filling/__tests__/session-sqlite.test.ts +454 -0
  63. package/src/filling/context.ts +72 -78
  64. package/src/filling/cookie-codec.ts +299 -0
  65. package/src/filling/deps.ts +25 -1
  66. package/src/filling/filling.ts +28 -3
  67. package/src/filling/session-sqlite.ts +617 -0
  68. package/src/filling/session.ts +265 -216
  69. package/src/guard/decision-memory.test.ts +52 -22
  70. package/src/id/__tests__/id.test.ts +120 -0
  71. package/src/id/index.ts +105 -0
  72. package/src/kitchen/index.ts +2 -2
  73. package/src/kitchen/kitchen-handler.ts +86 -0
  74. package/src/kitchen/stream/activity-sse.ts +2 -1
  75. package/src/middleware/csrf.ts +328 -0
  76. package/src/middleware/index.ts +40 -0
  77. package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
  78. package/src/middleware/oauth/index.ts +505 -0
  79. package/src/middleware/oauth/providers.ts +115 -0
  80. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
  81. package/src/middleware/rate-limit/index.ts +522 -0
  82. package/src/middleware/rate-limit/sqlite-store.ts +382 -0
  83. package/src/middleware/secure/__tests__/secure.test.ts +360 -0
  84. package/src/middleware/secure/csp.ts +193 -0
  85. package/src/middleware/secure/index.ts +417 -0
  86. package/src/middleware/session.ts +174 -0
  87. package/src/observability/event-bus.ts +81 -79
  88. package/src/paths.ts +37 -0
  89. package/src/perf/hmr-markers.ts +215 -0
  90. package/src/perf/index.ts +104 -0
  91. package/src/resource/__tests__/generator.test.ts +603 -2
  92. package/src/resource/ddl/__tests__/diff.test.ts +639 -0
  93. package/src/resource/ddl/__tests__/emit.test.ts +799 -0
  94. package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
  95. package/src/resource/ddl/diff.ts +392 -0
  96. package/src/resource/ddl/emit.ts +548 -0
  97. package/src/resource/ddl/persistence-types.ts +218 -0
  98. package/src/resource/ddl/snapshot.ts +447 -0
  99. package/src/resource/ddl/type-map.ts +223 -0
  100. package/src/resource/ddl/types.ts +232 -0
  101. package/src/resource/generator-repo.ts +610 -0
  102. package/src/resource/generator-schema.ts +476 -0
  103. package/src/resource/generator.ts +117 -1
  104. package/src/resource/index.ts +17 -1
  105. package/src/resource/schema.ts +30 -0
  106. package/src/router/fs-scanner.ts +3 -0
  107. package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
  108. package/src/runtime/__tests__/hdr-client.test.ts +223 -0
  109. package/src/runtime/__tests__/http-errors.test.ts +117 -0
  110. package/src/runtime/__tests__/not-found.test.ts +152 -0
  111. package/src/runtime/boundary.tsx +21 -1
  112. package/src/runtime/fast-refresh-runtime.ts +322 -0
  113. package/src/runtime/fast-refresh-types.ts +128 -0
  114. package/src/runtime/hmr-client.ts +409 -0
  115. package/src/runtime/http-errors.ts +113 -0
  116. package/src/runtime/index.ts +6 -0
  117. package/src/runtime/logger.ts +678 -677
  118. package/src/runtime/not-found.ts +93 -0
  119. package/src/runtime/redirect.ts +133 -0
  120. package/src/runtime/server.ts +679 -23
  121. package/src/runtime/ssr.ts +340 -10
  122. package/src/runtime/streaming-ssr.ts +222 -19
  123. package/src/scheduler/__tests__/scheduler.test.ts +514 -0
  124. package/src/scheduler/index.ts +343 -0
  125. package/src/storage/s3/__tests__/s3.test.ts +479 -0
  126. package/src/storage/s3/index.ts +412 -0
  127. package/src/testing/index.ts +247 -189
@@ -14,15 +14,52 @@ import type {
14
14
  IslandFileEntry,
15
15
  } from "./types";
16
16
  import { HYDRATION } from "../constants";
17
+ import { safeBuild } from "./safe-build";
18
+ import { fastRefreshPlugin } from "./fast-refresh-plugin";
19
+ import { mark, measure } from "../perf";
20
+ import { HMR_PERF } from "../perf/hmr-markers";
21
+ import {
22
+ readVendorCache,
23
+ writeVendorCache,
24
+ restoreVendorCache,
25
+ resolveVendorCacheKeys,
26
+ type VendorCacheKeyInput,
27
+ type VendorCacheWriteEntry,
28
+ } from "./vendor-cache";
17
29
  import path from "path";
18
30
  import fs from "fs/promises";
19
31
 
20
- /** Scan for *.island.tsx / *.island.ts files across hydrated route directories. */
32
+ /**
33
+ * Scan for *.island.tsx / *.island.ts files across hydrated route directories.
34
+ *
35
+ * Phase 7.1 R1 Agent C — per-island conditional skip defence.
36
+ *
37
+ * Callers (the two `buildClientBundles` paths at L1643 and L1817) feed us
38
+ * `hydratedRoutes` (from `getHydratedRoutes`), which already filters for
39
+ * `route.kind === "page" && route.clientModule && needsHydration(route)`.
40
+ * We re-assert the `needsHydration` predicate here for two reasons:
41
+ *
42
+ * 1. Defence-in-depth — if a future caller forgets the filter, we still
43
+ * skip routes with `hydration.strategy === "none"`. Each skipped route
44
+ * saves one `fs.readdir` + O(files) regex matches (~1-2 ms on Windows
45
+ * NTFS per skipped dir, per diagnostic R0.3).
46
+ *
47
+ * 2. Cold-start regression guard — the R0 → R3 F breakdown attributes
48
+ * +40-80 ms of cold start to per-island splitting (commit b503c36).
49
+ * The unit test `per-island-scan-skips-non-hydrated` pins this so a
50
+ * refactor cannot silently re-introduce the scan overhead for routes
51
+ * that opt out of hydration.
52
+ */
21
53
  async function scanIslandFiles(routes: RouteSpec[], rootDir: string): Promise<IslandFileEntry[]> {
22
54
  const entries: IslandFileEntry[] = [];
23
55
  const seenDirs = new Set<string>();
24
56
 
25
57
  for (const route of routes) {
58
+ // Defensive guard — see the block comment above for rationale. Without
59
+ // this, a hypothetical caller passing `manifest.routes` directly would
60
+ // readdir every page route, including pure-SSR ones.
61
+ if (!needsHydration(route)) continue;
62
+
26
63
  const dir = path.dirname(path.join(rootDir, route.componentModule ?? route.module));
27
64
  if (seenDirs.has(dir)) continue;
28
65
  seenDirs.add(dir);
@@ -45,21 +82,46 @@ async function scanIslandFiles(routes: RouteSpec[], rootDir: string): Promise<Is
45
82
  return entries;
46
83
  }
47
84
 
85
+ /**
86
+ * Test-only accessor for the island-file scanner. Allows the cold-start
87
+ * test suite (`__tests__/cold-start.test.ts`) to assert the per-island
88
+ * conditional skip without having to spin up a full `buildClientBundles`
89
+ * invocation (which would also run `safeBuild`).
90
+ *
91
+ * @internal
92
+ */
93
+ export const _testOnly_scanIslandFiles = scanIslandFiles;
94
+
95
+ /**
96
+ * Test-only accessor for the hydrated-routes filter. Mirrors the rationale
97
+ * above — lets tests verify that `getHydratedRoutes` really does drop
98
+ * `hydration.strategy: "none"` routes without rebuilding its logic.
99
+ *
100
+ * @internal
101
+ */
102
+ export const _testOnly_getHydratedRoutes = getHydratedRoutes;
103
+
48
104
  /** Build a single per-island bundle. */
49
105
  async function buildPerIslandBundle(
50
106
  entry: IslandFileEntry, outDir: string, options: BundlerOptions
51
107
  ): Promise<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }> {
52
108
  const entryPath = path.join(outDir, `_entry_island_${entry.name}.js`);
53
109
  const outputName = `${entry.name}.island.js`;
110
+ // Phase 7.1 B-1/B-4: wire Bun's native React Fast Refresh transform +
111
+ // Mandu's boundary injection plugin — but only in dev. Production
112
+ // bundles stay clean of `$RefreshReg$` / `$RefreshSig$` stubs.
113
+ const isDev = (options.minify ?? process.env.NODE_ENV === "production") === false;
54
114
  try {
55
115
  await Bun.write(entryPath, generateIslandEntry(entry.name, entry.filePath));
56
- const result = await Bun.build({
116
+ const result = await safeBuild({
57
117
  entrypoints: [entryPath],
58
118
  outdir: outDir,
59
119
  naming: outputName,
60
120
  minify: options.minify ?? process.env.NODE_ENV === "production",
61
121
  sourcemap: options.sourcemap ? "external" : "none",
62
122
  target: "browser",
123
+ ...(isDev ? { reactFastRefresh: true } : {}),
124
+ plugins: isDev ? [fastRefreshPlugin()] : [],
63
125
  external: ["react", "react-dom", "react-dom/client", ...(options.external || [])],
64
126
  define: { "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"), ...options.define },
65
127
  });
@@ -971,7 +1033,7 @@ if (typeof window !== 'undefined') {
971
1033
  try {
972
1034
  await Bun.write(srcPath, source);
973
1035
 
974
- const result = await Bun.build({
1036
+ const result = await safeBuild({
975
1037
  entrypoints: [srcPath],
976
1038
  outdir: outDir,
977
1039
  naming: outputName,
@@ -1025,7 +1087,7 @@ async function buildRouterRuntime(
1025
1087
  try {
1026
1088
  await Bun.write(routerPath, generateRouterRuntimeSource());
1027
1089
 
1028
- const result = await Bun.build({
1090
+ const result = await safeBuild({
1029
1091
  entrypoints: [routerPath],
1030
1092
  outdir: outDir,
1031
1093
  naming: outputName,
@@ -1100,7 +1162,7 @@ async function buildRuntime(
1100
1162
  await Bun.write(runtimePath, generateRuntimeSource());
1101
1163
 
1102
1164
  // 빌드
1103
- const result = await Bun.build({
1165
+ const result = await safeBuild({
1104
1166
  entrypoints: [runtimePath],
1105
1167
  outdir: outDir,
1106
1168
  naming: outputName,
@@ -1161,38 +1223,216 @@ interface VendorBuildResult {
1161
1223
  reactDomClient: string;
1162
1224
  jsxRuntime: string;
1163
1225
  jsxDevRuntime: string;
1226
+ /**
1227
+ * Phase 7.1 B-2: bundled `react-refresh/runtime` — emitted ONLY in
1228
+ * dev mode. Empty string in production. Consumed by the HTML
1229
+ * preamble (`bundler/dev.ts`) via a dynamic import.
1230
+ */
1231
+ reactRefreshRuntime: string;
1232
+ /**
1233
+ * Phase 7.1 B-2: Mandu's `__MANDU_HMR__` glue module — also dev-only.
1234
+ * Imported once by the HTML preamble which then calls `installGlobal`
1235
+ * with the already-loaded refresh runtime.
1236
+ */
1237
+ fastRefreshRuntime: string;
1164
1238
  errors: string[];
1165
1239
  }
1166
1240
 
1241
+ /**
1242
+ * Phase 7.1 B-2 — source generator for the `react-refresh/runtime` shim.
1243
+ *
1244
+ * The shim simply re-exports the upstream module under a default export
1245
+ * that `fast-refresh-runtime.ts`'s `installGlobal({ runtimeImport })`
1246
+ * can consume. Mirrors the pattern Vite uses for its `/@react-refresh`
1247
+ * endpoint.
1248
+ */
1249
+ function generateReactRefreshRuntimeShimSource(): string {
1250
+ return `
1251
+ /**
1252
+ * Mandu React Refresh Runtime Shim (Generated, dev-only)
1253
+ * Re-exports the upstream react-refresh/runtime so the bundler can
1254
+ * pre-bundle it without leaking the CommonJS entry into the app graph.
1255
+ */
1256
+ import * as Runtime from 'react-refresh/runtime';
1257
+ export const injectIntoGlobalHook = Runtime.injectIntoGlobalHook;
1258
+ export const register = Runtime.register;
1259
+ export const createSignatureFunctionForTransform = Runtime.createSignatureFunctionForTransform;
1260
+ export const performReactRefresh = Runtime.performReactRefresh;
1261
+ export default {
1262
+ injectIntoGlobalHook,
1263
+ register,
1264
+ createSignatureFunctionForTransform,
1265
+ performReactRefresh,
1266
+ };
1267
+ `;
1268
+ }
1269
+
1270
+ /**
1271
+ * Phase 7.1 B-2 — source generator for the Mandu Fast Refresh glue.
1272
+ *
1273
+ * This re-exports `installGlobal` / `manduHMR` / helpers from
1274
+ * `runtime/fast-refresh-runtime.ts`. By routing through a generated
1275
+ * shim we keep the absolute path to core resolved once at build time
1276
+ * (same pattern as `_devtools.js`).
1277
+ */
1278
+ function generateFastRefreshRuntimeShimSource(): string {
1279
+ const runtimePath = path
1280
+ .resolve(import.meta.dir, "..", "runtime", "fast-refresh-runtime.ts")
1281
+ .replace(/\\/g, "/");
1282
+ return `
1283
+ /**
1284
+ * Mandu Fast Refresh Runtime Shim (Generated, dev-only)
1285
+ * Wires the react-refresh runtime to window.__MANDU_HMR__.
1286
+ */
1287
+ export * from "${runtimePath}";
1288
+ import { installGlobal } from "${runtimePath}";
1289
+ export { installGlobal };
1290
+ `;
1291
+ }
1292
+
1167
1293
  /**
1168
1294
  * Vendor shim 번들 빌드
1169
1295
  * React, ReactDOM, ReactDOMClient를 각각의 shim으로 빌드
1296
+ *
1297
+ * Phase 7.2.S2: caches the built shim outputs on disk under
1298
+ * `.mandu/vendor-cache/` keyed by Bun + React + ReactDOM + react-refresh +
1299
+ * @mandujs/core versions. Warm boots reuse the cached files instead of
1300
+ * re-running Bun.build, eliminating ~80-120 ms from cold start.
1170
1301
  */
1171
1302
  async function buildVendorShims(
1303
+ rootDir: string,
1172
1304
  outDir: string,
1173
1305
  options: BundlerOptions
1174
1306
  ): Promise<VendorBuildResult> {
1175
1307
  const errors: string[] = [];
1176
- type VendorShimKey = "react" | "reactDom" | "reactDomClient" | "jsxRuntime" | "jsxDevRuntime";
1308
+ type VendorShimKey =
1309
+ | "react"
1310
+ | "reactDom"
1311
+ | "reactDomClient"
1312
+ | "jsxRuntime"
1313
+ | "jsxDevRuntime"
1314
+ | "reactRefreshRuntime"
1315
+ | "fastRefreshRuntime";
1177
1316
  const results: Record<VendorShimKey, string> = {
1178
1317
  react: "",
1179
1318
  reactDom: "",
1180
1319
  reactDomClient: "",
1181
1320
  jsxRuntime: "",
1182
1321
  jsxDevRuntime: "",
1322
+ reactRefreshRuntime: "",
1323
+ fastRefreshRuntime: "",
1183
1324
  };
1184
1325
 
1185
- const shims: Array<{ name: string; source: string; key: VendorShimKey }> = [
1186
- { name: "_react", source: generateReactShimSource(), key: "react" },
1187
- { name: "_react-dom", source: generateReactDOMShimSource(), key: "reactDom" },
1188
- { name: "_react-dom-client", source: generateReactDOMClientShimSource(), key: "reactDomClient" },
1189
- { name: "_jsx-runtime", source: generateJsxRuntimeShimSource(), key: "jsxRuntime" },
1190
- { name: "_jsx-dev-runtime", source: generateJsxDevRuntimeShimSource(), key: "jsxDevRuntime" },
1326
+ // Phase 7.1 B-2: dev-only Fast Refresh shims. In production we skip
1327
+ // them entirely so `react-refresh/runtime` is never bundled and the
1328
+ // attack surface / bundle size regressions stay zero for deploys.
1329
+ const isDev =
1330
+ (options.minify ?? process.env.NODE_ENV === "production") === false;
1331
+
1332
+ const shims: Array<{ name: string; source: string; key: VendorShimKey; cacheId: string }> = [
1333
+ { name: "_react", source: generateReactShimSource(), key: "react", cacheId: "react" },
1334
+ { name: "_react-dom", source: generateReactDOMShimSource(), key: "reactDom", cacheId: "react-dom" },
1335
+ { name: "_react-dom-client", source: generateReactDOMClientShimSource(), key: "reactDomClient", cacheId: "react-dom-client" },
1336
+ { name: "_jsx-runtime", source: generateJsxRuntimeShimSource(), key: "jsxRuntime", cacheId: "jsx-runtime" },
1337
+ { name: "_jsx-dev-runtime", source: generateJsxDevRuntimeShimSource(), key: "jsxDevRuntime", cacheId: "jsx-dev-runtime" },
1191
1338
  ];
1339
+ if (isDev) {
1340
+ shims.push(
1341
+ {
1342
+ name: "_vendor-react-refresh",
1343
+ source: generateReactRefreshRuntimeShimSource(),
1344
+ key: "reactRefreshRuntime",
1345
+ cacheId: "react-refresh-runtime",
1346
+ },
1347
+ {
1348
+ name: "_fast-refresh-runtime",
1349
+ source: generateFastRefreshRuntimeShimSource(),
1350
+ key: "fastRefreshRuntime",
1351
+ cacheId: "fast-refresh-glue",
1352
+ },
1353
+ );
1354
+ }
1355
+
1356
+ // Phase 7.2.S2 — Tier 2 disk cache consultation. Production builds
1357
+ // (non-dev, minified) still rebuild fresh because (a) the shim set is
1358
+ // smaller (no fast-refresh) and (b) production is one-shot — there's no
1359
+ // warm workflow to amortize the cache cost against. Dev warm restarts
1360
+ // are where the cache pays off.
1361
+ //
1362
+ // `MANDU_VENDOR_CACHE=0` disables the cache (escape hatch for debugging
1363
+ // cache-invalidation bugs without touching code).
1364
+ const cacheEnabled = isDev && process.env.MANDU_VENDOR_CACHE !== "0";
1365
+ let cacheKeys: VendorCacheKeyInput | null = null;
1366
+
1367
+ if (cacheEnabled) {
1368
+ try {
1369
+ cacheKeys = await resolveVendorCacheKeys(rootDir);
1370
+ const hitOrMiss = await readVendorCache(rootDir, cacheKeys);
1371
+
1372
+ if (hitOrMiss.kind === "hit") {
1373
+ // Attempt to restore every shim file to outDir.
1374
+ const restored = await restoreVendorCache(
1375
+ rootDir,
1376
+ hitOrMiss.manifest,
1377
+ outDir,
1378
+ );
1379
+ if (restored !== null) {
1380
+ // Map the restored files into the result shape. If a shim is
1381
+ // not in the manifest (eg. an older cache from before we added
1382
+ // fast-refresh) we rebuild just that one — but the simpler
1383
+ // policy is to require the manifest to cover every entry the
1384
+ // current shim list wants, so we only accept hits that include
1385
+ // everything. Otherwise fall through to rebuild.
1386
+ const expected = new Set(shims.map((s) => s.cacheId));
1387
+ const present = new Set(restored.keys());
1388
+ let allPresent = true;
1389
+ for (const id of expected) {
1390
+ if (!present.has(id)) {
1391
+ allPresent = false;
1392
+ break;
1393
+ }
1394
+ }
1395
+ if (allPresent) {
1396
+ // Populate result + return. Every shim's outputPath references
1397
+ // the freshly-restored file in `outDir`.
1398
+ for (const shim of shims) {
1399
+ const dst = restored.get(shim.cacheId);
1400
+ if (dst) {
1401
+ const fileName = path.basename(dst);
1402
+ results[shim.key] = `/.mandu/client/${fileName}`;
1403
+ }
1404
+ }
1405
+ mark(HMR_PERF.VENDOR_CACHE_HIT);
1406
+ measure(HMR_PERF.VENDOR_CACHE_HIT, HMR_PERF.VENDOR_CACHE_HIT);
1407
+ return {
1408
+ success: true,
1409
+ react: results.react,
1410
+ reactDom: results.reactDom,
1411
+ reactDomClient: results.reactDomClient,
1412
+ jsxRuntime: results.jsxRuntime,
1413
+ jsxDevRuntime: results.jsxDevRuntime,
1414
+ reactRefreshRuntime: results.reactRefreshRuntime,
1415
+ fastRefreshRuntime: results.fastRefreshRuntime,
1416
+ errors,
1417
+ };
1418
+ }
1419
+ // Restored but missing one of the expected shims — fall through.
1420
+ }
1421
+ // Restore failed — fall through to rebuild.
1422
+ }
1423
+
1424
+ // Miss / failed restore: record the miss marker for perf logs.
1425
+ mark(HMR_PERF.VENDOR_CACHE_MISS);
1426
+ measure(HMR_PERF.VENDOR_CACHE_MISS, HMR_PERF.VENDOR_CACHE_MISS);
1427
+ } catch {
1428
+ // Any cache failure falls through to the full rebuild path — cache
1429
+ // is strictly an optimisation.
1430
+ }
1431
+ }
1192
1432
 
1193
1433
  const buildShim = async (
1194
- shim: { name: string; source: string; key: VendorShimKey }
1195
- ): Promise<{ key: VendorShimKey; outputPath?: string; error?: string }> => {
1434
+ shim: { name: string; source: string; key: VendorShimKey; cacheId: string }
1435
+ ): Promise<{ key: VendorShimKey; cacheId: string; outputName?: string; outputPath?: string; error?: string }> => {
1196
1436
  const srcPath = path.join(outDir, `${shim.name}.src.js`);
1197
1437
  const outputName = `${shim.name}.js`;
1198
1438
 
@@ -1207,8 +1447,11 @@ async function buildVendorShims(
1207
1447
  } else if (shim.name === "_jsx-runtime" || shim.name === "_jsx-dev-runtime") {
1208
1448
  shimExternal = ["react"];
1209
1449
  }
1450
+ // `_vendor-react-refresh` and `_fast-refresh-runtime` are
1451
+ // self-contained: we WANT react-refresh bundled in so the
1452
+ // preamble's single dynamic import pulls the whole graph.
1210
1453
 
1211
- const result = await Bun.build({
1454
+ const result = await safeBuild({
1212
1455
  entrypoints: [srcPath],
1213
1456
  outdir: outDir,
1214
1457
  naming: outputName,
@@ -1228,32 +1471,57 @@ async function buildVendorShims(
1228
1471
  const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
1229
1472
  return {
1230
1473
  key: shim.key,
1474
+ cacheId: shim.cacheId,
1231
1475
  error: `Vendor shim '${shim.name}' build failed (source: ${srcPath}):\n${grouped}\n Hint: Check the import paths and ensure the vendor package is installed.`,
1232
1476
  };
1233
1477
  }
1234
1478
 
1235
1479
  return {
1236
1480
  key: shim.key,
1481
+ cacheId: shim.cacheId,
1482
+ outputName,
1237
1483
  outputPath: `/.mandu/client/${outputName}`,
1238
1484
  };
1239
1485
  } catch (error) {
1240
1486
  await fs.unlink(srcPath).catch(() => {});
1241
1487
  return {
1242
1488
  key: shim.key,
1489
+ cacheId: shim.cacheId,
1243
1490
  error: `[${shim.name}] ${String(error)}`,
1244
1491
  };
1245
1492
  }
1246
1493
  };
1247
1494
 
1248
1495
  const buildResults = await Promise.all(shims.map((shim) => buildShim(shim)));
1496
+ const writeEntries: VendorCacheWriteEntry[] = [];
1249
1497
  for (const result of buildResults) {
1250
1498
  if (result.error) {
1251
1499
  errors.push(result.error);
1252
- } else if (result.outputPath) {
1500
+ } else if (result.outputPath && result.outputName) {
1253
1501
  results[result.key] = result.outputPath;
1502
+ writeEntries.push({
1503
+ logicalId: result.cacheId,
1504
+ absPath: path.join(outDir, result.outputName),
1505
+ });
1254
1506
  }
1255
1507
  }
1256
1508
 
1509
+ // Phase 7.2.S2 — persist freshly-built shims for next boot. Best-effort;
1510
+ // a write failure is logged via perf markers (opt-in) but never breaks
1511
+ // the build. Only write when every shim succeeded — partial manifests
1512
+ // would still satisfy the expected-set check on next boot but we
1513
+ // prefer writing only complete manifests.
1514
+ if (cacheEnabled && cacheKeys && errors.length === 0 && writeEntries.length > 0) {
1515
+ // Fire-and-forget so the fast path isn't blocked on disk. The
1516
+ // returned VendorBuildResult does not depend on the write outcome.
1517
+ mark(HMR_PERF.VENDOR_CACHE_WRITE);
1518
+ void writeVendorCache(rootDir, cacheKeys, writeEntries)
1519
+ .then(() => {
1520
+ measure(HMR_PERF.VENDOR_CACHE_WRITE, HMR_PERF.VENDOR_CACHE_WRITE);
1521
+ })
1522
+ .catch(() => {});
1523
+ }
1524
+
1257
1525
  return {
1258
1526
  success: errors.length === 0,
1259
1527
  react: results.react,
@@ -1261,6 +1529,8 @@ async function buildVendorShims(
1261
1529
  reactDomClient: results.reactDomClient,
1262
1530
  jsxRuntime: results.jsxRuntime,
1263
1531
  jsxDevRuntime: results.jsxDevRuntime,
1532
+ reactRefreshRuntime: results.reactRefreshRuntime,
1533
+ fastRefreshRuntime: results.fastRefreshRuntime,
1264
1534
  errors,
1265
1535
  };
1266
1536
  }
@@ -1278,13 +1548,17 @@ async function buildIsland(
1278
1548
  const entryPath = path.join(outDir, `_entry_${route.id}.js`);
1279
1549
  const outputName = `${route.id}.island.js`;
1280
1550
 
1551
+ // Phase 7.1 B-1/B-4: wire native Fast Refresh transform + Mandu's
1552
+ // boundary injection plugin. Dev-only; prod bundles remain clean.
1553
+ const isDev =
1554
+ (options.minify ?? process.env.NODE_ENV === "production") === false;
1281
1555
  try {
1282
1556
  // 엔트리 래퍼 생성
1283
1557
  await Bun.write(entryPath, generateIslandEntry(route.id, clientModulePath));
1284
1558
 
1285
1559
  // 빌드
1286
1560
  // splitting 옵션: true면 공통 코드를 별도 청크로 추출
1287
- const result = await Bun.build({
1561
+ const result = await safeBuild({
1288
1562
  entrypoints: [entryPath],
1289
1563
  outdir: outDir,
1290
1564
  naming: options.splitting ? "[name]-[hash].js" : outputName,
@@ -1292,6 +1566,8 @@ async function buildIsland(
1292
1566
  sourcemap: options.sourcemap ? "external" : "none",
1293
1567
  target: "browser",
1294
1568
  splitting: options.splitting ?? (process.env.NODE_ENV === "production"),
1569
+ ...(isDev ? { reactFastRefresh: true } : {}),
1570
+ plugins: isDev ? [fastRefreshPlugin()] : [],
1295
1571
  external: ["react", "react-dom", "react-dom/client", ...(options.external || [])],
1296
1572
  define: {
1297
1573
  "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"),
@@ -1385,6 +1661,17 @@ function createBundleManifest(
1385
1661
  }
1386
1662
  }
1387
1663
 
1664
+ // Phase 7.1 B-2: expose Fast Refresh dev bundles so the HTML
1665
+ // preamble can inject a dynamic import pointing at them. Only
1666
+ // populated when buildVendorShims ran in dev mode.
1667
+ const fastRefresh =
1668
+ vendorResult.reactRefreshRuntime && vendorResult.fastRefreshRuntime
1669
+ ? {
1670
+ runtime: vendorResult.reactRefreshRuntime,
1671
+ glue: vendorResult.fastRefreshRuntime,
1672
+ }
1673
+ : undefined;
1674
+
1388
1675
  return {
1389
1676
  version: 1,
1390
1677
  buildTime: new Date().toISOString(),
@@ -1395,6 +1682,7 @@ function createBundleManifest(
1395
1682
  runtime: runtimePath,
1396
1683
  vendor: vendorResult.react, // primary vendor for backwards compatibility
1397
1684
  router: routerPath, // Client-side Router
1685
+ ...(fastRefresh ? { fastRefresh } : {}),
1398
1686
  },
1399
1687
  importMap: {
1400
1688
  imports: {
@@ -1456,6 +1744,7 @@ export async function buildClientBundles(
1456
1744
  rootDir: string,
1457
1745
  options: BundlerOptions = {}
1458
1746
  ): Promise<BundleResult> {
1747
+ mark("bundler:full");
1459
1748
  const startTime = performance.now();
1460
1749
  const outputs: BundleOutput[] = [];
1461
1750
  const errors: string[] = [];
@@ -1473,6 +1762,44 @@ export async function buildClientBundles(
1473
1762
  // Hydration 라우트가 없어도 빈 매니페스트를 저장해야 함
1474
1763
  // (이전 빌드의 stale 매니페스트 참조 방지)
1475
1764
  if (hydratedRoutes.length === 0) {
1765
+ // #185: skipFrameworkBundles 모드에서는 기존 manifest를 그대로 유지 (devtools 재빌드도 스킵)
1766
+ if (options.skipFrameworkBundles) {
1767
+ const manifestPath = path.join(rootDir, ".mandu/manifest.json");
1768
+ try {
1769
+ const manifestRaw = await fs.readFile(manifestPath, "utf-8");
1770
+ let existing: BundleManifest;
1771
+ try {
1772
+ existing = JSON.parse(manifestRaw) as BundleManifest;
1773
+ } catch (parseError) {
1774
+ // #186 hardening: corrupt JSON이면 silent overwrite 대신 경고 + full build로 fallback
1775
+ console.warn(
1776
+ `[Mandu] Existing manifest is corrupt, falling back to full build: ${parseError instanceof Error ? parseError.message : String(parseError)}`,
1777
+ );
1778
+ throw parseError;
1779
+ }
1780
+ // #186 hardening: 필수 필드 검증 (shared / bundles 누락 시 fallback)
1781
+ if (!existing || typeof existing !== "object" || !existing.shared || !existing.bundles) {
1782
+ console.warn("[Mandu] Existing manifest missing required fields, falling back to full build");
1783
+ throw new Error("invalid manifest shape");
1784
+ }
1785
+ return {
1786
+ success: true,
1787
+ outputs: [],
1788
+ errors: [],
1789
+ manifest: existing,
1790
+ stats: {
1791
+ totalSize: 0,
1792
+ totalGzipSize: 0,
1793
+ largestBundle: { routeId: "", size: 0 },
1794
+ buildTime: 0,
1795
+ bundleCount: 0,
1796
+ },
1797
+ };
1798
+ } catch {
1799
+ // 기존 manifest 없음/corrupt/invalid → full path로 fallback
1800
+ }
1801
+ }
1802
+
1476
1803
  // Dev 모드에서는 DevTools 번들 빌드 (island 없어도 동작해야 함)
1477
1804
  const isDev = env === "development";
1478
1805
  if (isDev) {
@@ -1506,13 +1833,18 @@ export async function buildClientBundles(
1506
1833
  if (options.targetRouteIds && options.targetRouteIds.length > 0) {
1507
1834
  const targetRoutes = hydratedRoutes.filter((r) => options.targetRouteIds!.includes(r.id));
1508
1835
 
1509
- for (const route of targetRoutes) {
1510
- try {
1511
- const result = await buildIsland(route, rootDir, outDir, options);
1512
- outputs.push(result);
1513
- } catch (error) {
1514
- errors.push(`[${route.id}] ${String(error)}`);
1515
- }
1836
+ const targetResults = await Promise.all(
1837
+ targetRoutes.map(async (route) => {
1838
+ try {
1839
+ return { ok: true as const, result: await buildIsland(route, rootDir, outDir, options) };
1840
+ } catch (error) {
1841
+ return { ok: false as const, routeId: route.id, error: String(error) };
1842
+ }
1843
+ }),
1844
+ );
1845
+ for (const r of targetResults) {
1846
+ if (r.ok) outputs.push(r.result);
1847
+ else errors.push(`[${r.routeId}] ${r.error}`);
1516
1848
  }
1517
1849
 
1518
1850
  // 기존 매니페스트를 읽어 변경된 Island만 갱신
@@ -1552,11 +1884,129 @@ export async function buildClientBundles(
1552
1884
  return { success: errors.length === 0, outputs, errors, manifest: existingManifest, stats };
1553
1885
  }
1554
1886
 
1887
+ // #185: Framework-internal 번들 스킵 모드
1888
+ // 사용자 코드(src/shared 등) 변경 시 runtime/router/vendor/devtools 재빌드는 낭비.
1889
+ // 기존 매니페스트를 로드해 framework 출력 경로만 재사용하고 사용자 island만 재빌드.
1890
+ if (options.skipFrameworkBundles) {
1891
+ let existingManifest: BundleManifest;
1892
+ try {
1893
+ const manifestData = await fs.readFile(path.join(rootDir, ".mandu/manifest.json"), "utf-8");
1894
+ existingManifest = JSON.parse(manifestData) as BundleManifest;
1895
+ } catch (parseError) {
1896
+ // 기존 매니페스트 없음/corrupt → 경고 후 full build로 fallback
1897
+ if (parseError instanceof SyntaxError) {
1898
+ console.warn(
1899
+ `[Mandu] Existing manifest is corrupt, falling back to full build: ${parseError.message}`,
1900
+ );
1901
+ }
1902
+ return buildClientBundles(manifest, rootDir, { ...options, skipFrameworkBundles: false });
1903
+ }
1904
+
1905
+ // #186 hardening: 필수 필드 검증 — 누락 시 full build로 fallback
1906
+ if (
1907
+ !existingManifest ||
1908
+ typeof existingManifest !== "object" ||
1909
+ !existingManifest.shared ||
1910
+ !existingManifest.bundles
1911
+ ) {
1912
+ console.warn(
1913
+ "[Mandu] Existing manifest missing required fields (shared/bundles), falling back to full build",
1914
+ );
1915
+ return buildClientBundles(manifest, rootDir, { ...options, skipFrameworkBundles: false });
1916
+ }
1917
+
1918
+ // Pre-build validation + 병렬 island 빌드 (framework 번들은 스킵)
1919
+ for (const route of hydratedRoutes) {
1920
+ if (!route.clientModule) continue;
1921
+ const clientModulePath = path.join(rootDir, route.clientModule);
1922
+ try {
1923
+ const source = await fs.readFile(clientModulePath, "utf-8");
1924
+ const wrongImportPattern = /(?:import|from)\s+['"]@mandujs\/core['"]|require\s*\(\s*['"]@mandujs\/core['"]\s*\)/;
1925
+ if (wrongImportPattern.test(source)) {
1926
+ errors.push(
1927
+ `[${route.id}] Island file "${route.clientModule}" imports from "@mandujs/core" which is a server-side module.\n` +
1928
+ ` Fix: Change the import to "@mandujs/core/client".`,
1929
+ );
1930
+ }
1931
+ } catch {
1932
+ // 파일 읽기 실패는 나중 빌드에서 catch됨
1933
+ }
1934
+ }
1935
+
1936
+ const islandResults = await Promise.all(
1937
+ hydratedRoutes.map(async (route) => {
1938
+ try {
1939
+ return { ok: true as const, result: await buildIsland(route, rootDir, outDir, options) };
1940
+ } catch (error) {
1941
+ return { ok: false as const, routeId: route.id, error: String(error) };
1942
+ }
1943
+ }),
1944
+ );
1945
+ for (const r of islandResults) {
1946
+ if (r.ok) outputs.push(r.result);
1947
+ else errors.push(`[${r.routeId}] ${r.error}`);
1948
+ }
1949
+
1950
+ // Per-island bundle 재빌드 (이미 병렬)
1951
+ const islandFiles = await scanIslandFiles(hydratedRoutes, rootDir);
1952
+ const perIslandBundles: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }> = [];
1953
+ if (islandFiles.length > 0) {
1954
+ const perIslandResults = await Promise.all(
1955
+ islandFiles.map(async (entry) => {
1956
+ try {
1957
+ return await buildPerIslandBundle(entry, outDir, options);
1958
+ } catch (error) {
1959
+ errors.push(`[island:${entry.name}] ${String(error)}`);
1960
+ return null;
1961
+ }
1962
+ }),
1963
+ );
1964
+ for (const result of perIslandResults) {
1965
+ if (result) perIslandBundles.push(result);
1966
+ }
1967
+ }
1968
+
1969
+ // 기존 manifest를 기반으로 bundles / islands 엔트리만 교체 (framework 경로는 유지)
1970
+ for (const output of outputs) {
1971
+ if (existingManifest.bundles[output.routeId]) {
1972
+ existingManifest.bundles[output.routeId].js = output.outputPath;
1973
+ } else {
1974
+ const route = hydratedRoutes.find((r) => r.id === output.routeId);
1975
+ const hydration = route ? getRouteHydration(route) : null;
1976
+ existingManifest.bundles[output.routeId] = {
1977
+ js: output.outputPath,
1978
+ dependencies: ["_runtime", "_react"],
1979
+ priority: hydration?.priority || HYDRATION.DEFAULT_PRIORITY,
1980
+ };
1981
+ }
1982
+ }
1983
+ if (perIslandBundles.length > 0) {
1984
+ existingManifest.islands = existingManifest.islands || {};
1985
+ for (const ib of perIslandBundles) {
1986
+ existingManifest.islands[ib.name] = {
1987
+ js: ib.js,
1988
+ route: ib.route,
1989
+ priority: ib.priority,
1990
+ };
1991
+ }
1992
+ }
1993
+
1994
+ await fs.writeFile(
1995
+ path.join(rootDir, ".mandu/manifest.json"),
1996
+ JSON.stringify(existingManifest, null, 2),
1997
+ );
1998
+
1999
+ const stats = calculateStats(outputs, startTime);
2000
+ return { success: errors.length === 0, outputs, errors, manifest: existingManifest, stats };
2001
+ }
2002
+
1555
2003
  // 3-4. Runtime, Router, Vendor, DevTools 번들 병렬 빌드 (서로 독립적)
1556
2004
  const isDev = env === "development";
1557
2005
  const runtimePromise = buildRuntime(outDir, options);
1558
2006
  const routerPromise = buildRouterRuntime(outDir, options);
1559
- const vendorPromise = buildVendorShims(outDir, options);
2007
+ // Phase 7.2.S2 — `rootDir` is now threaded through so buildVendorShims can
2008
+ // consult `.mandu/vendor-cache/` for warm-boot shim reuse.
2009
+ const vendorPromise = buildVendorShims(rootDir, outDir, options);
1560
2010
  const devtoolsPromise = isDev ? buildDevtoolsBundle(outDir, options) : null;
1561
2011
 
1562
2012
  const [runtimeResult, routerResult, vendorResult, devtoolsResult] = await Promise.all([
@@ -1602,23 +2052,30 @@ export async function buildClientBundles(
1602
2052
  }
1603
2053
  }
1604
2054
 
1605
- // 5. 각 Island 번들 빌드
1606
- for (const route of hydratedRoutes) {
1607
- try {
1608
- const result = await buildIsland(route, rootDir, outDir, options);
1609
- outputs.push(result);
1610
- } catch (error) {
1611
- const errorStr = String(error);
1612
- // Detect common mistake: importing @mandujs/core (server module) in client island
2055
+ // 5. 각 Island 번들 병렬 빌드 (#185: L1631의 per-island와 일관성 확보)
2056
+ const fullIslandResults = await Promise.all(
2057
+ hydratedRoutes.map(async (route) => {
2058
+ try {
2059
+ return { ok: true as const, result: await buildIsland(route, rootDir, outDir, options) };
2060
+ } catch (error) {
2061
+ return { ok: false as const, route, error: String(error) };
2062
+ }
2063
+ }),
2064
+ );
2065
+ for (const r of fullIslandResults) {
2066
+ if (r.ok) {
2067
+ outputs.push(r.result);
2068
+ } else {
2069
+ const errorStr = r.error;
1613
2070
  if (errorStr.includes("AggregateError") || errorStr.includes("Could not resolve")) {
1614
- const clientModule = route.clientModule || "";
2071
+ const clientModule = r.route.clientModule || "";
1615
2072
  errors.push(
1616
- `[${route.id}] ${errorStr}\n` +
2073
+ `[${r.route.id}] ${errorStr}\n` +
1617
2074
  ` 💡 Hint: If your island imports from "@mandujs/core", change it to "@mandujs/core/client".\n` +
1618
- ` Client islands cannot use server-side modules. File: ${clientModule}`
2075
+ ` Client islands cannot use server-side modules. File: ${clientModule}`,
1619
2076
  );
1620
2077
  } else {
1621
- errors.push(`[${route.id}] ${errorStr}`);
2078
+ errors.push(`[${r.route.id}] ${errorStr}`);
1622
2079
  }
1623
2080
  }
1624
2081
  }
@@ -1662,6 +2119,7 @@ export async function buildClientBundles(
1662
2119
  // 7. 통계 계산
1663
2120
  const stats = calculateStats(outputs, startTime);
1664
2121
 
2122
+ measure("bundler:full", "bundler:full");
1665
2123
  return {
1666
2124
  success: errors.length === 0,
1667
2125
  outputs,