@mandujs/core 0.21.0 → 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 (122) hide show
  1. package/package.json +94 -69
  2. package/src/auth/__tests__/login.test.ts +419 -0
  3. package/src/auth/__tests__/password.test.ts +122 -0
  4. package/src/auth/__tests__/reset.test.ts +296 -0
  5. package/src/auth/__tests__/tokens.test.ts +274 -0
  6. package/src/auth/__tests__/verification.test.ts +274 -0
  7. package/src/auth/index.ts +76 -0
  8. package/src/auth/login.ts +225 -0
  9. package/src/auth/password.ts +120 -0
  10. package/src/auth/reset.ts +243 -0
  11. package/src/auth/tokens.ts +612 -0
  12. package/src/auth/verification.ts +253 -0
  13. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
  14. package/src/bundler/__tests__/cold-start.test.ts +504 -0
  15. package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
  16. package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
  17. package/src/bundler/__tests__/extended-watch.test.ts +710 -0
  18. package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
  19. package/src/bundler/__tests__/hdr.test.ts +353 -0
  20. package/src/bundler/__tests__/hmr-client.test.ts +532 -0
  21. package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
  22. package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
  23. package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
  24. package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
  25. package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
  26. package/src/bundler/build.test.ts +8 -1
  27. package/src/bundler/build.ts +310 -18
  28. package/src/bundler/css.ts +326 -323
  29. package/src/bundler/dev.ts +1611 -59
  30. package/src/bundler/fast-refresh-plugin.ts +307 -0
  31. package/src/bundler/hmr-types.ts +252 -0
  32. package/src/bundler/manifest-schema.ts +301 -0
  33. package/src/bundler/safe-build.test.ts +128 -0
  34. package/src/bundler/safe-build.ts +77 -0
  35. package/src/bundler/scenario-matrix.ts +229 -0
  36. package/src/bundler/types.ts +11 -0
  37. package/src/bundler/vendor-cache-types.ts +130 -0
  38. package/src/bundler/vendor-cache.ts +526 -0
  39. package/src/client/router.ts +214 -56
  40. package/src/db/__tests__/db.test.ts +485 -0
  41. package/src/db/index.ts +513 -0
  42. package/src/db/migrations/__tests__/runner.test.ts +661 -0
  43. package/src/db/migrations/history-table.ts +345 -0
  44. package/src/db/migrations/lock.ts +269 -0
  45. package/src/db/migrations/runner.ts +633 -0
  46. package/src/desktop/__tests__/smoke.test.ts +100 -0
  47. package/src/desktop/__tests__/window.test.ts +172 -0
  48. package/src/desktop/__tests__/worker.test.ts +266 -0
  49. package/src/desktop/index.ts +43 -0
  50. package/src/desktop/types.ts +158 -0
  51. package/src/desktop/window.ts +492 -0
  52. package/src/desktop/worker.ts +180 -0
  53. package/src/email/__tests__/email.test.ts +355 -0
  54. package/src/email/index.ts +282 -0
  55. package/src/email/resend.ts +163 -0
  56. package/src/email/smtp.ts +64 -0
  57. package/src/filling/__tests__/session-sqlite.test.ts +454 -0
  58. package/src/filling/context.ts +72 -78
  59. package/src/filling/cookie-codec.ts +299 -0
  60. package/src/filling/deps.ts +25 -1
  61. package/src/filling/filling.ts +28 -3
  62. package/src/filling/session-sqlite.ts +617 -0
  63. package/src/filling/session.ts +265 -216
  64. package/src/guard/decision-memory.test.ts +52 -22
  65. package/src/id/__tests__/id.test.ts +120 -0
  66. package/src/id/index.ts +105 -0
  67. package/src/kitchen/index.ts +2 -2
  68. package/src/kitchen/kitchen-handler.ts +86 -0
  69. package/src/kitchen/stream/activity-sse.ts +2 -1
  70. package/src/middleware/csrf.ts +328 -0
  71. package/src/middleware/index.ts +40 -0
  72. package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
  73. package/src/middleware/oauth/index.ts +505 -0
  74. package/src/middleware/oauth/providers.ts +115 -0
  75. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
  76. package/src/middleware/rate-limit/index.ts +522 -0
  77. package/src/middleware/rate-limit/sqlite-store.ts +382 -0
  78. package/src/middleware/secure/__tests__/secure.test.ts +360 -0
  79. package/src/middleware/secure/csp.ts +193 -0
  80. package/src/middleware/secure/index.ts +417 -0
  81. package/src/middleware/session.ts +174 -0
  82. package/src/observability/event-bus.ts +81 -79
  83. package/src/paths.ts +37 -0
  84. package/src/perf/hmr-markers.ts +215 -0
  85. package/src/perf/index.ts +104 -0
  86. package/src/resource/__tests__/generator.test.ts +603 -2
  87. package/src/resource/ddl/__tests__/diff.test.ts +639 -0
  88. package/src/resource/ddl/__tests__/emit.test.ts +799 -0
  89. package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
  90. package/src/resource/ddl/diff.ts +392 -0
  91. package/src/resource/ddl/emit.ts +548 -0
  92. package/src/resource/ddl/persistence-types.ts +218 -0
  93. package/src/resource/ddl/snapshot.ts +447 -0
  94. package/src/resource/ddl/type-map.ts +223 -0
  95. package/src/resource/ddl/types.ts +232 -0
  96. package/src/resource/generator-repo.ts +610 -0
  97. package/src/resource/generator-schema.ts +476 -0
  98. package/src/resource/generator.ts +117 -1
  99. package/src/resource/index.ts +17 -1
  100. package/src/resource/schema.ts +30 -0
  101. package/src/router/fs-scanner.ts +3 -0
  102. package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
  103. package/src/runtime/__tests__/hdr-client.test.ts +223 -0
  104. package/src/runtime/__tests__/http-errors.test.ts +117 -0
  105. package/src/runtime/__tests__/not-found.test.ts +152 -0
  106. package/src/runtime/boundary.tsx +21 -1
  107. package/src/runtime/fast-refresh-runtime.ts +322 -0
  108. package/src/runtime/fast-refresh-types.ts +128 -0
  109. package/src/runtime/hmr-client.ts +409 -0
  110. package/src/runtime/http-errors.ts +113 -0
  111. package/src/runtime/index.ts +6 -0
  112. package/src/runtime/logger.ts +678 -677
  113. package/src/runtime/not-found.ts +93 -0
  114. package/src/runtime/redirect.ts +133 -0
  115. package/src/runtime/server.ts +518 -20
  116. package/src/runtime/ssr.ts +340 -10
  117. package/src/runtime/streaming-ssr.ts +222 -19
  118. package/src/scheduler/__tests__/scheduler.test.ts +514 -0
  119. package/src/scheduler/index.ts +343 -0
  120. package/src/storage/s3/__tests__/s3.test.ts +479 -0
  121. package/src/storage/s3/index.ts +412 -0
  122. package/src/testing/index.ts +58 -0
