@mandujs/core 0.21.0 → 0.22.1
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/package.json +101 -69
- 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 +310 -18
- package/src/bundler/css.ts +326 -323
- package/src/bundler/dev.ts +1611 -59
- 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 +11 -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/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/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 +518 -20
- 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 +58 -0
package/src/runtime/server.ts
CHANGED
|
@@ -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,
|
|
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") ??
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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<
|
|
1606
|
+
): Promise<LayoutLoadResult> {
|
|
1369
1607
|
const layoutData = new Map<string, unknown>();
|
|
1370
|
-
if (!layoutChain || layoutChain.length === 0)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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:
|
|
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
|
}
|