@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
@@ -2,9 +2,17 @@ import type { Server } from "bun";
2
2
  import type { RoutesManifest, RouteSpec, HydrationConfig } from "../spec/schema";
3
3
  import type { BundleManifest } from "../bundler/types";
4
4
  import type { ManduFilling, RenderMode } from "../filling/filling";
5
- import { ManduContext, type CookieManager } from "../filling/context";
5
+ import { ManduContext, CookieManager } from "../filling/context";
6
6
  import { Router } from "./router";
7
7
  import { renderSSR, renderStreamingResponse } from "./ssr";
8
+ import {
9
+ resolveMetadata,
10
+ renderMetadata,
11
+ renderTitle,
12
+ type Metadata,
13
+ type MetadataItem,
14
+ type GenerateMetadata,
15
+ } from "../seo";
8
16
  import { type ErrorFallbackProps } from "./boundary";
9
17
  import React, { type ReactNode } from "react";
10
18
  import path from "path";
@@ -50,6 +58,9 @@ import { createFetchHandler } from "./handler";
50
58
  import { wrapBunWebSocket, type WSUpgradeData } from "../filling/ws";
51
59
  import { handleImageRequest } from "./image-handler";
52
60
  import { extractShellHtml, createPPRResponse } from "./ppr";
61
+ import { isRedirectResponse } from "./redirect";
62
+ import { isNotFoundResponse } from "./not-found";
63
+ import { newId } from "../id";
53
64
 