@@ -2,7 +2,7 @@ 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
8
  import {
@@ -58,6 +58,9 @@ import { createFetchHandler } from "./handler";
58
58
  import { wrapBunWebSocket, type WSUpgradeData } from "../filling/ws";
59
59
  import { handleImageRequest } from "./image-handler";
60
60
  import { extractShellHtml, createPPRResponse } from "./ppr";
61
+ import { isRedirectResponse } from "./redirect";
62
+ import { isNotFoundResponse } from "./not-found";
63
+ import { newId } from "../id";
61
64
 
62
65
  export interface RateLimitOptions {
63
66
  windowMs?: number;
@@ -468,6 +471,13 @@ export class ServerRegistry {
468
471
  readonly errorLoaders: Map<string, ErrorLoader> = new Map();
469
472
  createAppFn: CreateAppFn | null = null;
470
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;
471
481
  /** Kitchen dev dashboard handler (dev mode only) */
472
482
  kitchen: KitchenHandler | null = null;
473
483
  /** 라우트별 캐시 옵션 (filling.loader()의 cacheOptions에서 등록) */
@@ -535,6 +545,16 @@ export class ServerRegistry {
535
545
  this.errorLoaders.set(modulePath, loader);
536
546
  }
537
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
+
538
558
  /**
539
559
  * 제네릭 컴포넌트 로더 (DRY)
540
560
  * 캐시 → 로더 → 동적 import 순서로 시도
@@ -658,6 +678,7 @@ export class ServerRegistry {
658
678
  this.layoutGenerateMetadata.clear();
659
679
  this.createAppFn = null;
660
680
  this.rateLimiter = null;
681
+ this.notFoundHandler = null;
661
682
  }
662
683
  }
663
684
 
@@ -728,6 +749,15 @@ export function registerErrorLoader(modulePath: string, loader: ErrorLoader): vo
728
749
  defaultRegistry.registerErrorLoader(modulePath, loader);
729
750
  }
730
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
+
731
761
  export function registerWSHandler(routeId: string, handlers: import("../filling/ws").WSHandlers): void {
732
762
  defaultRegistry.wsHandlers.set(routeId, handlers);
733
763
  }
@@ -1165,7 +1195,7 @@ async function handleInternalCacheControlRequest(
1165
1195
  async function handleRequest(req: Request, router: Router, registry: ServerRegistry): Promise<Response> {
1166
1196
  const requestStart = Date.now();
1167
1197
  // Phase 1-4: Correlation ID — 한 요청에서 발생하는 모든 이벤트를 추적
1168
- const correlationId = req.headers.get("x-mandu-request-id") ?? crypto.randomUUID();
1198
+ const correlationId = req.headers.get("x-mandu-request-id") ?? newId();
1169
1199
  const result = await handleRequestInternal(req, router, registry);
1170
1200
 
1171
1201
  if (!result.ok) {
@@ -1257,11 +1287,99 @@ async function handleApiRoute(
1257
1287
 
1258
1288
  // ---------- Page Data Loader ----------
1259
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
+
1260
1358
  interface PageLoadResult {
1261
1359
  loaderData: unknown;
1262
1360
  cookies?: CookieManager;
1263
1361
  /** Layout별 loader 데이터 (모듈 경로 → 데이터) */
1264
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;
1265
1383
  }
1266
1384
 
1267
1385
  /**
@@ -1285,7 +1403,40 @@ async function loadPageData(
1285
1403
  // Filling의 loader 실행
1286
1404
  if (registration.filling?.hasLoader()) {
1287
1405
  const ctx = new ManduContext(req, params);
1288
- 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;
1289
1440
  if (ctx.cookies.hasPendingCookies()) {
1290
1441
  cookies = ctx.cookies;
1291
1442
  }
@@ -1328,14 +1479,50 @@ async function loadPageData(
1328
1479
  }
1329
1480
 
1330
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.
1331
1488
  let cookies: CookieManager | undefined;
1332
- 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;
1333
1495
  if (filling?.getCacheOptions?.()) {
1334
1496
  registry.cacheOptions.set(route.id, filling.getCacheOptions()!);
1335
1497
  }
1336
1498
  if (filling?.hasLoader?.()) {
1337
1499
  const ctx = new ManduContext(req, params);
1338
- 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;
1339
1526
  if (ctx.cookies.hasPendingCookies()) {
1340
1527
  cookies = ctx.cookies;
1341
1528
  }
@@ -1356,18 +1543,71 @@ async function loadPageData(
1356
1543
  return ok({ loaderData });
1357
1544
  }
1358
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
+
1359
1592
  /**
1360
1593
  * Layout chain의 모든 loader를 병렬 실행
1361
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 에 가까운 쪽)가 이김
1362
1600
  */
1363
1601
  async function loadLayoutData(
1364
1602
  req: Request,
1365
1603
  layoutChain: string[] | undefined,
1366
1604
  params: Record<string, string>,
1367
1605
  registry: ServerRegistry
1368
- ): Promise<Map<string, unknown>> {
1606
+ ): Promise<LayoutLoadResult> {
1369
1607
  const layoutData = new Map<string, unknown>();
1370
- if (!layoutChain || layoutChain.length === 0) return layoutData;
1608
+ if (!layoutChain || layoutChain.length === 0) {
1609
+ return { data: layoutData, cookies: undefined };
1610
+ }
1371
1611
 
1372
1612
  // layout.slot.ts 파일 검색: layout 모듈 경로에서 .slot.ts 파일 경로 유도
1373
1613
  // 예: app/layout.tsx → spec/slots/layout.slot.ts (auto-link 규칙)
@@ -1408,7 +1648,7 @@ async function loadLayoutData(
1408
1648
  }
1409
1649
  }
1410
1650
 
1411
- if (loaderEntries.length === 0) return layoutData;
1651
+ if (loaderEntries.length === 0) return { data: layoutData, cookies: undefined };
1412
1652
 
1413
1653
  const results = await Promise.all(
1414
1654
  loaderEntries.map(async ({ modulePath, slotPath }) => {
@@ -1421,23 +1661,64 @@ async function loadLayoutData(
1421
1661
  if (filling.hasLoader()) {
1422
1662
  const ctx = new ManduContext(req, params);
1423
1663
  const data = await filling.executeLoader(ctx);
1424
- 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 };
1425
1680
  }
1426
1681
  }
1427
1682
  } catch (error) {
1428
- 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
+ }
1429
1693
  }
1430
- return { modulePath, data: undefined };
1694
+ return { modulePath, data: undefined, cookies: undefined };
1431
1695
  })
