@mandujs/core 0.19.0 → 0.20.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.ko.md +0 -14
- package/package.json +4 -1
- package/src/brain/architecture/analyzer.ts +4 -4
- package/src/brain/doctor/analyzer.ts +18 -14
- package/src/bundler/build.test.ts +127 -0
- package/src/bundler/build.ts +385 -115
- package/src/bundler/css.ts +20 -5
- package/src/bundler/dev.ts +55 -2
- package/src/bundler/prerender.ts +195 -0
- package/src/bundler/types.ts +20 -0
- package/src/change/snapshot.ts +4 -23
- package/src/change/types.ts +2 -3
- package/src/client/Form.tsx +105 -0
- package/src/client/__tests__/use-sse.test.ts +153 -0
- package/src/client/hooks.ts +105 -6
- package/src/client/index.ts +35 -6
- package/src/client/router.ts +670 -433
- package/src/client/rpc.ts +140 -0
- package/src/client/runtime.ts +24 -21
- package/src/client/use-fetch.ts +239 -0
- package/src/client/use-head.ts +197 -0
- package/src/client/use-sse.ts +378 -0
- package/src/components/Image.tsx +162 -0
- package/src/config/mandu.ts +5 -0
- package/src/config/validate.ts +34 -0
- package/src/content/index.ts +5 -1
- package/src/devtools/client/catchers/error-catcher.ts +17 -0
- package/src/devtools/client/catchers/network-proxy.ts +390 -367
- package/src/devtools/client/components/kitchen-root.tsx +479 -467
- package/src/devtools/client/components/mandu-character.tsx +77 -53
- package/src/devtools/client/components/panel/diff-viewer.tsx +219 -0
- package/src/devtools/client/components/panel/errors-panel.tsx +2 -2
- package/src/devtools/client/components/panel/guard-panel.tsx +363 -234
- package/src/devtools/client/components/panel/index.ts +45 -32
- package/src/devtools/client/components/panel/islands-panel.tsx +30 -14
- package/src/devtools/client/components/panel/network-panel.tsx +2 -3
- package/src/devtools/client/components/panel/panel-container.tsx +273 -100
- package/src/devtools/client/components/panel/preview-panel.tsx +212 -0
- package/src/devtools/client/state-manager.ts +535 -478
- package/src/devtools/design-tokens.ts +265 -264
- package/src/devtools/init.ts +1 -1
- package/src/devtools/types.ts +321 -295
- package/src/filling/filling.ts +328 -6
- package/src/filling/index.ts +5 -1
- package/src/filling/session.ts +216 -0
- package/src/filling/ws.ts +78 -0
- package/src/generator/generate.ts +2 -2
- package/src/guard/auto-correct.ts +0 -29
- package/src/guard/check.ts +14 -31
- package/src/guard/presets/index.ts +296 -294
- package/src/guard/rules.ts +15 -19
- package/src/guard/validator.ts +834 -834
- package/src/index.ts +6 -1
- package/src/island/index.ts +373 -304
- package/src/kitchen/api/contract-api.ts +225 -0
- package/src/kitchen/api/diff-parser.ts +108 -0
- package/src/kitchen/api/file-api.ts +273 -0
- package/src/kitchen/api/guard-api.ts +83 -0
- package/src/kitchen/api/guard-decisions.ts +100 -0
- package/src/kitchen/api/routes-api.ts +50 -0
- package/src/kitchen/index.ts +21 -0
- package/src/kitchen/kitchen-handler.ts +335 -0
- package/src/kitchen/kitchen-ui.ts +1732 -0
- package/src/kitchen/stream/activity-sse.ts +145 -0
- package/src/kitchen/stream/file-tailer.ts +99 -0
- package/src/middleware/compress.ts +62 -0
- package/src/middleware/cors.ts +47 -0
- package/src/middleware/index.ts +10 -0
- package/src/middleware/jwt.ts +134 -0
- package/src/middleware/logger.ts +58 -0
- package/src/middleware/timeout.ts +55 -0
- package/src/observability/event-bus.ts +79 -0
- package/src/observability/index.ts +8 -0
- package/src/observability/logger-adapter.ts +36 -0
- package/src/paths.ts +0 -4
- package/src/plugins/hooks.ts +64 -0
- package/src/plugins/index.ts +3 -0
- package/src/plugins/types.ts +5 -0
- package/src/report/build.ts +0 -6
- package/src/resource/__tests__/backward-compat.test.ts +0 -1
- package/src/router/fs-patterns.ts +11 -1
- package/src/router/fs-routes.ts +78 -14
- package/src/router/fs-scanner.ts +2 -2
- package/src/router/fs-types.ts +2 -1
- package/src/runtime/adapter-bun.ts +62 -0
- package/src/runtime/adapter.ts +47 -0
- package/src/runtime/cache.ts +310 -0
- package/src/runtime/handler.ts +65 -0
- package/src/runtime/image-handler.ts +195 -0
- package/src/runtime/index.ts +13 -0
- package/src/runtime/middleware.ts +263 -0
- package/src/runtime/ppr.ts +74 -0
- package/src/runtime/server.ts +706 -71
- package/src/runtime/ssr.ts +70 -31
- package/src/runtime/streaming-ssr.ts +121 -78
- package/src/spec/index.ts +0 -1
- package/src/spec/schema.ts +1 -0
- package/src/testing/index.ts +189 -0
- package/src/watcher/watcher.ts +27 -1
- package/src/spec/lock.ts +0 -56
package/src/runtime/server.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Server } from "bun";
|
|
2
2
|
import type { RoutesManifest, RouteSpec, HydrationConfig } from "../spec/schema";
|
|
3
3
|
import type { BundleManifest } from "../bundler/types";
|
|
4
|
-
import type { ManduFilling } from "../filling/filling";
|
|
4
|
+
import type { ManduFilling, RenderMode } from "../filling/filling";
|
|
5
5
|
import { ManduContext, type CookieManager } from "../filling/context";
|
|
6
6
|
import { Router } from "./router";
|
|
7
7
|
import { renderSSR, renderStreamingResponse } from "./ssr";
|
|
@@ -10,6 +10,17 @@ import React, { type ReactNode } from "react";
|
|
|
10
10
|
import path from "path";
|
|
11
11
|
import fs from "fs/promises";
|
|
12
12
|
import { PORTS } from "../constants";
|
|
13
|
+
import {
|
|
14
|
+
type CacheStore,
|
|
15
|
+
type CacheStoreStats,
|
|
16
|
+
type CacheLookupResult,
|
|
17
|
+
MemoryCacheStore,
|
|
18
|
+
lookupCache,
|
|
19
|
+
createCacheEntry,
|
|
20
|
+
createCachedResponse,
|
|
21
|
+
getCacheStoreStats,
|
|
22
|
+
setGlobalCache,
|
|
23
|
+
} from "./cache";
|
|
13
24
|
import {
|
|
14
25
|
createNotFoundResponse,
|
|
15
26
|
createHandlerNotFoundResponse,
|
|
@@ -28,6 +39,16 @@ import {
|
|
|
28
39
|
isCorsRequest,
|
|
29
40
|
} from "./cors";
|
|
30
41
|
import { validateImportPath } from "./security";
|
|
42
|
+
import { KITCHEN_PREFIX, KitchenHandler, recordRequest } from "../kitchen/kitchen-handler";
|
|
43
|
+
import {
|
|
44
|
+
type MiddlewareFn,
|
|
45
|
+
type MiddlewareConfig,
|
|
46
|
+
loadMiddlewareSync,
|
|
47
|
+
} from "./middleware";
|
|
48
|
+
import { createFetchHandler } from "./handler";
|
|
49
|
+
import { wrapBunWebSocket, type WSUpgradeData } from "../filling/ws";
|
|
50
|
+
import { handleImageRequest } from "./image-handler";
|
|
51
|
+
import { extractShellHtml, createPPRResponse } from "./ppr";
|
|
31
52
|
|
|
32
53
|
export interface RateLimitOptions {
|
|
33
54
|
windowMs?: number;
|
|
@@ -301,6 +322,22 @@ export interface ServerOptions {
|
|
|
301
322
|
* - 테스트나 멀티앱 시나리오에서 createServerRegistry()로 생성한 인스턴스 전달
|
|
302
323
|
*/
|
|
303
324
|
registry?: ServerRegistry;
|
|
325
|
+
/**
|
|
326
|
+
* Guard config for Kitchen dev dashboard (dev mode only)
|
|
327
|
+
*/
|
|
328
|
+
guardConfig?: import("../guard/types").GuardConfig | null;
|
|
329
|
+
/**
|
|
330
|
+
* SSR 캐시 설정 (ISR/SWR 용)
|
|
331
|
+
* - true: 기본 메모리 캐시 (LRU 1000 엔트리)
|
|
332
|
+
* - CacheStore: 커스텀 캐시 구현체
|
|
333
|
+
* - false/undefined: 캐시 비활성화
|
|
334
|
+
*/
|
|
335
|
+
cache?: boolean | CacheStore;
|
|
336
|
+
/**
|
|
337
|
+
* Internal management token for local CLI/runtime control endpoints.
|
|
338
|
+
* When set, token-protected endpoints such as `/_mandu/cache` become available.
|
|
339
|
+
*/
|
|
340
|
+
managementToken?: string;
|
|
304
341
|
}
|
|
305
342
|
|
|
306
343
|
export interface ManduServer {
|
|
@@ -392,12 +429,17 @@ export interface ServerRegistrySettings {
|
|
|
392
429
|
* - undefined: false로 처리 (404 방지)
|
|
393
430
|
*/
|
|
394
431
|
cssPath?: string | false;
|
|
432
|
+
/** ISR/SWR 캐시 스토어 */
|
|
433
|
+
cacheStore?: CacheStore;
|
|
434
|
+
/** Internal management token for local runtime control */
|
|
435
|
+
managementToken?: string;
|
|
395
436
|
}
|
|
396
437
|
|
|
397
438
|
export class ServerRegistry {
|
|
398
439
|
readonly apiHandlers: Map<string, ApiHandler> = new Map();
|
|
399
440
|
readonly pageLoaders: Map<string, PageLoader> = new Map();
|
|
400
441
|
readonly pageHandlers: Map<string, PageHandler> = new Map();
|
|
442
|
+
readonly pageFillings: Map<string, ManduFilling<unknown>> = new Map();
|
|
401
443
|
readonly routeComponents: Map<string, RouteComponent> = new Map();
|
|
402
444
|
/** Layout 컴포넌트 캐시 (모듈 경로 → 컴포넌트) */
|
|
403
445
|
readonly layoutComponents: Map<string, LayoutComponent> = new Map();
|
|
@@ -413,6 +455,16 @@ export class ServerRegistry {
|
|
|
413
455
|
readonly errorLoaders: Map<string, ErrorLoader> = new Map();
|
|
414
456
|
createAppFn: CreateAppFn | null = null;
|
|
415
457
|
rateLimiter: MemoryRateLimiter | null = null;
|
|
458
|
+
/** Kitchen dev dashboard handler (dev mode only) */
|
|
459
|
+
kitchen: KitchenHandler | null = null;
|
|
460
|
+
/** 라우트별 캐시 옵션 (filling.loader()의 cacheOptions에서 등록) */
|
|
461
|
+
readonly cacheOptions: Map<string, { revalidate?: number; tags?: string[] }> = new Map();
|
|
462
|
+
/** 라우트별 렌더 모드 */
|
|
463
|
+
readonly renderModes: Map<string, RenderMode> = new Map();
|
|
464
|
+
/** Layout slot 파일 경로 캐시 (모듈 경로 → slot 경로 | null) */
|
|
465
|
+
readonly layoutSlotPaths: Map<string, string | null> = new Map();
|
|
466
|
+
/** WebSocket 핸들러 (라우트 ID → WSHandlers) */
|
|
467
|
+
readonly wsHandlers: Map<string, import("../filling/ws").WSHandlers> = new Map();
|
|
416
468
|
settings: ServerRegistrySettings = {
|
|
417
469
|
isDev: false,
|
|
418
470
|
rootDir: process.cwd(),
|
|
@@ -630,6 +682,10 @@ export function registerErrorLoader(modulePath: string, loader: ErrorLoader): vo
|
|
|
630
682
|
defaultRegistry.registerErrorLoader(modulePath, loader);
|
|
631
683
|
}
|
|
632
684
|
|
|
685
|
+
export function registerWSHandler(routeId: string, handlers: import("../filling/ws").WSHandlers): void {
|
|
686
|
+
defaultRegistry.wsHandlers.set(routeId, handlers);
|
|
687
|
+
}
|
|
688
|
+
|
|
633
689
|
/**
|
|
634
690
|
* 레이아웃 체인으로 컨텐츠 래핑
|
|
635
691
|
*
|
|
@@ -643,7 +699,8 @@ async function wrapWithLayouts(
|
|
|
643
699
|
content: React.ReactElement,
|
|
644
700
|
layoutChain: string[],
|
|
645
701
|
registry: ServerRegistry,
|
|
646
|
-
params: Record<string, string
|
|
702
|
+
params: Record<string, string>,
|
|
703
|
+
layoutData?: Map<string, unknown>
|
|
647
704
|
): Promise<React.ReactElement> {
|
|
648
705
|
if (!layoutChain || layoutChain.length === 0) {
|
|
649
706
|
return content;
|
|
@@ -659,7 +716,16 @@ async function wrapWithLayouts(
|
|
|
659
716
|
for (let i = layouts.length - 1; i >= 0; i--) {
|
|
660
717
|
const Layout = layouts[i];
|
|
661
718
|
if (Layout) {
|
|
662
|
-
|
|
719
|
+
// layout별 loader 데이터가 있으면 props로 전달
|
|
720
|
+
const data = layoutData?.get(layoutChain[i]);
|
|
721
|
+
const baseProps = { params, children: wrapped };
|
|
722
|
+
if (data && typeof data === "object") {
|
|
723
|
+
// data에서 children/params 키 제거 → 구조적 props 보호
|
|
724
|
+
const { children: _, params: __, ...safeData } = data as Record<string, unknown>;
|
|
725
|
+
wrapped = React.createElement(Layout as React.ComponentType<Record<string, unknown>>, { ...safeData, ...baseProps });
|
|
726
|
+
} else {
|
|
727
|
+
wrapped = React.createElement(Layout, baseProps);
|
|
728
|
+
}
|
|
663
729
|
}
|
|
664
730
|
}
|
|
665
731
|
|
|
@@ -687,6 +753,24 @@ function createDefaultAppFactory(registry: ServerRegistry) {
|
|
|
687
753
|
|
|
688
754
|
// ========== Static File Serving ==========
|
|
689
755
|
|
|
756
|
+
interface StaticFileResult {
|
|
757
|
+
handled: boolean;
|
|
758
|
+
response?: Response;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const INTERNAL_CACHE_ENDPOINT = "/_mandu/cache";
|
|
762
|
+
|
|
763
|
+
function createStaticErrorResponse(status: 400 | 403 | 404 | 500): Response {
|
|
764
|
+
const body = {
|
|
765
|
+
400: "Bad Request",
|
|
766
|
+
403: "Forbidden",
|
|
767
|
+
404: "Not Found",
|
|
768
|
+
500: "Internal Server Error",
|
|
769
|
+
}[status];
|
|
770
|
+
|
|
771
|
+
return new Response(body, { status });
|
|
772
|
+
}
|
|
773
|
+
|
|
690
774
|
/**
|
|
691
775
|
* 경로가 허용된 디렉토리 내에 있는지 검증
|
|
692
776
|
* Path traversal 공격 방지
|
|
@@ -728,17 +812,12 @@ async function isPathSafe(filePath: string, allowedDir: string): Promise<boolean
|
|
|
728
812
|
*
|
|
729
813
|
* 보안: Path traversal 공격 방지를 위해 모든 경로를 검증합니다.
|
|
730
814
|
*/
|
|
731
|
-
async function serveStaticFile(pathname: string, settings: ServerRegistrySettings): Promise<
|
|
815
|
+
async function serveStaticFile(pathname: string, settings: ServerRegistrySettings, request?: Request): Promise<StaticFileResult> {
|
|
732
816
|
let filePath: string | null = null;
|
|
733
817
|
let isBundleFile = false;
|
|
734
818
|
let allowedBaseDir: string;
|
|
735
819
|
let relativePath: string;
|
|
736
820
|
|
|
737
|
-
// Path traversal 시도 조기 차단 (정규화 전 raw 체크)
|
|
738
|
-
if (pathname.includes("..")) {
|
|
739
|
-
return null;
|
|
740
|
-
}
|
|
741
|
-
|
|
742
821
|
// 1. 클라이언트 번들 파일 (/.mandu/client/*)
|
|
743
822
|
if (pathname.startsWith("/.mandu/client/")) {
|
|
744
823
|
// pathname에서 prefix 제거 후 안전하게 조합
|
|
@@ -762,7 +841,7 @@ async function serveStaticFile(pathname: string, settings: ServerRegistrySetting
|
|
|
762
841
|
relativePath = path.basename(pathname);
|
|
763
842
|
allowedBaseDir = path.join(settings.rootDir, settings.publicDir);
|
|
764
843
|
} else {
|
|
765
|
-
return
|
|
844
|
+
return { handled: false }; // 정적 파일이 아님
|
|
766
845
|
}
|
|
767
846
|
|
|
768
847
|
// URL 디코딩 (실패 시 차단)
|
|
@@ -770,30 +849,29 @@ async function serveStaticFile(pathname: string, settings: ServerRegistrySetting
|
|
|
770
849
|
try {
|
|
771
850
|
decodedPath = decodeURIComponent(relativePath);
|
|
772
851
|
} catch {
|
|
773
|
-
return
|
|
852
|
+
return { handled: true, response: createStaticErrorResponse(400) };
|
|
774
853
|
}
|
|
775
854
|
|
|
776
855
|
// 정규화 + Null byte 방지
|
|
777
856
|
const normalizedPath = path.posix.normalize(decodedPath);
|
|
778
857
|
if (normalizedPath.includes("\0")) {
|
|
779
858
|
console.warn(`[Mandu Security] Null byte attack detected: ${pathname}`);
|
|
780
|
-
return
|
|
859
|
+
return { handled: true, response: createStaticErrorResponse(400) };
|
|
781
860
|
}
|
|
782
861
|
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
// 상대 경로 탈출 차단
|
|
787
|
-
if (safeRelativePath.startsWith("..")) {
|
|
788
|
-
return null;
|
|
862
|
+
const normalizedSegments = normalizedPath.split("/");
|
|
863
|
+
if (normalizedSegments.some((segment) => segment === "..")) {
|
|
864
|
+
return { handled: true, response: createStaticErrorResponse(403) };
|
|
789
865
|
}
|
|
790
866
|
|
|
867
|
+
// 선행 슬래시 제거 → path.join이 base를 무시하지 않도록 보장
|
|
868
|
+
const safeRelativePath = normalizedPath.replace(/^\/+/, "");
|
|
791
869
|
filePath = path.join(allowedBaseDir, safeRelativePath);
|
|
792
870
|
|
|
793
871
|
// 최종 경로 검증: 허용된 디렉토리 내에 있는지 확인
|
|
794
872
|
if (!(await isPathSafe(filePath, allowedBaseDir!))) {
|
|
795
873
|
console.warn(`[Mandu Security] Path traversal attempt blocked: ${pathname}`);
|
|
796
|
-
return
|
|
874
|
+
return { handled: true, response: createStaticErrorResponse(403) };
|
|
797
875
|
}
|
|
798
876
|
|
|
799
877
|
try {
|
|
@@ -801,7 +879,7 @@ async function serveStaticFile(pathname: string, settings: ServerRegistrySetting
|
|
|
801
879
|
const exists = await file.exists();
|
|
802
880
|
|
|
803
881
|
if (!exists) {
|
|
804
|
-
return
|
|
882
|
+
return { handled: true, response: createStaticErrorResponse(404) };
|
|
805
883
|
}
|
|
806
884
|
|
|
807
885
|
const mimeType = getMimeType(filePath);
|
|
@@ -819,24 +897,152 @@ async function serveStaticFile(pathname: string, settings: ServerRegistrySetting
|
|
|
819
897
|
cacheControl = "public, max-age=86400";
|
|
820
898
|
}
|
|
821
899
|
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
900
|
+
// ETag: weak validator (파일 크기 + 최종 수정 시간)
|
|
901
|
+
const etag = `W/"${file.size.toString(36)}-${file.lastModified.toString(36)}"`;
|
|
902
|
+
|
|
903
|
+
// 304 Not Modified — 불필요한 전송 방지
|
|
904
|
+
const ifNoneMatch = request?.headers.get("If-None-Match");
|
|
905
|
+
if (ifNoneMatch === etag) {
|
|
906
|
+
return {
|
|
907
|
+
handled: true,
|
|
908
|
+
response: new Response(null, {
|
|
909
|
+
status: 304,
|
|
910
|
+
headers: { "ETag": etag, "Cache-Control": cacheControl },
|
|
911
|
+
}),
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
return {
|
|
916
|
+
handled: true,
|
|
917
|
+
response: new Response(file, {
|
|
918
|
+
headers: {
|
|
919
|
+
"Content-Type": mimeType,
|
|
920
|
+
"Cache-Control": cacheControl,
|
|
921
|
+
"ETag": etag,
|
|
922
|
+
},
|
|
923
|
+
}),
|
|
924
|
+
};
|
|
828
925
|
} catch {
|
|
829
|
-
return
|
|
926
|
+
return { handled: true, response: createStaticErrorResponse(500) };
|
|
830
927
|
}
|
|
831
928
|
}
|
|
832
929
|
|
|
833
930
|
// ========== Request Handler ==========
|
|
834
931
|
|
|
932
|
+
function unauthorizedControlResponse(): Response {
|
|
933
|
+
return Response.json({ error: "Unauthorized runtime control request" }, { status: 401 });
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function resolveInternalCacheTarget(payload: Record<string, unknown>): string {
|
|
937
|
+
if (typeof payload.path === "string" && payload.path.length > 0) {
|
|
938
|
+
return `path=${payload.path}`;
|
|
939
|
+
}
|
|
940
|
+
if (typeof payload.tag === "string" && payload.tag.length > 0) {
|
|
941
|
+
return `tag=${payload.tag}`;
|
|
942
|
+
}
|
|
943
|
+
if (payload.all === true) {
|
|
944
|
+
return "all";
|
|
945
|
+
}
|
|
946
|
+
return "unknown";
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
async function handleInternalCacheControlRequest(
|
|
950
|
+
req: Request,
|
|
951
|
+
settings: ServerRegistrySettings
|
|
952
|
+
): Promise<Response> {
|
|
953
|
+
const expectedToken = settings.managementToken;
|
|
954
|
+
const providedToken = req.headers.get("x-mandu-control-token");
|
|
955
|
+
|
|
956
|
+
if (!expectedToken || providedToken !== expectedToken) {
|
|
957
|
+
return unauthorizedControlResponse();
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
const store = settings.cacheStore ?? null;
|
|
961
|
+
if (!store) {
|
|
962
|
+
return Response.json({
|
|
963
|
+
enabled: false,
|
|
964
|
+
message: "Runtime cache is disabled for this server instance.",
|
|
965
|
+
stats: null,
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
if (req.method === "GET") {
|
|
970
|
+
const stats = getCacheStoreStats(store);
|
|
971
|
+
return Response.json({
|
|
972
|
+
enabled: true,
|
|
973
|
+
message: "Runtime cache is available.",
|
|
974
|
+
stats,
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
if (req.method === "POST" || req.method === "DELETE") {
|
|
979
|
+
let payload: Record<string, unknown> = {};
|
|
980
|
+
if (req.method === "POST") {
|
|
981
|
+
try {
|
|
982
|
+
payload = await req.json() as Record<string, unknown>;
|
|
983
|
+
} catch {
|
|
984
|
+
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
|
985
|
+
}
|
|
986
|
+
} else {
|
|
987
|
+
payload = { all: true };
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
const before = store.size;
|
|
991
|
+
if (typeof payload.path === "string" && payload.path.length > 0) {
|
|
992
|
+
store.deleteByPath(payload.path);
|
|
993
|
+
} else if (typeof payload.tag === "string" && payload.tag.length > 0) {
|
|
994
|
+
store.deleteByTag(payload.tag);
|
|
995
|
+
} else if (payload.all === true) {
|
|
996
|
+
store.clear();
|
|
997
|
+
} else {
|
|
998
|
+
return Response.json({
|
|
999
|
+
error: "Provide one of: { path }, { tag }, or { all: true }",
|
|
1000
|
+
}, { status: 400 });
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const after = store.size;
|
|
1004
|
+
const stats: CacheStoreStats | null = getCacheStoreStats(store);
|
|
1005
|
+
|
|
1006
|
+
return Response.json({
|
|
1007
|
+
enabled: true,
|
|
1008
|
+
cleared: Math.max(0, before - after),
|
|
1009
|
+
target: resolveInternalCacheTarget(payload),
|
|
1010
|
+
stats,
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
1015
|
+
}
|
|
1016
|
+
|
|
835
1017
|
async function handleRequest(req: Request, router: Router, registry: ServerRegistry): Promise<Response> {
|
|
1018
|
+
const requestStart = Date.now();
|
|
836
1019
|
const result = await handleRequestInternal(req, router, registry);
|
|
837
1020
|
|
|
838
1021
|
if (!result.ok) {
|
|
839
|
-
|
|
1022
|
+
const errorResponse = errorToResponse(result.error, registry.settings.isDev);
|
|
1023
|
+
if (registry.settings.isDev) {
|
|
1024
|
+
const url = new URL(req.url);
|
|
1025
|
+
const p = url.pathname;
|
|
1026
|
+
if (!p.startsWith("/.mandu/") && !p.startsWith("/__kitchen")) {
|
|
1027
|
+
const elapsed = Date.now() - requestStart;
|
|
1028
|
+
console.log(`[${new Date().toLocaleTimeString()}] ${req.method} ${p} ${errorResponse.status} ${elapsed}ms`);
|
|
1029
|
+
recordRequest({ id: crypto.randomUUID(), method: req.method, path: p, status: errorResponse.status, duration: elapsed, timestamp: Date.now() });
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
return errorResponse;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
if (registry.settings.isDev) {
|
|
1036
|
+
const url = new URL(req.url);
|
|
1037
|
+
const p = url.pathname;
|
|
1038
|
+
if (!p.startsWith("/.mandu/") && !p.startsWith("/__kitchen")) {
|
|
1039
|
+
const elapsed = Date.now() - requestStart;
|
|
1040
|
+
const status = result.value.status;
|
|
1041
|
+
const cacheHdr = result.value.headers.get("X-Mandu-Cache") ?? "";
|
|
1042
|
+
const cacheTag = cacheHdr ? ` ${cacheHdr}` : "";
|
|
1043
|
+
console.log(`[${new Date().toLocaleTimeString()}] ${req.method} ${p} ${status} ${elapsed}ms${cacheTag}`);
|
|
1044
|
+
recordRequest({ id: crypto.randomUUID(), method: req.method, path: p, status, duration: elapsed, timestamp: Date.now(), cacheStatus: cacheHdr || undefined });
|
|
1045
|
+
}
|
|
840
1046
|
}
|
|
841
1047
|
|
|
842
1048
|
return result.value;
|
|
@@ -873,6 +1079,8 @@ async function handleApiRoute(
|
|
|
873
1079
|
interface PageLoadResult {
|
|
874
1080
|
loaderData: unknown;
|
|
875
1081
|
cookies?: CookieManager;
|
|
1082
|
+
/** Layout별 loader 데이터 (모듈 경로 → 데이터) */
|
|
1083
|
+
layoutData?: Map<string, unknown>;
|
|
876
1084
|
}
|
|
877
1085
|
|
|
878
1086
|
/**
|
|
@@ -880,7 +1088,7 @@ interface PageLoadResult {
|
|
|
880
1088
|
*/
|
|
881
1089
|
async function loadPageData(
|
|
882
1090
|
req: Request,
|
|
883
|
-
route: { id: string; pattern: string },
|
|
1091
|
+
route: { id: string; pattern: string; layoutChain?: string[] },
|
|
884
1092
|
params: Record<string, string>,
|
|
885
1093
|
registry: ServerRegistry
|
|
886
1094
|
): Promise<Result<PageLoadResult>> {
|
|
@@ -891,9 +1099,7 @@ async function loadPageData(
|
|
|
891
1099
|
if (pageHandler) {
|
|
892
1100
|
let cookies: CookieManager | undefined;
|
|
893
1101
|
try {
|
|
894
|
-
const registration = await pageHandler
|
|
895
|
-
const component = registration.component as RouteComponent;
|
|
896
|
-
registry.registerRouteComponent(route.id, component);
|
|
1102
|
+
const registration = await ensurePageRouteMetadata(route.id, registry, pageHandler);
|
|
897
1103
|
|
|
898
1104
|
// Filling의 loader 실행
|
|
899
1105
|
if (registration.filling?.hasLoader()) {
|
|
@@ -928,9 +1134,12 @@ async function loadPageData(
|
|
|
928
1134
|
: (exportedObj?.component ?? exported);
|
|
929
1135
|
registry.registerRouteComponent(route.id, component as RouteComponent);
|
|
930
1136
|
|
|
931
|
-
// filling이 있으면 loader 실행
|
|
1137
|
+
// filling이 있으면 캐시 옵션 등록 + loader 실행
|
|
932
1138
|
let cookies: CookieManager | undefined;
|
|
933
1139
|
const filling = typeof exported === "object" && exported !== null ? (exportedObj as Record<string, unknown>)?.filling as ManduFilling | null : null;
|
|
1140
|
+
if (filling?.getCacheOptions?.()) {
|
|
1141
|
+
registry.cacheOptions.set(route.id, filling.getCacheOptions()!);
|
|
1142
|
+
}
|
|
934
1143
|
if (filling?.hasLoader?.()) {
|
|
935
1144
|
const ctx = new ManduContext(req, params);
|
|
936
1145
|
loaderData = await filling.executeLoader(ctx);
|
|
@@ -954,18 +1163,103 @@ async function loadPageData(
|
|
|
954
1163
|
return ok({ loaderData });
|
|
955
1164
|
}
|
|
956
1165
|
|
|
1166
|
+
/**
|
|
1167
|
+
* Layout chain의 모든 loader를 병렬 실행
|
|
1168
|
+
* 각 layout.slot.ts가 있으면 해당 데이터를 layout props로 전달
|
|
1169
|
+
*/
|
|
1170
|
+
async function loadLayoutData(
|
|
1171
|
+
req: Request,
|
|
1172
|
+
layoutChain: string[] | undefined,
|
|
1173
|
+
params: Record<string, string>,
|
|
1174
|
+
registry: ServerRegistry
|
|
1175
|
+
): Promise<Map<string, unknown>> {
|
|
1176
|
+
const layoutData = new Map<string, unknown>();
|
|
1177
|
+
if (!layoutChain || layoutChain.length === 0) return layoutData;
|
|
1178
|
+
|
|
1179
|
+
// layout.slot.ts 파일 검색: layout 모듈 경로에서 .slot.ts 파일 경로 유도
|
|
1180
|
+
// 예: app/layout.tsx → spec/slots/layout.slot.ts (auto-link 규칙)
|
|
1181
|
+
// 또는 직접 등록된 layout loader에서 filling 추출
|
|
1182
|
+
|
|
1183
|
+
const loaderEntries: { modulePath: string; slotPath: string }[] = [];
|
|
1184
|
+
for (const modulePath of layoutChain) {
|
|
1185
|
+
// 캐시된 결과 확인
|
|
1186
|
+
if (registry.layoutSlotPaths.has(modulePath)) {
|
|
1187
|
+
const cached = registry.layoutSlotPaths.get(modulePath);
|
|
1188
|
+
if (cached) loaderEntries.push({ modulePath, slotPath: cached });
|
|
1189
|
+
continue;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// layout.tsx → layout 이름 추출 → 같은 디렉토리에서 .slot.ts 검색
|
|
1193
|
+
const layoutName = path.basename(modulePath, path.extname(modulePath));
|
|
1194
|
+
const slotCandidates = [
|
|
1195
|
+
path.join(path.dirname(modulePath), `${layoutName}.slot.ts`),
|
|
1196
|
+
path.join(path.dirname(modulePath), `${layoutName}.slot.tsx`),
|
|
1197
|
+
];
|
|
1198
|
+
let found = false;
|
|
1199
|
+
for (const slotPath of slotCandidates) {
|
|
1200
|
+
try {
|
|
1201
|
+
const fullPath = path.join(registry.settings.rootDir, slotPath);
|
|
1202
|
+
const file = Bun.file(fullPath);
|
|
1203
|
+
if (await file.exists()) {
|
|
1204
|
+
registry.layoutSlotPaths.set(modulePath, fullPath);
|
|
1205
|
+
loaderEntries.push({ modulePath, slotPath: fullPath });
|
|
1206
|
+
found = true;
|
|
1207
|
+
break;
|
|
1208
|
+
}
|
|
1209
|
+
} catch {
|
|
1210
|
+
// 파일 없으면 스킵
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
if (!found) {
|
|
1214
|
+
registry.layoutSlotPaths.set(modulePath, null); // 없음 캐시
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
if (loaderEntries.length === 0) return layoutData;
|
|
1219
|
+
|
|
1220
|
+
const results = await Promise.all(
|
|
1221
|
+
loaderEntries.map(async ({ modulePath, slotPath }) => {
|
|
1222
|
+
try {
|
|
1223
|
+
const module = await import(slotPath);
|
|
1224
|
+
const exported = module.default;
|
|
1225
|
+
// layout.slot.ts가 ManduFilling이면 loader 실행
|
|
1226
|
+
if (exported && typeof exported === "object" && "executeLoader" in exported) {
|
|
1227
|
+
const filling = exported as ManduFilling;
|
|
1228
|
+
if (filling.hasLoader()) {
|
|
1229
|
+
const ctx = new ManduContext(req, params);
|
|
1230
|
+
const data = await filling.executeLoader(ctx);
|
|
1231
|
+
return { modulePath, data };
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
} catch (error) {
|
|
1235
|
+
console.warn(`[Mandu] Layout loader failed for ${modulePath}:`, error);
|
|
1236
|
+
}
|
|
1237
|
+
return { modulePath, data: undefined };
|
|
1238
|
+
})
|
|
1239
|
+
);
|
|
1240
|
+
|
|
1241
|
+
for (const { modulePath, data } of results) {
|
|
1242
|
+
if (data !== undefined) {
|
|
1243
|
+
layoutData.set(modulePath, data);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
return layoutData;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
957
1250
|
// ---------- SSR Renderer ----------
|
|
958
1251
|
|
|
959
1252
|
/**
|
|
960
1253
|
* SSR 렌더링 (Streaming/Non-streaming)
|
|
961
1254
|
*/
|
|
962
1255
|
async function renderPageSSR(
|
|
963
|
-
route: { id: string; pattern: string; layoutChain?: string[]; streaming?: boolean; hydration?: HydrationConfig },
|
|
1256
|
+
route: { id: string; pattern: string; layoutChain?: string[]; streaming?: boolean; hydration?: HydrationConfig; errorModule?: string },
|
|
964
1257
|
params: Record<string, string>,
|
|
965
1258
|
loaderData: unknown,
|
|
966
1259
|
url: string,
|
|
967
1260
|
registry: ServerRegistry,
|
|
968
|
-
cookies?: CookieManager
|
|
1261
|
+
cookies?: CookieManager,
|
|
1262
|
+
layoutData?: Map<string, unknown>
|
|
969
1263
|
): Promise<Result<Response>> {
|
|
970
1264
|
const settings = registry.settings;
|
|
971
1265
|
const defaultAppCreator = createDefaultAppFactory(registry);
|
|
@@ -979,9 +1273,28 @@ async function renderPageSSR(
|
|
|
979
1273
|
loaderData,
|
|
980
1274
|
});
|
|
981
1275
|
|
|
982
|
-
// 레이아웃
|
|
1276
|
+
// Island 래핑: 레이아웃 적용 전에 페이지 콘텐츠만 island div로 감쌈
|
|
1277
|
+
// 이렇게 하면 레이아웃은 island 바깥에 위치하여 하이드레이션 시 레이아웃이 유지됨
|
|
1278
|
+
const needsIslandWrap =
|
|
1279
|
+
route.hydration &&
|
|
1280
|
+
route.hydration.strategy !== "none" &&
|
|
1281
|
+
settings.bundleManifest;
|
|
1282
|
+
|
|
1283
|
+
if (needsIslandWrap) {
|
|
1284
|
+
const bundle = settings.bundleManifest?.bundles[route.id];
|
|
1285
|
+
const bundleSrc = bundle?.js ? `${bundle.js}?t=${Date.now()}` : "";
|
|
1286
|
+
const priority = route.hydration!.priority || "visible";
|
|
1287
|
+
app = React.createElement("div", {
|
|
1288
|
+
"data-mandu-island": route.id,
|
|
1289
|
+
"data-mandu-src": bundleSrc,
|
|
1290
|
+
"data-mandu-priority": priority,
|
|
1291
|
+
style: { display: "contents" },
|
|
1292
|
+
}, app);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// 레이아웃 체인 적용 (island 래핑 후 → 레이아웃은 island 바깥)
|
|
983
1296
|
if (route.layoutChain && route.layoutChain.length > 0) {
|
|
984
|
-
app = await wrapWithLayouts(app, route.layoutChain, registry, params);
|
|
1297
|
+
app = await wrapWithLayouts(app, route.layoutChain, registry, params, layoutData);
|
|
985
1298
|
}
|
|
986
1299
|
|
|
987
1300
|
const serverData = loaderData
|
|
@@ -1024,6 +1337,9 @@ async function renderPageSSR(
|
|
|
1024
1337
|
}
|
|
1025
1338
|
|
|
1026
1339
|
// 기존 renderToString 방식
|
|
1340
|
+
// Note: hydration 래핑은 위에서 React 엘리먼트 레벨로 이미 처리됨
|
|
1341
|
+
// renderToHTML에서 중복 래핑하지 않도록 hydration을 전달하되 strategy를 "none"으로 설정
|
|
1342
|
+
// 단, hydration 스크립트(importmap, runtime 등)는 여전히 필요하므로 bundleManifest는 유지
|
|
1027
1343
|
const ssrResponse = renderSSR(app, {
|
|
1028
1344
|
title: `${route.id} - Mandu`,
|
|
1029
1345
|
isDev: settings.isDev,
|
|
@@ -1035,13 +1351,46 @@ async function renderPageSSR(
|
|
|
1035
1351
|
enableClientRouter: true,
|
|
1036
1352
|
routePattern: route.pattern,
|
|
1037
1353
|
cssPath: settings.cssPath,
|
|
1354
|
+
islandPreWrapped: !!needsIslandWrap,
|
|
1038
1355
|
});
|
|
1039
1356
|
return ok(cookies ? cookies.applyToResponse(ssrResponse) : ssrResponse);
|
|
1040
1357
|
} catch (error) {
|
|
1358
|
+
const renderError = error instanceof Error ? error : new Error(String(error));
|
|
1359
|
+
|
|
1360
|
+
// Route-level ErrorBoundary: errorModule이 있으면 해당 컴포넌트로 에러 렌더링
|
|
1361
|
+
if (route.errorModule) {
|
|
1362
|
+
try {
|
|
1363
|
+
const errorMod = await import(path.join(settings.rootDir, route.errorModule));
|
|
1364
|
+
const ErrorComponent = errorMod.default as React.ComponentType<ErrorFallbackProps>;
|
|
1365
|
+
if (ErrorComponent) {
|
|
1366
|
+
const errorElement = React.createElement(ErrorComponent, {
|
|
1367
|
+
error: renderError,
|
|
1368
|
+
errorInfo: undefined,
|
|
1369
|
+
resetError: () => {}, // SSR에서는 noop — 클라이언트 hydration 시 실제 동작
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
// 레이아웃은 유지하면서 에러 컴포넌트만 교체
|
|
1373
|
+
let errorApp: React.ReactElement = errorElement;
|
|
1374
|
+
if (route.layoutChain && route.layoutChain.length > 0) {
|
|
1375
|
+
errorApp = await wrapWithLayouts(errorApp, route.layoutChain, registry, params, layoutData);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
const errorHtml = renderSSR(errorApp, {
|
|
1379
|
+
title: `Error - ${route.id}`,
|
|
1380
|
+
isDev: settings.isDev,
|
|
1381
|
+
cssPath: settings.cssPath,
|
|
1382
|
+
});
|
|
1383
|
+
return ok(cookies ? cookies.applyToResponse(errorHtml) : errorHtml);
|
|
1384
|
+
}
|
|
1385
|
+
} catch (errorBoundaryError) {
|
|
1386
|
+
console.error(`[Mandu] Error boundary failed for ${route.id}:`, errorBoundaryError);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1041
1390
|
const ssrError = createSSRErrorResponse(
|
|
1042
1391
|
route.id,
|
|
1043
1392
|
route.pattern,
|
|
1044
|
-
|
|
1393
|
+
renderError
|
|
1045
1394
|
);
|
|
1046
1395
|
console.error(`[Mandu] ${ssrError.errorType}:`, ssrError.message);
|
|
1047
1396
|
return err(ssrError);
|
|
@@ -1050,6 +1399,9 @@ async function renderPageSSR(
|
|
|
1050
1399
|
|
|
1051
1400
|
// ---------- Page Route Handler ----------
|
|
1052
1401
|
|
|
1402
|
+
/** SWR 백그라운드 재생성 중복 방지 */
|
|
1403
|
+
const pendingRevalidations = new Set<string>();
|
|
1404
|
+
|
|
1053
1405
|
/**
|
|
1054
1406
|
* 페이지 라우트 처리
|
|
1055
1407
|
*/
|
|
@@ -1060,8 +1412,68 @@ async function handlePageRoute(
|
|
|
1060
1412
|
params: Record<string, string>,
|
|
1061
1413
|
registry: ServerRegistry
|
|
1062
1414
|
): Promise<Result<Response>> {
|
|
1063
|
-
|
|
1064
|
-
const
|
|
1415
|
+
const settings = registry.settings;
|
|
1416
|
+
const cache = settings.cacheStore;
|
|
1417
|
+
// Only call ensurePageRouteMetadata when a pageHandler exists;
|
|
1418
|
+
// routes registered via registerPageLoader are handled by loadPageData instead.
|
|
1419
|
+
if (registry.pageHandlers.has(route.id)) {
|
|
1420
|
+
await ensurePageRouteMetadata(route.id, registry);
|
|
1421
|
+
}
|
|
1422
|
+
const renderMode = getRenderModeForRoute(route.id, registry);
|
|
1423
|
+
|
|
1424
|
+
// _data 요청 (SPA 네비게이션)은 캐시하지 않음
|
|
1425
|
+
const isDataRequest = url.searchParams.has("_data");
|
|
1426
|
+
|
|
1427
|
+
// PPR: cached shell + fresh dynamic data per request
|
|
1428
|
+
if (renderMode === "ppr" && cache && !isDataRequest) {
|
|
1429
|
+
const shellCacheKey = `ppr-shell:${route.id}`;
|
|
1430
|
+
const cachedShell = cache.get(shellCacheKey);
|
|
1431
|
+
|
|
1432
|
+
if (cachedShell) {
|
|
1433
|
+
// Shell HIT: load only the dynamic data (cheap), skip full SSR render
|
|
1434
|
+
const loadResult = await loadPageData(req, route, params, registry);
|
|
1435
|
+
if (!loadResult.ok) return loadResult;
|
|
1436
|
+
const { loaderData, cookies } = loadResult.value;
|
|
1437
|
+
const pprResponse = createPPRResponse(cachedShell.html, route.id, loaderData);
|
|
1438
|
+
return ok(cookies ? cookies.applyToResponse(pprResponse) : pprResponse);
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
// Shell MISS: fall through to full render, then cache the shell below
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
// ISR/SWR 캐시 확인 (SSR 렌더링 요청에만 적용)
|
|
1445
|
+
if (cache && !isDataRequest && renderMode !== "dynamic" && renderMode !== "ppr") {
|
|
1446
|
+
const cacheKey = buildRouteCacheKey(route.id, url);
|
|
1447
|
+
const lookup = lookupCache(cache, cacheKey);
|
|
1448
|
+
|
|
1449
|
+
if (lookup.status === "HIT" && lookup.entry) {
|
|
1450
|
+
return ok(createCachedResponse(lookup.entry, "HIT"));
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
if (lookup.status === "STALE" && lookup.entry) {
|
|
1454
|
+
// Stale-While-Revalidate: 이전 캐시 즉시 반환 + 백그라운드 재생성
|
|
1455
|
+
// 중복 재생성 방지: 이미 진행 중이면 스킵
|
|
1456
|
+
if (!pendingRevalidations.has(cacheKey)) {
|
|
1457
|
+
pendingRevalidations.add(cacheKey);
|
|
1458
|
+
queueMicrotask(async () => {
|
|
1459
|
+
try {
|
|
1460
|
+
await regenerateCache(req, url, route, params, registry, cache, cacheKey);
|
|
1461
|
+
} catch (error) {
|
|
1462
|
+
console.warn(`[Mandu Cache] Background revalidation failed for ${cacheKey}:`, error);
|
|
1463
|
+
} finally {
|
|
1464
|
+
pendingRevalidations.delete(cacheKey);
|
|
1465
|
+
}
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
return ok(createCachedResponse(lookup.entry, "STALE"));
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// 1. 페이지 + 레이아웃 데이터 병렬 로딩
|
|
1473
|
+
const [loadResult, layoutData] = await Promise.all([
|
|
1474
|
+
loadPageData(req, route, params, registry),
|
|
1475
|
+
loadLayoutData(req, route.layoutChain, params, registry),
|
|
1476
|
+
]);
|
|
1065
1477
|
if (!loadResult.ok) {
|
|
1066
1478
|
return loadResult;
|
|
1067
1479
|
}
|
|
@@ -1069,7 +1481,8 @@ async function handlePageRoute(
|
|
|
1069
1481
|
const { loaderData, cookies } = loadResult.value;
|
|
1070
1482
|
|
|
1071
1483
|
// 2. Client-side Routing: 데이터만 반환 (JSON)
|
|
1072
|
-
|
|
1484
|
+
// 참고: layoutData는 SSR 시에만 사용 — SPA 네비게이션은 전체 페이지 SSR을 받지 않으므로 제외
|
|
1485
|
+
if (isDataRequest) {
|
|
1073
1486
|
const jsonResponse = Response.json({
|
|
1074
1487
|
routeId: route.id,
|
|
1075
1488
|
pattern: route.pattern,
|
|
@@ -1080,8 +1493,141 @@ async function handlePageRoute(
|
|
|
1080
1493
|
return ok(cookies ? cookies.applyToResponse(jsonResponse) : jsonResponse);
|
|
1081
1494
|
}
|
|
1082
1495
|
|
|
1083
|
-
// 3. SSR 렌더링
|
|
1084
|
-
|
|
1496
|
+
// 3. SSR 렌더링 (layoutData 전달)
|
|
1497
|
+
const ssrResult = await renderPageSSR(route, params, loaderData, req.url, registry, cookies, layoutData);
|
|
1498
|
+
|
|
1499
|
+
// 4a. PPR: cache only the shell (HTML structure minus loader data), not the full page
|
|
1500
|
+
if (cache && ssrResult.ok && renderMode === "ppr") {
|
|
1501
|
+
const cacheOptions = getCacheOptionsForRoute(route.id, registry);
|
|
1502
|
+
const revalidate = cacheOptions?.revalidate ?? 3600; // default 1 hour for PPR shells
|
|
1503
|
+
const shellCacheKey = `ppr-shell:${route.id}`;
|
|
1504
|
+
const cloned = ssrResult.value.clone();
|
|
1505
|
+
cloned.text().then((html) => {
|
|
1506
|
+
const shellHtml = extractShellHtml(html);
|
|
1507
|
+
cache.set(shellCacheKey, createCacheEntry(
|
|
1508
|
+
shellHtml, null, revalidate, cacheOptions?.tags ?? []
|
|
1509
|
+
));
|
|
1510
|
+
}).catch(() => {});
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
// 4b. ISR/SWR 캐시 저장 (revalidate 설정이 있는 경우 — non-blocking)
|
|
1514
|
+
if (cache && ssrResult.ok && renderMode !== "dynamic" && renderMode !== "ppr") {
|
|
1515
|
+
const cacheOptions = getCacheOptionsForRoute(route.id, registry);
|
|
1516
|
+
if (cacheOptions?.revalidate && cacheOptions.revalidate > 0) {
|
|
1517
|
+
const cloned = ssrResult.value.clone();
|
|
1518
|
+
const status = ssrResult.value.status;
|
|
1519
|
+
const headers = Object.fromEntries(ssrResult.value.headers.entries());
|
|
1520
|
+
const cacheKey = buildRouteCacheKey(route.id, url);
|
|
1521
|
+
// streaming 응답도 블로킹하지 않도록 백그라운드에서 캐시 저장
|
|
1522
|
+
cloned.text().then((html) => {
|
|
1523
|
+
cache.set(cacheKey, createCacheEntry(
|
|
1524
|
+
html, loaderData, cacheOptions.revalidate!, cacheOptions.tags ?? [], status, headers
|
|
1525
|
+
));
|
|
1526
|
+
}).catch(() => {});
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
return ssrResult;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
/**
|
|
1534
|
+
* 백그라운드 캐시 재생성 (SWR 패턴)
|
|
1535
|
+
*/
|
|
1536
|
+
async function regenerateCache(
|
|
1537
|
+
req: Request,
|
|
1538
|
+
url: URL,
|
|
1539
|
+
route: { id: string; pattern: string; layoutChain?: string[]; streaming?: boolean; hydration?: HydrationConfig },
|
|
1540
|
+
params: Record<string, string>,
|
|
1541
|
+
registry: ServerRegistry,
|
|
1542
|
+
cache: CacheStore,
|
|
1543
|
+
cacheKey: string
|
|
1544
|
+
): Promise<void> {
|
|
1545
|
+
const [loadResult, layoutData] = await Promise.all([
|
|
1546
|
+
loadPageData(req, route, params, registry),
|
|
1547
|
+
loadLayoutData(req, route.layoutChain, params, registry),
|
|
1548
|
+
]);
|
|
1549
|
+
if (!loadResult.ok) return;
|
|
1550
|
+
|
|
1551
|
+
const { loaderData } = loadResult.value;
|
|
1552
|
+
const ssrResult = await renderPageSSR(route, params, loaderData, req.url, registry, undefined, layoutData);
|
|
1553
|
+
if (!ssrResult.ok) return;
|
|
1554
|
+
|
|
1555
|
+
const cacheOptions = getCacheOptionsForRoute(route.id, registry);
|
|
1556
|
+
if (!cacheOptions?.revalidate) return;
|
|
1557
|
+
|
|
1558
|
+
const html = await ssrResult.value.text();
|
|
1559
|
+
const entry = createCacheEntry(
|
|
1560
|
+
html,
|
|
1561
|
+
loaderData,
|
|
1562
|
+
cacheOptions.revalidate,
|
|
1563
|
+
cacheOptions.tags ?? [],
|
|
1564
|
+
ssrResult.value.status,
|
|
1565
|
+
Object.fromEntries(ssrResult.value.headers.entries())
|
|
1566
|
+
);
|
|
1567
|
+
cache.set(cacheKey, entry);
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
/**
|
|
1571
|
+
* 라우트의 캐시 옵션 가져오기 (pageHandler의 filling에서 추출)
|
|
1572
|
+
*/
|
|
1573
|
+
function getCacheOptionsForRoute(
|
|
1574
|
+
routeId: string,
|
|
1575
|
+
registry: ServerRegistry
|
|
1576
|
+
): { revalidate?: number; tags?: string[] } | null {
|
|
1577
|
+
const pageHandler = registry.pageHandlers.get(routeId);
|
|
1578
|
+
if (!pageHandler) return null;
|
|
1579
|
+
|
|
1580
|
+
// pageHandler는 async () => { component, filling } 형태
|
|
1581
|
+
// filling의 getCacheOptions()를 호출하려면 filling 인스턴스에 접근해야 하지만
|
|
1582
|
+
// pageHandler 실행 없이는 접근 불가 → 등록 시점에 캐시 옵션을 별도 저장
|
|
1583
|
+
return registry.cacheOptions?.get(routeId) ?? null;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
function getRenderModeForRoute(routeId: string, registry: ServerRegistry): RenderMode {
|
|
1587
|
+
return registry.renderModes.get(routeId) ?? "dynamic";
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
async function ensurePageRouteMetadata(
|
|
1591
|
+
routeId: string,
|
|
1592
|
+
registry: ServerRegistry,
|
|
1593
|
+
pageHandler?: PageHandler
|
|
1594
|
+
): Promise<PageRegistration> {
|
|
1595
|
+
const handler = pageHandler ?? registry.pageHandlers.get(routeId);
|
|
1596
|
+
if (!handler) {
|
|
1597
|
+
throw new Error(`Page handler not found for route: ${routeId}`);
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
const existingComponent = registry.routeComponents.get(routeId);
|
|
1601
|
+
const existingFilling = registry.pageFillings.get(routeId);
|
|
1602
|
+
if (existingComponent && existingFilling) {
|
|
1603
|
+
return { component: existingComponent, filling: existingFilling };
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
const registration = await handler();
|
|
1607
|
+
const component = registration.component as RouteComponent;
|
|
1608
|
+
registry.registerRouteComponent(routeId, component);
|
|
1609
|
+
|
|
1610
|
+
if (registration.filling) {
|
|
1611
|
+
registry.pageFillings.set(routeId, registration.filling);
|
|
1612
|
+
const cacheOptions = registration.filling.getCacheOptions?.();
|
|
1613
|
+
if (cacheOptions) {
|
|
1614
|
+
registry.cacheOptions.set(routeId, cacheOptions);
|
|
1615
|
+
}
|
|
1616
|
+
registry.renderModes.set(routeId, registration.filling.getRenderMode());
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
return registration;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
function buildRouteCacheKey(routeId: string, url: URL): string {
|
|
1623
|
+
const entries = [...url.searchParams.entries()].sort(([aKey, aValue], [bKey, bValue]) => {
|
|
1624
|
+
if (aKey === bKey) {
|
|
1625
|
+
return aValue.localeCompare(bValue);
|
|
1626
|
+
}
|
|
1627
|
+
return aKey.localeCompare(bKey);
|
|
1628
|
+
});
|
|
1629
|
+
const search = entries.length > 0 ? `?${new URLSearchParams(entries).toString()}` : "";
|
|
1630
|
+
return `${routeId}:${url.pathname}${search}`;
|
|
1085
1631
|
}
|
|
1086
1632
|
|
|
1087
1633
|
// ---------- Main Request Dispatcher ----------
|
|
@@ -1105,8 +1651,9 @@ async function handleRequestInternal(
|
|
|
1105
1651
|
}
|
|
1106
1652
|
|
|
1107
1653
|
// 1. 정적 파일 서빙 시도 (최우선)
|
|
1108
|
-
const
|
|
1109
|
-
if (
|
|
1654
|
+
const staticFileResult = await serveStaticFile(pathname, settings, req);
|
|
1655
|
+
if (staticFileResult.handled) {
|
|
1656
|
+
const staticResponse = staticFileResult.response!;
|
|
1110
1657
|
if (settings.cors && isCorsRequest(req)) {
|
|
1111
1658
|
const corsOptions: CorsOptions = typeof settings.cors === 'object' ? settings.cors : {};
|
|
1112
1659
|
return ok(applyCorsToResponse(staticResponse, req, corsOptions));
|
|
@@ -1114,7 +1661,24 @@ async function handleRequestInternal(
|
|
|
1114
1661
|
return ok(staticResponse);
|
|
1115
1662
|
}
|
|
1116
1663
|
|
|
1117
|
-
//
|
|
1664
|
+
// 1.5. Image optimization handler (/_mandu/image)
|
|
1665
|
+
if (pathname === "/_mandu/image") {
|
|
1666
|
+
const imageResponse = await handleImageRequest(req, settings.rootDir, settings.publicDir);
|
|
1667
|
+
if (imageResponse) return ok(imageResponse);
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
// 1.6. Internal runtime cache control endpoint
|
|
1671
|
+
if (pathname === INTERNAL_CACHE_ENDPOINT) {
|
|
1672
|
+
return ok(await handleInternalCacheControlRequest(req, settings));
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
// 2. Kitchen dev dashboard (dev mode only)
|
|
1676
|
+
if (settings.isDev && pathname.startsWith(KITCHEN_PREFIX) && registry.kitchen) {
|
|
1677
|
+
const kitchenResponse = await registry.kitchen.handle(req, pathname);
|
|
1678
|
+
if (kitchenResponse) return ok(kitchenResponse);
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
// 3. 라우트 매칭
|
|
1118
1682
|
const match = router.match(pathname);
|
|
1119
1683
|
if (!match) {
|
|
1120
1684
|
return err(createNotFoundResponse(pathname));
|
|
@@ -1174,19 +1738,18 @@ function isPortInUseError(error: unknown): boolean {
|
|
|
1174
1738
|
function startBunServerWithFallback(options: {
|
|
1175
1739
|
port: number;
|
|
1176
1740
|
hostname?: string;
|
|
1177
|
-
fetch: (req: Request) => Promise<Response>;
|
|
1741
|
+
fetch: (req: Request, server: Server<undefined>) => Promise<Response | undefined>;
|
|
1742
|
+
websocket?: Record<string, unknown>;
|
|
1178
1743
|
}): { server: Server<undefined>; port: number; attempts: number } {
|
|
1179
|
-
const { port: startPort, hostname, fetch } = options;
|
|
1744
|
+
const { port: startPort, hostname, fetch, websocket } = options;
|
|
1180
1745
|
let lastError: unknown = null;
|
|
1181
1746
|
|
|
1747
|
+
const serveOptions: Record<string, unknown> = { hostname, fetch, idleTimeout: 255 };
|
|
1748
|
+
if (websocket) serveOptions.websocket = websocket;
|
|
1749
|
+
|
|
1182
1750
|
// Port 0: let Bun/OS pick an available ephemeral port.
|
|
1183
1751
|
if (startPort === 0) {
|
|
1184
|
-
const server = Bun.serve({
|
|
1185
|
-
port: 0,
|
|
1186
|
-
hostname,
|
|
1187
|
-
fetch,
|
|
1188
|
-
idleTimeout: 255,
|
|
1189
|
-
});
|
|
1752
|
+
const server = Bun.serve({ port: 0, ...serveOptions } as any);
|
|
1190
1753
|
return { server, port: server.port ?? 0, attempts: 0 };
|
|
1191
1754
|
}
|
|
1192
1755
|
|
|
@@ -1196,12 +1759,7 @@ function startBunServerWithFallback(options: {
|
|
|
1196
1759
|
continue;
|
|
1197
1760
|
}
|
|
1198
1761
|
try {
|
|
1199
|
-
const server = Bun.serve({
|
|
1200
|
-
port: candidate,
|
|
1201
|
-
hostname,
|
|
1202
|
-
fetch,
|
|
1203
|
-
idleTimeout: 255,
|
|
1204
|
-
});
|
|
1762
|
+
const server = Bun.serve({ port: candidate, ...serveOptions } as any);
|
|
1205
1763
|
return { server, port: server.port ?? candidate, attempts: attempt };
|
|
1206
1764
|
} catch (error) {
|
|
1207
1765
|
if (!isPortInUseError(error)) {
|
|
@@ -1230,6 +1788,9 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
1230
1788
|
rateLimit = false,
|
|
1231
1789
|
cssPath: cssPathOption,
|
|
1232
1790
|
registry = defaultRegistry,
|
|
1791
|
+
guardConfig = null,
|
|
1792
|
+
cache: cacheOption,
|
|
1793
|
+
managementToken,
|
|
1233
1794
|
} = options;
|
|
1234
1795
|
|
|
1235
1796
|
// cssPath 처리:
|
|
@@ -1264,28 +1825,96 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
1264
1825
|
streaming,
|
|
1265
1826
|
rateLimit: rateLimitOptions,
|
|
1266
1827
|
cssPath,
|
|
1828
|
+
managementToken,
|
|
1267
1829
|
};
|
|
1268
1830
|
|
|
1269
1831
|
registry.rateLimiter = rateLimitOptions ? new MemoryRateLimiter() : null;
|
|
1270
1832
|
|
|
1833
|
+
// ISR/SWR 캐시 초기화
|
|
1834
|
+
if (cacheOption) {
|
|
1835
|
+
const store = cacheOption === true ? new MemoryCacheStore() : cacheOption;
|
|
1836
|
+
registry.settings.cacheStore = store;
|
|
1837
|
+
setGlobalCache(store); // revalidatePath/revalidateTag API에서 사용
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
// Kitchen dev dashboard (dev mode only)
|
|
1841
|
+
if (isDev) {
|
|
1842
|
+
const kitchen = new KitchenHandler({ rootDir, manifest, guardConfig });
|
|
1843
|
+
kitchen.start();
|
|
1844
|
+
registry.kitchen = kitchen;
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1271
1847
|
const router = new Router(manifest.routes);
|
|
1272
1848
|
|
|
1273
|
-
//
|
|
1274
|
-
|
|
1275
|
-
|
|
1849
|
+
// 글로벌 미들웨어 (middleware.ts) — 동기 로드로 첫 요청부터 보장
|
|
1850
|
+
let middlewareFn: MiddlewareFn | null = null;
|
|
1851
|
+
let middlewareConfig: MiddlewareConfig | null = null;
|
|
1276
1852
|
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1853
|
+
const mwResult = loadMiddlewareSync(rootDir);
|
|
1854
|
+
if (mwResult) {
|
|
1855
|
+
middlewareFn = mwResult.fn;
|
|
1856
|
+
middlewareConfig = mwResult.config;
|
|
1857
|
+
console.log("🔗 Global middleware loaded");
|
|
1858
|
+
}
|
|
1281
1859
|
|
|
1282
|
-
|
|
1283
|
-
|
|
1860
|
+
// Fetch handler: 미들웨어 + CORS + 라우트 디스패치 (런타임 중립 팩토리 사용)
|
|
1861
|
+
const fetchHandler = createFetchHandler({
|
|
1862
|
+
router,
|
|
1863
|
+
registry,
|
|
1864
|
+
corsOptions,
|
|
1865
|
+
middlewareFn,
|
|
1866
|
+
middlewareConfig,
|
|
1867
|
+
handleRequest,
|
|
1868
|
+
});
|
|
1869
|
+
|
|
1870
|
+
// WebSocket 핸들러 빌드 (등록된 WS 라우트가 있을 때만)
|
|
1871
|
+
const hasWsRoutes = registry.wsHandlers.size > 0;
|
|
1872
|
+
const wsConfig = hasWsRoutes ? {
|
|
1873
|
+
open(ws: any) {
|
|
1874
|
+
const data = ws.data as WSUpgradeData;
|
|
1875
|
+
const handlers = registry.wsHandlers.get(data.routeId);
|
|
1876
|
+
handlers?.open?.(wrapBunWebSocket(ws));
|
|
1877
|
+
},
|
|
1878
|
+
message(ws: any, message: string | ArrayBuffer) {
|
|
1879
|
+
const data = ws.data as WSUpgradeData;
|
|
1880
|
+
const handlers = registry.wsHandlers.get(data.routeId);
|
|
1881
|
+
handlers?.message?.(wrapBunWebSocket(ws), message);
|
|
1882
|
+
},
|
|
1883
|
+
close(ws: any, code: number, reason: string) {
|
|
1884
|
+
const data = ws.data as WSUpgradeData;
|
|
1885
|
+
const handlers = registry.wsHandlers.get(data.routeId);
|
|
1886
|
+
handlers?.close?.(wrapBunWebSocket(ws), code, reason);
|
|
1887
|
+
},
|
|
1888
|
+
drain(ws: any) {
|
|
1889
|
+
const data = ws.data as WSUpgradeData;
|
|
1890
|
+
const handlers = registry.wsHandlers.get(data.routeId);
|
|
1891
|
+
handlers?.drain?.(wrapBunWebSocket(ws));
|
|
1892
|
+
},
|
|
1893
|
+
} : undefined;
|
|
1894
|
+
|
|
1895
|
+
// fetch handler: WS upgrade 감지 추가
|
|
1896
|
+
const wrappedFetch = hasWsRoutes
|
|
1897
|
+
? async (req: Request, bunServer: Server<undefined>): Promise<Response | undefined> => {
|
|
1898
|
+
// WebSocket upgrade 요청 감지
|
|
1899
|
+
if (req.headers.get("upgrade") === "websocket") {
|
|
1900
|
+
const url = new URL(req.url);
|
|
1901
|
+
const match = router.match(url.pathname);
|
|
1902
|
+
if (match && registry.wsHandlers.has(match.route.id)) {
|
|
1903
|
+
const upgraded = (bunServer as any).upgrade(req, {
|
|
1904
|
+
data: { routeId: match.route.id, params: match.params, id: crypto.randomUUID() },
|
|
1905
|
+
});
|
|
1906
|
+
return upgraded ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
return fetchHandler(req);
|
|
1910
|
+
}
|
|
1911
|
+
: async (req: Request): Promise<Response> => fetchHandler(req);
|
|
1284
1912
|
|
|
1285
1913
|
const { server, port: actualPort, attempts } = startBunServerWithFallback({
|
|
1286
1914
|
port,
|
|
1287
1915
|
hostname,
|
|
1288
|
-
fetch:
|
|
1916
|
+
fetch: wrappedFetch as any,
|
|
1917
|
+
websocket: wsConfig,
|
|
1289
1918
|
});
|
|
1290
1919
|
|
|
1291
1920
|
if (attempts > 0) {
|
|
@@ -1308,6 +1937,9 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
1308
1937
|
if (streaming) {
|
|
1309
1938
|
console.log(`🌊 Streaming SSR enabled`);
|
|
1310
1939
|
}
|
|
1940
|
+
if (registry.kitchen) {
|
|
1941
|
+
console.log(`🍳 Kitchen dashboard at http://${hostname}:${actualPort}/__kitchen`);
|
|
1942
|
+
}
|
|
1311
1943
|
} else {
|
|
1312
1944
|
console.log(`🥟 Mandu server running at http://${hostname}:${actualPort}`);
|
|
1313
1945
|
if (streaming) {
|
|
@@ -1319,7 +1951,10 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
1319
1951
|
server,
|
|
1320
1952
|
router,
|
|
1321
1953
|
registry,
|
|
1322
|
-
stop: () =>
|
|
1954
|
+
stop: () => {
|
|
1955
|
+
registry.kitchen?.stop();
|
|
1956
|
+
server.stop();
|
|
1957
|
+
},
|
|
1323
1958
|
};
|
|
1324
1959
|
}
|
|
1325
1960
|
|