54
65
  export interface RateLimitOptions {
55
66
  windowMs?: number;
@@ -390,6 +401,10 @@ export type ErrorLoader = () => Promise<{ default: ErrorComponent }>;
390
401
  export interface PageRegistration {
391
402
  component: React.ComponentType<{ params: Record<string, string>; loaderData?: unknown }>;
392
403
  filling?: ManduFilling<unknown>;
404
+ /** #186: page 모듈의 static `metadata` export (선택) */
405
+ metadata?: Metadata;
406
+ /** #186: page 모듈의 `generateMetadata` 함수 export (선택) */
407
+ generateMetadata?: GenerateMetadata;
393
408
  }
394
409
 
395
410
  /**
@@ -456,6 +471,13 @@ export class ServerRegistry {
456
471
  readonly errorLoaders: Map<string, ErrorLoader> = new Map();
457
472
  createAppFn: CreateAppFn | null = null;
458
473
  rateLimiter: MemoryRateLimiter | null = null;
474
+ /**
475
+ * Phase 6.3: app-level `not-found.tsx` handler. Returns the React
476
+ * component used for 404 rendering. Set via {@link registerNotFoundHandler}.
477
+ * Global — one per app, registered at startup. Unresolved → fall back
478
+ * to the framework's built-in 404 JSON error.
479
+ */
480
+ notFoundHandler: PageHandler | null = null;
459
481
  /** Kitchen dev dashboard handler (dev mode only) */
460
482
  kitchen: KitchenHandler | null = null;
461
483
  /** 라우트별 캐시 옵션 (filling.loader()의 cacheOptions에서 등록) */
@@ -466,6 +488,17 @@ export class ServerRegistry {
466
488
  readonly layoutSlotPaths: Map<string, string | null> = new Map();
467
489
  /** WebSocket 핸들러 (라우트 ID → WSHandlers) */
468
490
  readonly wsHandlers: Map<string, import("../filling/ws").WSHandlers> = new Map();
491
+ /**
492
+ * Metadata API 캐시 (#186)
493
+ * - pageMetadata: routeId → page 모듈의 static `metadata` export
494
+ * - pageGenerateMetadata: routeId → `generateMetadata` 함수
495
+ * - layoutMetadata: layout 모듈 경로 → static `metadata` export (null = 시도했지만 없음)
496
+ * - layoutGenerateMetadata: layout 모듈 경로 → `generateMetadata` 함수
497
+ */
498
+ readonly pageMetadata: Map<string, import("../seo").Metadata> = new Map();
499
+ readonly pageGenerateMetadata: Map<string, import("../seo").GenerateMetadata> = new Map();
500
+ readonly layoutMetadata: Map<string, import("../seo").Metadata | null> = new Map();
501
+ readonly layoutGenerateMetadata: Map<string, import("../seo").GenerateMetadata> = new Map();
469
502
  settings: ServerRegistrySettings = {
470
503
  isDev: false,
471
504
  rootDir: process.cwd(),
@@ -512,6 +545,16 @@ export class ServerRegistry {
512
545
  this.errorLoaders.set(modulePath, loader);
513
546
  }
514
547
 
548
+ /**
549
+ * Phase 6.3: register the app-level not-found handler. Follows the
550
+ * same factory shape as `registerPageHandler` (async component loader)
551
+ * so users can lazy-import their `app/not-found.tsx`. Only one handler
552
+ * is retained — later calls overwrite earlier ones.
553
+ */
554
+ registerNotFoundHandler(handler: PageHandler): void {
555
+ this.notFoundHandler = handler;
556
+ }
557
+
515
558
  /**
516
559
  * 제네릭 컴포넌트 로더 (DRY)
517
560
  * 캐시 → 로더 → 동적 import 순서로 시도
@@ -537,6 +580,22 @@ export class ServerRegistry {
537
580
  const cached = cacheMap.get(modulePath);
538
581
  if (cached) return cached;
539
582
 
583
+ // #186: layout인 경우 metadata / generateMetadata export를 함께 캐싱
584
+ const cacheLayoutMetadata = (mod: unknown) => {
585
+ if (type !== "layout") return;
586
+ if (this.layoutMetadata.has(modulePath)) return;
587
+ const modObj = (mod && typeof mod === "object" ? (mod as Record<string, unknown>) : null);
588
+ const staticMeta = modObj?.metadata;
589
+ const generateFn = modObj?.generateMetadata;
590
+ this.layoutMetadata.set(
591
+ modulePath,
592
+ staticMeta && typeof staticMeta === "object" ? (staticMeta as Metadata) : null,
593
+ );
594
+ if (typeof generateFn === "function") {
595
+ this.layoutGenerateMetadata.set(modulePath, generateFn as GenerateMetadata);
596
+ }
597
+ };
598
+
540
599
  // 2. 등록된 로더 시도
541
600
  const loader = loaderMap.get(modulePath);
542
601
  if (loader) {
@@ -544,6 +603,7 @@ export class ServerRegistry {
544
603
  const module = await loader();
545
604
  const component = module.default;
546
605
  cacheMap.set(modulePath, component);
606
+ cacheLayoutMetadata(module);
547
607
  return component;
548
608
  } catch (error) {
549
609
  console.error(`[Mandu] Failed to load ${type}: ${modulePath}`, error);
@@ -562,6 +622,7 @@ export class ServerRegistry {
562
622
  const module = await import(validation.value);
563
623
  const component = module.default;
564
624
  cacheMap.set(modulePath, component);
625
+ cacheLayoutMetadata(module);
565
626
  return component;
566
627
  } catch (error) {
567
628
  // layout은 에러 로깅, loading/error는 조용히 실패
@@ -611,8 +672,13 @@ export class ServerRegistry {
611
672
  this.loadingLoaders.clear();
612
673
  this.errorComponents.clear();
613
674
  this.errorLoaders.clear();
675
+ this.pageMetadata.clear();
676
+ this.pageGenerateMetadata.clear();
677
+ this.layoutMetadata.clear();
678
+ this.layoutGenerateMetadata.clear();
614
679
  this.createAppFn = null;
615
680
  this.rateLimiter = null;
681
+ this.notFoundHandler = null;
616
682
  }
617
683
  }
618
684
 
@@ -683,6 +749,15 @@ export function registerErrorLoader(modulePath: string, loader: ErrorLoader): vo
683
749
  defaultRegistry.registerErrorLoader(modulePath, loader);
684
750
  }
685
751
 
752
+ /**
753
+ * Phase 6.3: register the app-level not-found handler on the default
754
+ * registry. Called once at app init (either by codegen or manually)
755
+ * with a PageHandler that resolves to the `not-found.tsx` component.
756
+ */
757
+ export function registerNotFoundHandler(handler: PageHandler): void {
758
+ defaultRegistry.registerNotFoundHandler(handler);
759
+ }
760
+
686
761
  export function registerWSHandler(routeId: string, handlers: import("../filling/ws").WSHandlers): void {
687
762
  defaultRegistry.wsHandlers.set(routeId, handlers);
688
763
  }
@@ -1120,7 +1195,7 @@ async function handleInternalCacheControlRequest(
1120
1195
  async function handleRequest(req: Request, router: Router, registry: ServerRegistry): Promise<Response> {
1121
1196
  const requestStart = Date.now();
1122
1197
  // Phase 1-4: Correlation ID — 한 요청에서 발생하는 모든 이벤트를 추적
1123
- const correlationId = req.headers.get("x-mandu-request-id") ?? crypto.randomUUID();
1198
+ const correlationId = req.headers.get("x-mandu-request-id") ?? newId();
1124
1199
  const result = await handleRequestInternal(req, router, registry);
1125
1200
 
1126
1201
  if (!result.ok) {
@@ -1212,11 +1287,99 @@ async function handleApiRoute(
1212
1287
 
1213
1288
  // ---------- Page Data Loader ----------
1214
1289
 
1290
+ /**
1291
+ * Merge any pending Set-Cookie headers from ctx.cookies into the given
1292
+ * Response. Used when a loader short-circuits via `redirect(...)` — session
1293
+ * mutations made before the redirect call must still be emitted.
1294
+ *
1295
+ * CookieManager.applyToResponse already handles the no-op case (empty
1296
+ * cookie set); we guard first anyway to avoid cloning the Response body
1297
+ * unnecessarily (Response.redirect's body is always null, but keeping
1298
+ * this cheap).
1299
+ */
1300
+ function mergeCookiesIntoResponse(response: Response, cookies: CookieManager): Response {
1301
+ if (!cookies.hasPendingCookies()) return response;
1302
+ return cookies.applyToResponse(response);
1303
+ }
1304
+
1305
+ /**
1306
+ * Phase 6.3: derive a short opaque digest for an Error so dev and prod
1307
+ * renders can both reference the same log entry. Not a security token —
1308
+ * just a correlation aid. We hash `message + top stack frame` for a
1309
+ * stable-ish 8-char hex that survives the same error thrown twice.
1310
+ *
1311
+ * Exported for unit testing. Not part of the public API.
1312
+ */
1313
+ export function computeErrorDigest(error: Error): string {
1314
+ const source = `${error.message ?? ""}::${(error.stack ?? "").split("\n")[1] ?? ""}`;
1315
+ // FNV-1a 32-bit — cheap, no deps, deterministic. Avoids pulling crypto.
1316
+ let hash = 0x811c9dc5;
1317
+ for (let i = 0; i < source.length; i++) {
1318
+ hash ^= source.charCodeAt(i);
1319
+ hash = Math.imul(hash, 0x01000193);
1320
+ }
1321
+ return (hash >>> 0).toString(16).padStart(8, "0");
1322
+ }
1323
+
1324
+ /**
1325
+ * Phase 6.3: redact an Error for the rendered error-boundary surface.
1326
+ *
1327
+ * In dev: pass-through — `error.tsx` sees the original Error unchanged.
1328
+ * In prod: produce a clone with
1329
+ * - `.message` kept (users want a hint)
1330
+ * - `.stack` trimmed to the error header + the top 3 frames (enough
1331
+ * to tell "it's my code" vs "it's node_modules" without leaking the
1332
+ * whole call tree to the browser)
1333
+ * - the original `.name` preserved
1334
+ *
1335
+ * The returned value is always an Error (or subclass), so React rendering
1336
+ * can treat it uniformly. A matching digest is computed and returned so
1337
+ * the caller can pass it to the `digest` prop.
1338
+ *
1339
+ * Exported for unit testing. Not part of the public API.
1340
+ */
1341
+ export function redactErrorForBoundary(error: Error, isDev: boolean): { error: Error; digest: string } {
1342
+ const digest = computeErrorDigest(error);
1343
+ if (isDev) {
1344
+ return { error, digest };
1345
+ }
1346
+ const redacted = new Error(error.message);
1347
+ redacted.name = error.name;
1348
+ if (typeof error.stack === "string") {
1349
+ const lines = error.stack.split("\n");
1350
+ // Keep the header line (`Error: msg`) + up to 3 frames.
1351
+ redacted.stack = lines.slice(0, 4).join("\n");
1352
+ } else {
1353
+ redacted.stack = undefined;
1354
+ }
1355
+ return { error: redacted, digest };
1356
+ }
1357
+
1215
1358
  interface PageLoadResult {
1216
1359
  loaderData: unknown;
1217
1360
  cookies?: CookieManager;
1218
1361
  /** Layout별 loader 데이터 (모듈 경로 → 데이터) */
1219
1362
  layoutData?: Map<string, unknown>;
1363
+ /**
1364
+ * If the page's loader returned or threw a redirect Response, it surfaces
1365
+ * here. Callers short-circuit SSR and emit this Response to the browser
1366
+ * with any pending ctx.cookies merged in (session/CSRF must survive).
1367
+ *
1368
+ * NOTE: a bare `throw new Error(...)` does NOT set this — only Response
1369
+ * instances with a redirect-range status + Location header. See
1370
+ * `isRedirectResponse()` in runtime/redirect.ts.
1371
+ */
1372
+ redirect?: Response;
1373
+ /**
1374
+ * Phase 6.3: page loader returned/threw `notFound()`. When set, the
1375
+ * caller renders `app/not-found.tsx` (if registered) or falls through
1376
+ * to the built-in 404. The original Response carries the message body
1377
+ * as plain text so it can be surfaced on the 404 page.
1378
+ *
1379
+ * NOTE: a bare `new Response(null, { status: 404 })` does NOT set this
1380
+ * — only `notFound()` from `runtime/not-found.ts` (checked via brand).
1381
+ */
1382
+ notFound?: Response;
1220
1383
  }
1221
1384
 
1222
1385
  /**
@@ -1240,7 +1403,40 @@ async function loadPageData(
1240
1403
  // Filling의 loader 실행
1241
1404
  if (registration.filling?.hasLoader()) {
1242
1405
  const ctx = new ManduContext(req, params);
1243
- loaderData = await registration.filling.executeLoader(ctx);
1406
+ // DX-3: loader may return OR throw a redirect Response. Both are
1407
+ // short-circuits — if we detect one, skip SSR and hand the Response
1408
+ // to the caller with pending cookies merged in.
1409
+ // Phase 6.3: same semantics for notFound() — checked BEFORE redirect
1410
+ // so a loader that does `throw notFound()` surfaces through the
1411
+ // dedicated path rather than hitting the isRedirectResponse false
1412
+ // positive (it won't — notFound has no Location — but being explicit
1413
+ // prevents future regressions).
1414
+ let returned: unknown;
1415
+ try {
1416
+ returned = await registration.filling.executeLoader(ctx);
1417
+ } catch (thrown) {
1418
+ if (isNotFoundResponse(thrown)) {
1419
+ // Carry the pending cookies on the result so the renderer can
1420
+ // merge them onto the rendered not-found page (which is a
1421
+ // fresh Response — the nfResponse body isn't reused).
1422
+ const nfCookies = ctx.cookies.hasPendingCookies() ? ctx.cookies : undefined;
1423
+ return ok({ loaderData: undefined, notFound: thrown, cookies: nfCookies });
1424
+ }
1425
+ if (isRedirectResponse(thrown)) {
1426
+ const redirectResponse = mergeCookiesIntoResponse(thrown, ctx.cookies);
1427
+ return ok({ loaderData: undefined, redirect: redirectResponse });
1428
+ }
1429
+ throw thrown;
1430
+ }
1431
+ if (isNotFoundResponse(returned)) {
1432
+ const nfCookies = ctx.cookies.hasPendingCookies() ? ctx.cookies : undefined;
1433
+ return ok({ loaderData: undefined, notFound: returned, cookies: nfCookies });
1434
+ }
1435
+ if (isRedirectResponse(returned)) {
1436
+ const redirectResponse = mergeCookiesIntoResponse(returned, ctx.cookies);
1437
+ return ok({ loaderData: undefined, redirect: redirectResponse });
1438
+ }
1439
+ loaderData = returned;
1244
1440
  if (ctx.cookies.hasPendingCookies()) {
1245
1441
  cookies = ctx.cookies;
1246
1442
  }
@@ -1270,15 +1466,63 @@ async function loadPageData(
1270
1466
  : (exportedObj?.component ?? exported);
1271
1467
  registry.registerRouteComponent(route.id, component as RouteComponent);
1272
1468
 
1469
+ // #186: page 모듈에서 metadata / generateMetadata export 캐싱
1470
+ const modObj = module as Record<string, unknown>;
1471
+ if (modObj.metadata && typeof modObj.metadata === "object") {
1472
+ registry.pageMetadata.set(route.id, modObj.metadata as Metadata);
1473
+ }
1474
+ if (typeof modObj.generateMetadata === "function") {
1475
+ registry.pageGenerateMetadata.set(
1476
+ route.id,
1477
+ modObj.generateMetadata as GenerateMetadata,
1478
+ );
1479
+ }
1480
+
1273
1481
  // filling이 있으면 캐시 옵션 등록 + loader 실행
1482
+ // Support both page-module shapes:
1483
+ // (a) `export default { component, filling }` — object default
1484
+ // (b) `export default function Page()` + `export const filling = …`
1485
+ // — function default with named filling export
1486
+ // (b) is the more natural TS/React shape; without this fallback, filling
1487
+ // silently does nothing and pages render without loader data.
1274
1488
  let cookies: CookieManager | undefined;
1275
- const filling = typeof exported === "object" && exported !== null ? (exportedObj as Record<string, unknown>)?.filling as ManduFilling | null : null;
1489
+ const fillingFromDefault =
1490
+ typeof exported === "object" && exported !== null
1491
+ ? ((exportedObj as Record<string, unknown>)?.filling as ManduFilling | null | undefined)
1492
+ : null;
1493
+ const fillingFromNamed = modObj.filling as ManduFilling | null | undefined;
1494
+ const filling: ManduFilling | null = fillingFromDefault ?? fillingFromNamed ?? null;
1276
1495
  if (filling?.getCacheOptions?.()) {
1277
1496
  registry.cacheOptions.set(route.id, filling.getCacheOptions()!);
1278
1497
  }
1279
1498
  if (filling?.hasLoader?.()) {
1280
1499
  const ctx = new ManduContext(req, params);
1281
- loaderData = await filling.executeLoader(ctx);
1500
+ // DX-3 / Phase 6.3: same redirect + notFound handling as the
1501
+ // PageHandler path above. notFound is checked first so both
1502
+ // short-circuits remain symmetric.
1503
+ let returned: unknown;
1504
+ try {
1505
+ returned = await filling.executeLoader(ctx);
1506
+ } catch (thrown) {
1507
+ if (isNotFoundResponse(thrown)) {
1508
+ const nfCookies = ctx.cookies.hasPendingCookies() ? ctx.cookies : undefined;
1509
+ return ok({ loaderData: undefined, notFound: thrown, cookies: nfCookies });
1510
+ }
1511
+ if (isRedirectResponse(thrown)) {
1512
+ const redirectResponse = mergeCookiesIntoResponse(thrown, ctx.cookies);
1513
+ return ok({ loaderData: undefined, redirect: redirectResponse });
1514
+ }
1515
+ throw thrown;
1516
+ }
1517
+ if (isNotFoundResponse(returned)) {
1518
+ const nfCookies = ctx.cookies.hasPendingCookies() ? ctx.cookies : undefined;
1519
+ return ok({ loaderData: undefined, notFound: returned, cookies: nfCookies });
1520
+ }
1521
+ if (isRedirectResponse(returned)) {
1522
+ const redirectResponse = mergeCookiesIntoResponse(returned, ctx.cookies);
1523
+ return ok({ loaderData: undefined, redirect: redirectResponse });
1524
+ }
1525
+ loaderData = returned;
1282
1526
  if (ctx.cookies.hasPendingCookies()) {
1283
1527
  cookies = ctx.cookies;
1284
1528
  }
@@ -1299,18 +1543,71 @@ async function loadPageData(
1299
1543
  return ok({ loaderData });
1300
1544
  }
1301
1545
 
1546
+ interface LayoutLoadResult {
1547
+ /** Layout별 loader 데이터 (모듈 경로 → 데이터) */
1548
+ data: Map<string, unknown>;
1549
+ /**
1550
+ * Layout chain이 ctx.cookies.set(...) 으로 쌓은 pending 쿠키들.
1551
+ * loader 여러 개가 쿠키를 쓰면 chain 순서대로 병합되어 단일 CookieManager가 됨
1552
+ * (부모 layout → 자식 layout 방향으로 later-wins).
1553
+ * 쓴 쿠키가 없으면 undefined.
1554
+ *
1555
+ * DX-2: 예전엔 layout slot의 ctx.cookies가 drop 됐음 — 이제 여기서 response로 전파된다.
1556
+ */
1557
+ cookies: CookieManager | undefined;
1558
+ }
1559
+
1560
+ /**
1561
+ * Layout + Page CookieManager 병합.
1562
+ *
1563
+ * 규칙 (DX-2):
1564
+ * - layout 이 쓴 Set-Cookie 는 먼저, page 가 쓴 Set-Cookie 는 나중에 오도록 순서 고정.
1565
+ * - HTTP 상 같은 이름의 Set-Cookie 가 여러 번 붙으면 브라우저는 뒤에 온 것(= page)을 최종 값으로 채택.
1566
+ * - 이렇게 하면 middleware → handler 와 동일한 "뒤에 온 것이 이긴다" 관례를 유지.
1567
+ *
1568
+ * 구현은 raw-append 만 사용해서 한쪽 CookieManager 를 mutate 하지 않는다.
1569
+ * (page 의 응답 API [ctx.json 등] 가 내부적으로 page 의 CookieManager 를 다시 쓸 수 있으므로
1570
+ * page CookieManager 를 mutate 해버리면 double-emit 위험이 생긴다.)
1571
+ */
1572
+ function mergeCookieManagers(
1573
+ req: Request,
1574
+ layout: CookieManager | undefined,
1575
+ page: CookieManager | undefined,
1576
+ ): CookieManager | undefined {
1577
+ if (!layout && !page) return undefined;
1578
+ if (!layout) return page;
1579
+ if (!page) return layout;
1580
+
1581
+ // 둘 다 있으면 빈 CookieManager 에 raw 로 layout → page 순서로 쌓는다.
1582
+ const merged = new CookieManager(req);
1583
+ for (const header of layout.getSetCookieHeaders()) {
1584
+ merged.appendRawSetCookie(header);
1585
+ }
1586
+ for (const header of page.getSetCookieHeaders()) {
1587
+ merged.appendRawSetCookie(header);
1588
+ }
1589
+ return merged;
1590
+ }
1591
+
1302
1592
  /**
1303
1593
  * Layout chain의 모든 loader를 병렬 실행
1304
1594
  * 각 layout.slot.ts가 있으면 해당 데이터를 layout props로 전달
1595
+ *
1596
+ * Cookie 처리 (DX-2):
1597
+ * - 각 layout slot은 개별 ManduContext 에서 실행되므로 각자의 CookieManager 를 가짐
1598
+ * - 함수 리턴 전에 chain 순서대로 하나의 CookieManager 로 병합해 반환
1599
+ * - 같은 이름의 쿠키를 여러 layout 이 set 하면 layout chain 후반부(= children 에 가까운 쪽)가 이김
1305
1600
  */
1306
1601
  async function loadLayoutData(
1307
1602
  req: Request,
1308
1603
  layoutChain: string[] | undefined,
1309
1604
  params: Record<string, string>,
1310
1605
  registry: ServerRegistry
1311
- ): Promise<Map<string, unknown>> {
1606
+ ): Promise<LayoutLoadResult> {
1312
1607
  const layoutData = new Map<string, unknown>();
1313
- if (!layoutChain || layoutChain.length === 0) return layoutData;
1608
+ if (!layoutChain || layoutChain.length === 0) {
1609
+ return { data: layoutData, cookies: undefined };
1610
+ }
1314
1611
 
1315
1612
  // layout.slot.ts 파일 검색: layout 모듈 경로에서 .slot.ts 파일 경로 유도
1316
1613
  // 예: app/layout.tsx → spec/slots/layout.slot.ts (auto-link 규칙)
@@ -1351,7 +1648,7 @@ async function loadLayoutData(
1351
1648
  }
1352
1649
  }
1353
1650
 
1354
- if (loaderEntries.length === 0) return layoutData;
1651
+ if (loaderEntries.length === 0) return { data: layoutData, cookies: undefined };
1355
1652
 
1356
1653
  const results = await Promise.all(
1357
1654
  loaderEntries.map(async ({ modulePath, slotPath }) => {
@@ -1364,27 +1661,154 @@ async function loadLayoutData(
1364
1661
  if (filling.hasLoader()) {
1365
1662
  const ctx = new ManduContext(req, params);
1366
1663
  const data = await filling.executeLoader(ctx);
1367
- return { modulePath, data };
1664
+ // DX-3: layout loaders are NOT allowed to redirect. They share
1665
+ // the pipeline with a page loader and we can only honor one
1666
+ // redirect — the page's wins (authoritative). If a layout
1667
+ // returned a Response we log + discard it (keeping cookies
1668
+ // the layout may have set). Users who want layout-level auth
1669
+ // should put the redirect in the page's loader.
1670
+ if (isRedirectResponse(data)) {
1671
+ console.warn(
1672
+ `[Mandu] Layout loader for ${modulePath} returned a redirect Response; ignoring. ` +
1673
+ `Put redirect() in a page loader instead — layout loaders cannot short-circuit rendering.`
1674
+ );
1675
+ const cookies = ctx.cookies.hasPendingCookies() ? ctx.cookies : undefined;
1676
+ return { modulePath, data: undefined, cookies };
1677
+ }
1678
+ const cookies = ctx.cookies.hasPendingCookies() ? ctx.cookies : undefined;
1679
+ return { modulePath, data, cookies };
1368
1680
  }
1369
1681
  }
1370
1682
  } catch (error) {
1371
- console.warn(`[Mandu] Layout loader failed for ${modulePath}:`, error);
1683
+ // A thrown redirect Response from a layout would land here —
1684
+ // same rule: ignore, it's not the layout's decision to make.
1685
+ if (isRedirectResponse(error)) {
1686
+ console.warn(
1687
+ `[Mandu] Layout loader for ${modulePath} threw a redirect Response; ignoring. ` +
1688
+ `Put redirect() in a page loader instead.`
1689
+ );
1690
+ } else {
1691
+ console.warn(`[Mandu] Layout loader failed for ${modulePath}:`, error);
1692
+ }
1372
1693
  }
1373
- return { modulePath, data: undefined };
1694
+ return { modulePath, data: undefined, cookies: undefined };
1374
1695
  })
1375
1696
  );
1376
1697
 
1377
- for (const { modulePath, data } of results) {
1698
+ // chain 순서 유지: loaderEntries 순으로 결과를 순회
1699
+ // (Promise.all 은 입력 순서대로 배열을 보존하므로 results 의 index 가 chain 순서와 일치)
1700
+ //
1701
+ // Layout chain 단일 쿠키 병합 전략 (DX-2):
1702
+ // - 쿠키를 쓴 layout 이 하나라도 있으면 빈 CookieManager 를 만들고 chain 순서대로 raw-append
1703
+ // - 같은 이름의 쿠키를 여러 layout 이 set 하면 chain 후반부(= children 에 가까운 쪽)가 뒤에 나오므로
1704
+ // 브라우저 semantics 상 이김 (HTTP 상 마지막 Set-Cookie 가 최종 값)
1705
+ // - 개별 loader 의 CookieManager 를 mutate 하지 않음 (test assertion 안전성 확보)
1706
+ let mergedCookies: CookieManager | undefined;
1707
+ for (const { modulePath, data, cookies } of results) {
1378
1708
  if (data !== undefined) {
1379
1709
  layoutData.set(modulePath, data);
1380
1710
  }
1711
+ if (cookies) {
1712
+ if (!mergedCookies) {
1713
+ mergedCookies = new CookieManager(req);
1714
+ }
1715
+ for (const rawSetCookie of cookies.getSetCookieHeaders()) {
1716
+ mergedCookies.appendRawSetCookie(rawSetCookie);
1717
+ }
1718
+ }
1381
1719
  }
1382
1720
 
1383
- return layoutData;
1721
+ return { data: layoutData, cookies: mergedCookies };
1384
1722
  }
1385
1723
 
1386
1724
  // ---------- SSR Renderer ----------
1387
1725
 
1726
+ /**
1727
+ * #186: URL에서 searchParams를 Record<string, string>로 추출 (SEO 모듈 시그니처)
1728
+ */
1729
+ function extractSearchParams(url: string): Record<string, string> {
1730
+ try {
1731
+ const u = new URL(url);
1732
+ const result: Record<string, string> = {};
1733
+ for (const [key, value] of u.searchParams.entries()) {
1734
+ if (!(key in result)) result[key] = value;
1735
+ }
1736
+ return result;
1737
+ } catch {
1738
+ return {};
1739
+ }
1740
+ }
1741
+
1742
+ /**
1743
+ * #186: layout chain + page metadata를 순서대로 수집해 MetadataItem[] 구성
1744
+ * - 각 layout의 generateMetadata 우선, 없으면 static metadata
1745
+ * - page 모듈의 generateMetadata 우선, 없으면 static metadata
1746
+ * - 결과 배열을 SEO 모듈의 resolveMetadata에 전달
1747
+ */
1748
+ async function collectMetadataItems(
1749
+ route: { id: string; layoutChain?: string[] },
1750
+ registry: ServerRegistry,
1751
+ ): Promise<MetadataItem[]> {
1752
+ const items: MetadataItem[] = [];
1753
+
1754
+ if (route.layoutChain) {
1755
+ for (const layoutPath of route.layoutChain) {
1756
+ // Layout 모듈 로드 → metadata / generateMetadata 캐시 채움
1757
+ await registry.getLayoutComponent(layoutPath);
1758
+ const dyn = registry.layoutGenerateMetadata.get(layoutPath);
1759
+ if (dyn) {
1760
+ items.push(dyn);
1761
+ continue;
1762
+ }
1763
+ const staticMeta = registry.layoutMetadata.get(layoutPath);
1764
+ if (staticMeta) items.push(staticMeta);
1765
+ }
1766
+ }
1767
+
1768
+ const pageDyn = registry.pageGenerateMetadata.get(route.id);
1769
+ if (pageDyn) {
1770
+ items.push(pageDyn);
1771
+ } else {
1772
+ const pageStatic = registry.pageMetadata.get(route.id);
1773
+ if (pageStatic) items.push(pageStatic);
1774
+ }
1775
+
1776
+ return items;
1777
+ }
1778
+
1779
+ /**
1780
+ * #186: 해석된 Metadata를 SSR 옵션(title + headTags)으로 변환
1781
+ */
1782
+ async function buildSSRMetadata(
1783
+ route: { id: string; layoutChain?: string[] },
1784
+ params: Record<string, string>,
1785
+ url: string,
1786
+ registry: ServerRegistry,
1787
+ ): Promise<{ title: string; headTags: string }> {
1788
+ try {
1789
+ const items = await collectMetadataItems(route, registry);
1790
+ if (items.length === 0) {
1791
+ return { title: "Mandu App", headTags: "" };
1792
+ }
1793
+ const resolved = await resolveMetadata(items, params, extractSearchParams(url));
1794
+ const titleHtml = renderTitle(resolved);
1795
+ const headTags = renderMetadata(resolved);
1796
+ // resolveMetadata는 <title>을 headTags 안에 이미 포함시키므로,
1797
+ // 중복 방지를 위해 title은 문자열만 뽑고 headTags에서 <title>을 제거
1798
+ const title = extractTitleText(titleHtml) ?? "Mandu App";
1799
+ const headWithoutTitle = headTags.replace(/<title>[^<]*<\/title>\n?/i, "");
1800
+ return { title, headTags: headWithoutTitle };
1801
+ } catch (error) {
1802
+ console.warn("[Mandu] metadata resolution failed:", error);
1803
+ return { title: "Mandu App", headTags: "" };
1804
+ }
1805
+ }
1806
+
1807
+ function extractTitleText(titleHtml: string): string | null {
1808
+ const match = /<title>([^<]*)<\/title>/i.exec(titleHtml);
1809
+ return match ? match[1] : null;
1810
+ }
1811
+
1388
1812
  /**
1389
1813
  * SSR 렌더링 (Streaming/Non-streaming)
1390
1814
  */
@@ -1437,6 +1861,9 @@ async function renderPageSSR(
1437
1861
  ? { [route.id]: { serverData: loaderData } }
1438
1862
  : undefined;
1439
1863
 
1864
+ // #186: layout chain + page metadata 병합
1865
+ const builtMeta = await buildSSRMetadata(route, params, url, registry);
1866
+
1440
1867
  // Streaming SSR 모드 결정
1441
1868
  const useStreaming = route.streaming !== undefined
1442
1869
  ? route.streaming
@@ -1444,7 +1871,8 @@ async function renderPageSSR(
1444
1871
 
1445
1872
  if (useStreaming) {
1446
1873
  const streamingResponse = await renderStreamingResponse(app, {
1447
- title: `${route.id} - Mandu`,
1874
+ title: builtMeta.title,
1875
+ headTags: builtMeta.headTags,
1448
1876
  isDev: settings.isDev,
1449
1877
  hmrPort: settings.hmrPort,
1450
1878
  routeId: route.id,
@@ -1477,7 +1905,8 @@ async function renderPageSSR(
1477
1905
  // renderToHTML에서 중복 래핑하지 않도록 hydration을 전달하되 strategy를 "none"으로 설정
1478
1906
  // 단, hydration 스크립트(importmap, runtime 등)는 여전히 필요하므로 bundleManifest는 유지
1479
1907
  const ssrResponse = renderSSR(app, {
1480
- title: `${route.id} - Mandu`,
1908
+ title: builtMeta.title,
1909
+ headTags: builtMeta.headTags,
1481
1910
  isDev: settings.isDev,
1482
1911
  hmrPort: settings.hmrPort,
1483
1912
  routeId: route.id,
@@ -1499,10 +1928,18 @@ async function renderPageSSR(
1499
1928
  const errorMod = await import(path.join(settings.rootDir, route.errorModule));
1500
1929
  const ErrorComponent = errorMod.default as React.ComponentType<ErrorFallbackProps>;
1501
1930
  if (ErrorComponent) {
1931
+ // Phase 6.3: redact stack in prod, keep full fidelity in dev.
1932
+ // Full error is always logged below; only the client-visible
1933
+ // `error` prop is trimmed.
1934
+ const { error: boundaryError, digest } = redactErrorForBoundary(
1935
+ renderError,
1936
+ settings.isDev,
1937
+ );
1502
1938
  const errorElement = React.createElement(ErrorComponent, {
1503
- error: renderError,
1939
+ error: boundaryError,
1504
1940
  errorInfo: undefined,
1505
1941
  resetError: () => {}, // SSR에서는 noop — 클라이언트 hydration 시 실제 동작
1942
+ digest,
1506
1943
  });
1507
1944
 
1508
1945
  // 레이아웃은 유지하면서 에러 컴포넌트만 교체
@@ -1512,7 +1949,8 @@ async function renderPageSSR(
1512
1949
  }
1513
1950
 
1514
1951
  const errorHtml = renderSSR(errorApp, {
1515
- title: `Error - ${route.id}`,
1952
+ // 에러 상태에서는 resolveMetadata 결과를 신뢰할 수 없을 수 있으므로 리터럴 사용
1953
+ title: "Mandu App — Error",
1516
1954
  isDev: settings.isDev,
1517
1955
  cssPath: settings.cssPath,
1518
1956
  });
@@ -1533,6 +1971,102 @@ async function renderPageSSR(
1533
1971
  }
1534
1972
  }
1535
1973
 
1974
+ // ---------- Not Found Renderer (Phase 6.3) ----------
1975
+
1976
+ /**
1977
+ * Read the plain-text message body out of a notFound() Response without
1978
+ * consuming it. Returns a short default if the body is empty or reading
1979
+ * fails (e.g. body already read — shouldn't happen but defensive).
1980
+ */
1981
+ async function readNotFoundMessage(response: Response): Promise<string> {
1982
+ try {
1983
+ const clone = response.clone();
1984
+ const body = await clone.text();
1985
+ return body.length > 0 ? body : "Not Found";
1986
+ } catch {
1987
+ return "Not Found";
1988
+ }
1989
+ }
1990
+
1991
+ /**
1992
+ * Render `app/not-found.tsx` (if registered) as a status-404 page, or
1993
+ * fall back to the framework's JSON 404 error. Cookies set by the page
1994
+ * loader and any layout loaders are preserved on the final Response.
1995
+ *
1996
+ * Infinite-loop guard: if rendering the not-found component itself
1997
+ * throws (bad user code), we don't recurse back into this function —
1998
+ * we emit the built-in 404 instead. That way a broken not-found.tsx
1999
+ * never causes a stack overflow or tarpit loop.
2000
+ */
2001
+ async function renderNotFoundPage(
2002
+ req: Request,
2003
+ route: { id: string; pattern: string; layoutChain?: string[]; hydration?: HydrationConfig; streaming?: boolean },
2004
+ params: Record<string, string>,
2005
+ registry: ServerRegistry,
2006
+ pageCookies: CookieManager | undefined,
2007
+ layoutCookies: CookieManager | undefined,
2008
+ layoutData: Map<string, unknown> | undefined,
2009
+ notFoundResponse: Response,
2010
+ ): Promise<Response> {
2011
+ const settings = registry.settings;
2012
+ const mergedCookies = mergeCookieManagers(req, layoutCookies, pageCookies);
2013
+ const message = await readNotFoundMessage(notFoundResponse);
2014
+
2015
+ const handler = registry.notFoundHandler;
2016
+ if (!handler) {
2017
+ // No app/not-found.tsx registered — return the existing 404 path.
2018
+ return errorToResponse(createNotFoundResponse(new URL(req.url).pathname), settings.isDev);
2019
+ }
2020
+
2021
+ try {
2022
+ const registration = await handler();
2023
+ const NotFoundComponent = registration.component;
2024
+
2025
+ // Let the not-found page's own loader contribute data (e.g. nav links,
2026
+ // locale strings). The page loader has already run — this is the 2nd
2027
+ // loader invocation, scoped to the 404 surface only.
2028
+ let loaderData: unknown = { message };
2029
+ if (registration.filling?.hasLoader()) {
2030
+ const ctx = new ManduContext(req, params);
2031
+ try {
2032
+ const returned = await registration.filling.executeLoader(ctx);
2033
+ loaderData = returned !== undefined ? returned : { message };
2034
+ } catch (loaderError) {
2035
+ console.warn(`[Mandu] not-found.tsx loader threw, falling back to { message }:`, loaderError);
2036
+ loaderData = { message };
2037
+ }
2038
+ }
2039
+
2040
+ // Render the component. Reuse renderSSR directly (no cache, no
2041
+ // streaming, no island bundling — a 404 page is plain).
2042
+ let app: React.ReactElement = React.createElement(NotFoundComponent, {
2043
+ params,
2044
+ loaderData,
2045
+ });
2046
+ if (route.layoutChain && route.layoutChain.length > 0) {
2047
+ app = await wrapWithLayouts(app, route.layoutChain, registry, params, layoutData);
2048
+ }
2049
+
2050
+ const html = renderSSR(app, {
2051
+ title: "Not Found",
2052
+ isDev: settings.isDev,
2053
+ cssPath: settings.cssPath,
2054
+ });
2055
+
2056
+ // renderSSR returns a 200; override to 404 without losing headers.
2057
+ const headers = new Headers(html.headers);
2058
+ const body = await html.text();
2059
+ let response = new Response(body, { status: 404, headers });
2060
+ if (mergedCookies) {
2061
+ response = mergedCookies.applyToResponse(response);
2062
+ }
2063
+ return response;
2064
+ } catch (renderError) {
2065
+ console.error(`[Mandu] app/not-found.tsx render failed; falling back to built-in 404:`, renderError);
2066
+ return errorToResponse(createNotFoundResponse(new URL(req.url).pathname), settings.isDev);
2067
+ }
2068
+ }
2069
+
1536
2070
  // ---------- Page Route Handler ----------
1537
2071
 
1538
2072
  /** SWR 백그라운드 재생성 중복 방지 */
@@ -1569,6 +2103,12 @@ async function handlePageRoute(
1569
2103
  // Shell HIT: load only the dynamic data (cheap), skip full SSR render
1570
2104
  const loadResult = await loadPageData(req, route, params, registry);
1571
2105
  if (!loadResult.ok) return loadResult;
2106
+ // DX-3: loader-level redirect wins over the PPR shell — never emit
2107
+ // cached HTML to a user the loader wants to redirect away. Cookies
2108
+ // were already merged into the redirect Response inside loadPageData.
2109
+ if (loadResult.value.redirect) {
2110
+ return ok(loadResult.value.redirect);
2111
+ }
1572
2112
  const { loaderData, cookies } = loadResult.value;
1573
2113
  const pprResponse = createPPRResponse(cachedShell.html, route.id, loaderData);
1574
2114
  return ok(cookies ? cookies.applyToResponse(pprResponse) : pprResponse);
@@ -1606,7 +2146,7 @@ async function handlePageRoute(
1606
2146
  }
1607
2147
 
1608
2148
  // 1. 페이지 + 레이아웃 데이터 병렬 로딩
1609
- const [loadResult, layoutData] = await Promise.all([
2149
+ const [loadResult, layoutLoad] = await Promise.all([
1610
2150
  loadPageData(req, route, params, registry),
1611
2151
  loadLayoutData(req, route.layoutChain, params, registry),
1612
2152
  ]);
@@ -1614,11 +2154,66 @@ async function handlePageRoute(
1614
2154
  return loadResult;
1615
2155
  }
1616
2156
 
1617
- const { loaderData, cookies } = loadResult.value;
2157
+ const { loaderData, cookies: pageCookies } = loadResult.value;
2158
+ const { data: layoutData, cookies: layoutCookies } = layoutLoad;
2159
+
2160
+ // DX-3: page loader redirect short-circuit. Applied BEFORE cookie merging
2161
+ // and BEFORE the SPA _data branch so the redirect is authoritative — a
2162
+ // client-side fetch('/page?_data=1') from the router must still see the
2163
+ // redirect so it follows the server's decision instead of rendering the
2164
+ // page shell. Layout loaders never redirect in this release (nested:
2165
+ // test #7) — only the page's decision wins.
2166
+ //
2167
+ // Cookies already merged in loadPageData include page-level cookies set
2168
+ // before the redirect call. Layout-level cookies also merge in so a
2169
+ // layout that started a session survives the page's redirect.
2170
+ if (loadResult.value.redirect) {
2171
+ let redirectResponse = loadResult.value.redirect;
2172
+ if (layoutCookies) {
2173
+ redirectResponse = layoutCookies.applyToResponse(redirectResponse);
2174
+ }
2175
+ return ok(redirectResponse);
2176
+ }
2177
+
2178
+ // Phase 6.3: notFound short-circuit. Same ordering rationale as redirect —
2179
+ // never serve cached HTML to a user whose loader emitted notFound(), never
2180
+ // leak loader JSON via the ?_data=1 path. If `app/not-found.tsx` is
2181
+ // registered we render it here (status 404) with cookies preserved;
2182
+ // otherwise we fall back to the built-in 404.
2183
+ if (loadResult.value.notFound) {
2184
+ const nfResponse = await renderNotFoundPage(
2185
+ req,
2186
+ route,
2187
+ params,
2188
+ registry,
2189
+ pageCookies,
2190
+ layoutCookies,
2191
+ layoutData,
2192
+ loadResult.value.notFound,
2193
+ );
2194
+ return ok(nfResponse);
2195
+ }
2196
+
2197
+ // DX-2: layout slot 의 쿠키를 response 로 전파.
2198
+ // 병합 순서: layout 먼저, page 가 뒤 — 같은 이름이면 page 가 이긴다 (middleware→handler 관례).
2199
+ const mergedCookies = mergeCookieManagers(req, layoutCookies, pageCookies);
1618
2200
 
1619
2201
  // 2. Client-side Routing: 데이터만 반환 (JSON)
1620
2202
  // 참고: layoutData는 SSR 시에만 사용 — SPA 네비게이션은 전체 페이지 SSR을 받지 않으므로 제외
1621
2203
  if (isDataRequest) {
2204
+ // Phase 7.2 — HDR (Hot Data Revalidation) signal. When the client
2205
+ // sends `X-Mandu-HDR: 1` it is a slot-refetch in dev mode. We echo
2206
+ // the header back so observability tooling can distinguish HDR
2207
+ // refetches from normal SPA navigations. The JSON body is
2208
+ // identical — HDR reuses the existing `_data=1` contract — so
2209
+ // this header is purely advisory.
2210
+ //
2211
+ // Phase 7.3 L-04: only echo the header in dev. HDR is a dev-time
2212
+ // feature (slot file watching + client HMR script) so no legitimate
2213
+ // production client should ever send `X-Mandu-HDR: 1`. Echoing it
2214
+ // in prod is zero-value attack surface (request-triggered response
2215
+ // header reflection). Silently ignore the request header instead.
2216
+ const isHDR = settings.isDev && req.headers.get("x-mandu-hdr") === "1";
1622
2217
  const jsonResponse = Response.json({
1623
2218
  routeId: route.id,
1624
2219
  pattern: route.pattern,
@@ -1626,11 +2221,22 @@ async function handlePageRoute(
1626
2221
  loaderData: loaderData ?? null,
1627
2222
  timestamp: Date.now(),
1628
2223
  });
1629
- return ok(cookies ? cookies.applyToResponse(jsonResponse) : jsonResponse);
2224
+ if (isHDR) {
2225
+ // Set headers on the response. Response.json() returns an
2226
+ // immutable Response; we wrap with a new Headers object.
2227
+ const headers = new Headers(jsonResponse.headers);
2228
+ headers.set("X-Mandu-HDR", "1");
2229
+ const taggedResponse = new Response(jsonResponse.body, {
2230
+ status: jsonResponse.status,
2231
+ headers,
2232
+ });
2233
+ return ok(mergedCookies ? mergedCookies.applyToResponse(taggedResponse) : taggedResponse);
2234
+ }
2235
+ return ok(mergedCookies ? mergedCookies.applyToResponse(jsonResponse) : jsonResponse);
1630
2236
  }
1631
2237
 
1632
2238
  // 3. SSR 렌더링 (layoutData 전달)
1633
- const ssrResult = await renderPageSSR(route, params, loaderData, req.url, registry, cookies, layoutData);
2239
+ const ssrResult = await renderPageSSR(route, params, loaderData, req.url, registry, mergedCookies, layoutData);
1634
2240
 
1635
2241
  // 4a. PPR: cache only the shell (HTML structure minus loader data), not the full page
1636
2242
  if (cache && ssrResult.ok && renderMode === "ppr") {
@@ -1678,13 +2284,19 @@ async function regenerateCache(
1678
2284
  cache: CacheStore,
1679
2285
  cacheKey: string
1680
2286
  ): Promise<void> {
1681
- const [loadResult, layoutData] = await Promise.all([
2287
+ const [loadResult, layoutLoad] = await Promise.all([
1682
2288
  loadPageData(req, route, params, registry),
1683
2289
  loadLayoutData(req, route.layoutChain, params, registry),
1684
2290
  ]);
1685
2291
  if (!loadResult.ok) return;
2292
+ // DX-3: never cache a redirect under a page-html cache key. A redirect
2293
+ // is per-request (auth state dependent) — caching it would poison every
2294
+ // subsequent visitor. Bail out and let the next request re-evaluate.
2295
+ if (loadResult.value.redirect) return;
1686
2296
 
1687
2297
  const { loaderData } = loadResult.value;
2298
+ const { data: layoutData } = layoutLoad;
2299
+ // 캐시 재생성 경로는 per-request 쿠키를 캐시해선 안 됨 → cookies undefined 로 유지.
1688
2300
  const ssrResult = await renderPageSSR(route, params, loaderData, req.url, registry, undefined, layoutData);
1689
2301
  if (!ssrResult.ok) return;
1690
2302
 
@@ -1752,6 +2364,15 @@ async function ensurePageRouteMetadata(
1752
2364
  registry.renderModes.set(routeId, registration.filling.getRenderMode());
1753
2365
  }
1754
2366
 
2367
+ // #186: pageHandlers 경로에서도 metadata / generateMetadata 캐싱
2368
+ // (pageLoaders 경로는 loadPageData에서 이미 처리됨)
2369
+ if (registration.metadata && typeof registration.metadata === "object") {
2370
+ registry.pageMetadata.set(routeId, registration.metadata);
2371
+ }
2372
+ if (typeof registration.generateMetadata === "function") {
2373
+ registry.pageGenerateMetadata.set(routeId, registration.generateMetadata);
2374
+ }
2375
+
1755
2376
  return registration;
1756
2377
  }
1757
2378
 
@@ -1825,6 +2446,41 @@ async function handleRequestInternal(
1825
2446
  // 3. 라우트 매칭
1826
2447
  const match = router.match(pathname);
1827
2448
  if (!match) {
2449
+ // Phase 6.3: unmatched URL → try `app/not-found.tsx` first. We
2450
+ // can't reuse renderNotFoundPage directly (no route/params/layoutChain
2451
+ // context here), so inline a minimal render path. The component and
2452
+ // its loader are invoked with empty params + a pseudo route id so
2453
+ // layouts that rely on routeId don't crash. If rendering fails OR no
2454
+ // handler is registered, fall through to the built-in error path.
2455
+ if (registry.notFoundHandler) {
2456
+ try {
2457
+ const registration = await registry.notFoundHandler();
2458
+ let loaderData: unknown = { message: "Not Found" };
2459
+ if (registration.filling?.hasLoader()) {
2460
+ const ctx = new ManduContext(req, {});
2461
+ try {
2462
+ const returned = await registration.filling.executeLoader(ctx);
2463
+ loaderData = returned !== undefined ? returned : { message: "Not Found" };
2464
+ } catch (loaderError) {
2465
+ console.warn(`[Mandu] not-found.tsx loader threw (unmatched URL):`, loaderError);
2466
+ }
2467
+ }
2468
+ const app = React.createElement(registration.component, {
2469
+ params: {},
2470
+ loaderData,
2471
+ });
2472
+ const html = renderSSR(app, {
2473
+ title: "Not Found",
2474
+ isDev: settings.isDev,
2475
+ cssPath: settings.cssPath,
2476
+ });
2477
+ const headers = new Headers(html.headers);
2478
+ const body = await html.text();
2479
+ return ok(new Response(body, { status: 404, headers }));
2480
+ } catch (renderError) {
2481
+ console.error(`[Mandu] app/not-found.tsx render failed for unmatched URL; falling back to built-in 404:`, renderError);
2482
+ }
2483
+ }
1828
2484
  return err(createNotFoundResponse(pathname));
1829
2485
  }
1830
2486
 
@@ -2045,7 +2701,7 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
2045
2701
  const match = router.match(url.pathname);
2046
2702
  if (match && registry.wsHandlers.has(match.route.id)) {
2047
2703
  const upgraded = (bunServer as any).upgrade(req, {
2048
- data: { routeId: match.route.id, params: match.params, id: crypto.randomUUID() },
2704
+ data: { routeId: match.route.id, params: match.params, id: newId() },
2049
2705
  });
2050
2706
  return upgraded ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
2051
2707
  }