1432
1696
  );
1433
1697
 
1434
- 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) {
1435
1708
  if (data !== undefined) {
1436
1709
  layoutData.set(modulePath, data);
1437
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
+ }
1438
1719
  }
1439
1720
 
1440
- return layoutData;
1721
+ return { data: layoutData, cookies: mergedCookies };
1441
1722
  }
1442
1723
 
1443
1724
  // ---------- SSR Renderer ----------
@@ -1647,10 +1928,18 @@ async function renderPageSSR(
1647
1928
  const errorMod = await import(path.join(settings.rootDir, route.errorModule));
1648
1929
  const ErrorComponent = errorMod.default as React.ComponentType<ErrorFallbackProps>;
1649
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
+ );
1650
1938
  const errorElement = React.createElement(ErrorComponent, {
1651
- error: renderError,
1939
+ error: boundaryError,
1652
1940
  errorInfo: undefined,
1653
1941
  resetError: () => {}, // SSR에서는 noop — 클라이언트 hydration 시 실제 동작
1942
+ digest,
1654
1943
  });
1655
1944
 
1656
1945
  // 레이아웃은 유지하면서 에러 컴포넌트만 교체
@@ -1682,6 +1971,102 @@ async function renderPageSSR(
1682
1971
  }
1683
1972
  }
