@mandujs/core 0.20.10 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/package.json +28 -3
- package/src/auth/__tests__/login.test.ts +419 -0
- package/src/auth/__tests__/password.test.ts +122 -0
- package/src/auth/__tests__/reset.test.ts +296 -0
- package/src/auth/__tests__/tokens.test.ts +274 -0
- package/src/auth/__tests__/verification.test.ts +274 -0
- package/src/auth/index.ts +76 -0
- package/src/auth/login.ts +225 -0
- package/src/auth/password.ts +120 -0
- package/src/auth/reset.ts +243 -0
- package/src/auth/tokens.ts +612 -0
- package/src/auth/verification.ts +253 -0
- package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
- package/src/bundler/__tests__/cold-start.test.ts +504 -0
- package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
- package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
- package/src/bundler/__tests__/extended-watch.test.ts +710 -0
- package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
- package/src/bundler/__tests__/hdr.test.ts +353 -0
- package/src/bundler/__tests__/hmr-client.test.ts +532 -0
- package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
- package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
- package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
- package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
- package/src/bundler/build.test.ts +8 -1
- package/src/bundler/build.ts +495 -37
- package/src/bundler/css.ts +326 -323
- package/src/bundler/dev.ts +1671 -80
- package/src/bundler/fast-refresh-plugin.ts +307 -0
- package/src/bundler/hmr-types.ts +252 -0
- package/src/bundler/manifest-schema.ts +301 -0
- package/src/bundler/safe-build.test.ts +128 -0
- package/src/bundler/safe-build.ts +77 -0
- package/src/bundler/scenario-matrix.ts +229 -0
- package/src/bundler/types.ts +19 -0
- package/src/bundler/vendor-cache-types.ts +130 -0
- package/src/bundler/vendor-cache.ts +526 -0
- package/src/client/router.ts +214 -56
- package/src/config/validate.ts +1 -0
- package/src/db/__tests__/db.test.ts +485 -0
- package/src/db/index.ts +513 -0
- package/src/db/migrations/__tests__/runner.test.ts +661 -0
- package/src/db/migrations/history-table.ts +345 -0
- package/src/db/migrations/lock.ts +269 -0
- package/src/db/migrations/runner.ts +633 -0
- package/src/desktop/__tests__/smoke.test.ts +100 -0
- package/src/desktop/__tests__/window.test.ts +172 -0
- package/src/desktop/__tests__/worker.test.ts +266 -0
- package/src/desktop/index.ts +43 -0
- package/src/desktop/types.ts +158 -0
- package/src/desktop/window.ts +492 -0
- package/src/desktop/worker.ts +180 -0
- package/src/devtools/ai/mcp-connector.ts +18 -16
- package/src/devtools/client/components/mandu-character.tsx +4 -1
- package/src/devtools/client/components/panel/panel-container.tsx +20 -5
- package/src/email/__tests__/email.test.ts +355 -0
- package/src/email/index.ts +282 -0
- package/src/email/resend.ts +163 -0
- package/src/email/smtp.ts +64 -0
- package/src/filling/__tests__/session-sqlite.test.ts +454 -0
- package/src/filling/context.ts +72 -78
- package/src/filling/cookie-codec.ts +299 -0
- package/src/filling/deps.ts +25 -1
- package/src/filling/filling.ts +28 -3
- package/src/filling/session-sqlite.ts +617 -0
- package/src/filling/session.ts +265 -216
- package/src/guard/decision-memory.test.ts +52 -22
- package/src/id/__tests__/id.test.ts +120 -0
- package/src/id/index.ts +105 -0
- package/src/kitchen/index.ts +2 -2
- package/src/kitchen/kitchen-handler.ts +86 -0
- package/src/kitchen/stream/activity-sse.ts +2 -1
- package/src/middleware/csrf.ts +328 -0
- package/src/middleware/index.ts +40 -0
- package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
- package/src/middleware/oauth/index.ts +505 -0
- package/src/middleware/oauth/providers.ts +115 -0
- package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
- package/src/middleware/rate-limit/index.ts +522 -0
- package/src/middleware/rate-limit/sqlite-store.ts +382 -0
- package/src/middleware/secure/__tests__/secure.test.ts +360 -0
- package/src/middleware/secure/csp.ts +193 -0
- package/src/middleware/secure/index.ts +417 -0
- package/src/middleware/session.ts +174 -0
- package/src/observability/event-bus.ts +81 -79
- package/src/paths.ts +37 -0
- package/src/perf/hmr-markers.ts +215 -0
- package/src/perf/index.ts +104 -0
- package/src/resource/__tests__/generator.test.ts +603 -2
- package/src/resource/ddl/__tests__/diff.test.ts +639 -0
- package/src/resource/ddl/__tests__/emit.test.ts +799 -0
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
- package/src/resource/ddl/diff.ts +392 -0
- package/src/resource/ddl/emit.ts +548 -0
- package/src/resource/ddl/persistence-types.ts +218 -0
- package/src/resource/ddl/snapshot.ts +447 -0
- package/src/resource/ddl/type-map.ts +223 -0
- package/src/resource/ddl/types.ts +232 -0
- package/src/resource/generator-repo.ts +610 -0
- package/src/resource/generator-schema.ts +476 -0
- package/src/resource/generator.ts +117 -1
- package/src/resource/index.ts +17 -1
- package/src/resource/schema.ts +30 -0
- package/src/router/fs-scanner.ts +3 -0
- package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
- package/src/runtime/__tests__/hdr-client.test.ts +223 -0
- package/src/runtime/__tests__/http-errors.test.ts +117 -0
- package/src/runtime/__tests__/not-found.test.ts +152 -0
- package/src/runtime/boundary.tsx +21 -1
- package/src/runtime/fast-refresh-runtime.ts +322 -0
- package/src/runtime/fast-refresh-types.ts +128 -0
- package/src/runtime/hmr-client.ts +409 -0
- package/src/runtime/http-errors.ts +113 -0
- package/src/runtime/index.ts +6 -0
- package/src/runtime/logger.ts +678 -677
- package/src/runtime/not-found.ts +93 -0
- package/src/runtime/redirect.ts +133 -0
- package/src/runtime/server.ts +679 -23
- package/src/runtime/ssr.ts +340 -10
- package/src/runtime/streaming-ssr.ts +222 -19
- package/src/scheduler/__tests__/scheduler.test.ts +514 -0
- package/src/scheduler/index.ts +343 -0
- package/src/storage/s3/__tests__/s3.test.ts +479 -0
- package/src/storage/s3/index.ts +412 -0
- package/src/testing/index.ts +247 -189
package/src/runtime/server.ts
CHANGED
|
@@ -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,
|
|
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") ??
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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<
|
|
1606
|
+
): Promise<LayoutLoadResult> {
|
|
1312
1607
|
const layoutData = new Map<string, unknown>();
|
|
1313
|
-
if (!layoutChain || layoutChain.length === 0)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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:
|
|
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
|
}
|