1684
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
+
1685
2070
  // ---------- Page Route Handler ----------
1686
2071
 
1687
2072
  /** SWR 백그라운드 재생성 중복 방지 */
@@ -1718,6 +2103,12 @@ async function handlePageRoute(
1718
2103
  // Shell HIT: load only the dynamic data (cheap), skip full SSR render
1719
2104
  const loadResult = await loadPageData(req, route, params, registry);
1720
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
+ }
1721
2112
  const { loaderData, cookies } = loadResult.value;
1722
2113
  const pprResponse = createPPRResponse(cachedShell.html, route.id, loaderData);
1723
2114
  return ok(cookies ? cookies.applyToResponse(pprResponse) : pprResponse);
@@ -1755,7 +2146,7 @@ async function handlePageRoute(
1755
2146
  }
1756
2147
 
1757
2148
  // 1. 페이지 + 레이아웃 데이터 병렬 로딩
1758
- const [loadResult, layoutData] = await Promise.all([
2149
+ const [loadResult, layoutLoad] = await Promise.all([
1759
2150
  loadPageData(req, route, params, registry),
1760
2151
  loadLayoutData(req, route.layoutChain, params, registry),
1761
2152
  ]);
@@ -1763,11 +2154,66 @@ async function handlePageRoute(
1763
2154
  return loadResult;
1764
2155
  }
1765
2156
 
1766
- 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);
1767
2200
 
1768
2201
  // 2. Client-side Routing: 데이터만 반환 (JSON)
1769
2202
  // 참고: layoutData는 SSR 시에만 사용 — SPA 네비게이션은 전체 페이지 SSR을 받지 않으므로 제외
1770
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";
1771
2217
  const jsonResponse = Response.json({
1772
2218
  routeId: route.id,
1773
2219
  pattern: route.pattern,
@@ -1775,11 +2221,22 @@ async function handlePageRoute(
1775
2221
  loaderData: loaderData ?? null,
1776
2222
  timestamp: Date.now(),
1777
2223
  });
1778
- 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);
1779
2236
  }
1780
2237
 
1781
2238
  // 3. SSR 렌더링 (layoutData 전달)
1782
- 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);
1783
2240
 
1784
2241
  // 4a. PPR: cache only the shell (HTML structure minus loader data), not the full page
1785
2242
  if (cache && ssrResult.ok && renderMode === "ppr") {
@@ -1827,13 +2284,19 @@ async function regenerateCache(
1827
2284
  cache: CacheStore,
1828
2285
  cacheKey: string
1829
2286
  ): Promise<void> {
1830
- const [loadResult, layoutData] = await Promise.all([
2287
+ const [loadResult, layoutLoad] = await Promise.all([
1831
2288
  loadPageData(req, route, params, registry),
1832
2289
  loadLayoutData(req, route.layoutChain, params, registry),
1833
2290
  ]);
1834
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;
1835
2296
 
1836
2297
  const { loaderData } = loadResult.value;
2298
+ const { data: layoutData } = layoutLoad;
2299
+ // 캐시 재생성 경로는 per-request 쿠키를 캐시해선 안 됨 → cookies undefined 로 유지.
1837
2300
  const ssrResult = await renderPageSSR(route, params, loaderData, req.url, registry, undefined, layoutData);
1838
2301
  if (!ssrResult.ok) return;
1839
2302
 
@@ -1983,6 +2446,41 @@ async function handleRequestInternal(
1983
2446
  // 3. 라우트 매칭
1984
2447
  const match = router.match(pathname);
1985
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
+ }
1986
2484
  return err(createNotFoundResponse(pathname));
1987
2485
  }
1988
2486
 
@@ -2203,7 +2701,7 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
2203
2701
  const match = router.match(url.pathname);
2204
2702
  if (match && registry.wsHandlers.has(match.route.id)) {
2205
2703
  const upgraded = (bunServer as any).upgrade(req, {
2206
- data: { routeId: match.route.id, params: match.params, id: crypto.randomUUID() },
2704
+ data: { routeId: match.route.id, params: match.params, id: newId() },
2207
2705
  });
2208
2706
  return upgraded ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
2209
2707
  }