@mandujs/core 0.18.22 → 0.19.2
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 +291 -113
- package/src/bundler/css.ts +20 -5
- package/src/bundler/dev.ts +55 -2
- package/src/bundler/prerender.ts +195 -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/panel/diff-viewer.tsx +219 -0
- package/src/devtools/client/components/panel/guard-panel.tsx +374 -244
- package/src/devtools/client/components/panel/index.ts +45 -32
- package/src/devtools/client/components/panel/panel-container.tsx +332 -312
- package/src/devtools/client/components/panel/preview-panel.tsx +188 -0
- package/src/devtools/client/state-manager.ts +535 -478
- package/src/devtools/design-tokens.ts +265 -264
- package/src/devtools/types.ts +345 -319
- package/src/filling/context.ts +65 -0
- package/src/filling/filling.ts +336 -14
- 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 +5 -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 +256 -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/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 +12 -0
- package/src/runtime/middleware.ts +263 -0
- package/src/runtime/server.ts +686 -92
- package/src/runtime/ssr.ts +55 -29
- package/src/runtime/streaming-ssr.ts +106 -82
- package/src/spec/index.ts +0 -1
- package/src/spec/schema.ts +1 -0
- package/src/testing/index.ts +144 -0
- package/src/watcher/watcher.ts +27 -1
- package/src/spec/lock.ts +0 -56
package/src/runtime/server.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
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";
|
|
5
|
-
import { ManduContext } from "../filling/context";
|
|
4
|
+
import type { ManduFilling, RenderMode } from "../filling/filling";
|
|
5
|
+
import { ManduContext, type CookieManager } from "../filling/context";
|
|
6
6
|
import { Router } from "./router";
|
|
7
7
|
import { renderSSR, renderStreamingResponse } from "./ssr";
|
|
8
8
|
import { type ErrorFallbackProps } from "./boundary";
|
|
@@ -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,15 @@ import {
|
|
|
28
39
|
isCorsRequest,
|
|
29
40
|
} from "./cors";
|
|
30
41
|
import { validateImportPath } from "./security";
|
|
42
|
+
import { KITCHEN_PREFIX, KitchenHandler } 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";
|
|
31
51
|
|
|
32
52
|
export interface RateLimitOptions {
|
|
33
53
|
windowMs?: number;
|
|
@@ -258,7 +278,7 @@ function getMimeType(filePath: string): string {
|
|
|
258
278
|
}
|
|
259
279
|
|
|
260
280
|
// ========== Server Options ==========
|
|
261
|
-
export interface ServerOptions {
|
|
281
|
+
export interface ServerOptions {
|
|
262
282
|
port?: number;
|
|
263
283
|
hostname?: string;
|
|
264
284
|
/** 프로젝트 루트 디렉토리 */
|
|
@@ -301,7 +321,23 @@ export interface ServerOptions {
|
|
|
301
321
|
* - 테스트나 멀티앱 시나리오에서 createServerRegistry()로 생성한 인스턴스 전달
|
|
302
322
|
*/
|
|
303
323
|
registry?: ServerRegistry;
|
|
304
|
-
|
|
324
|
+
/**
|
|
325
|
+
* Guard config for Kitchen dev dashboard (dev mode only)
|
|
326
|
+
*/
|
|
327
|
+
guardConfig?: import("../guard/types").GuardConfig | null;
|
|
328
|
+
/**
|
|
329
|
+
* SSR 캐시 설정 (ISR/SWR 용)
|
|
330
|
+
* - true: 기본 메모리 캐시 (LRU 1000 엔트리)
|
|
331
|
+
* - CacheStore: 커스텀 캐시 구현체
|
|
332
|
+
* - false/undefined: 캐시 비활성화
|
|
333
|
+
*/
|
|
334
|
+
cache?: boolean | CacheStore;
|
|
335
|
+
/**
|
|
336
|
+
* Internal management token for local CLI/runtime control endpoints.
|
|
337
|
+
* When set, token-protected endpoints such as `/_mandu/cache` become available.
|
|
338
|
+
*/
|
|
339
|
+
managementToken?: string;
|
|
340
|
+
}
|
|
305
341
|
|
|
306
342
|
export interface ManduServer {
|
|
307
343
|
server: Server<undefined>;
|
|
@@ -392,12 +428,17 @@ export interface ServerRegistrySettings {
|
|
|
392
428
|
* - undefined: false로 처리 (404 방지)
|
|
393
429
|
*/
|
|
394
430
|
cssPath?: string | false;
|
|
395
|
-
|
|
431
|
+
/** ISR/SWR 캐시 스토어 */
|
|
432
|
+
cacheStore?: CacheStore;
|
|
433
|
+
/** Internal management token for local runtime control */
|
|
434
|
+
managementToken?: string;
|
|
435
|
+
}
|
|
396
436
|
|
|
397
437
|
export class ServerRegistry {
|
|
398
438
|
readonly apiHandlers: Map<string, ApiHandler> = new Map();
|
|
399
439
|
readonly pageLoaders: Map<string, PageLoader> = new Map();
|
|
400
440
|
readonly pageHandlers: Map<string, PageHandler> = new Map();
|
|
441
|
+
readonly pageFillings: Map<string, ManduFilling<unknown>> = new Map();
|
|
401
442
|
readonly routeComponents: Map<string, RouteComponent> = new Map();
|
|
402
443
|
/** Layout 컴포넌트 캐시 (모듈 경로 → 컴포넌트) */
|
|
403
444
|
readonly layoutComponents: Map<string, LayoutComponent> = new Map();
|
|
@@ -413,6 +454,16 @@ export class ServerRegistry {
|
|
|
413
454
|
readonly errorLoaders: Map<string, ErrorLoader> = new Map();
|
|
414
455
|
createAppFn: CreateAppFn | null = null;
|
|
415
456
|
rateLimiter: MemoryRateLimiter | null = null;
|
|
457
|
+
/** Kitchen dev dashboard handler (dev mode only) */
|
|
458
|
+
kitchen: KitchenHandler | null = null;
|
|
459
|
+
/** 라우트별 캐시 옵션 (filling.loader()의 cacheOptions에서 등록) */
|
|
460
|
+
readonly cacheOptions: Map<string, { revalidate?: number; tags?: string[] }> = new Map();
|
|
461
|
+
/** 라우트별 렌더 모드 */
|
|
462
|
+
readonly renderModes: Map<string, RenderMode> = new Map();
|
|
463
|
+
/** Layout slot 파일 경로 캐시 (모듈 경로 → slot 경로 | null) */
|
|
464
|
+
readonly layoutSlotPaths: Map<string, string | null> = new Map();
|
|
465
|
+
/** WebSocket 핸들러 (라우트 ID → WSHandlers) */
|
|
466
|
+
readonly wsHandlers: Map<string, import("../filling/ws").WSHandlers> = new Map();
|
|
416
467
|
settings: ServerRegistrySettings = {
|
|
417
468
|
isDev: false,
|
|
418
469
|
rootDir: process.cwd(),
|
|
@@ -630,6 +681,10 @@ export function registerErrorLoader(modulePath: string, loader: ErrorLoader): vo
|
|
|
630
681
|
defaultRegistry.registerErrorLoader(modulePath, loader);
|
|
631
682
|
}
|
|
632
683
|
|
|
684
|
+
export function registerWSHandler(routeId: string, handlers: import("../filling/ws").WSHandlers): void {
|
|
685
|
+
defaultRegistry.wsHandlers.set(routeId, handlers);
|
|
686
|
+
}
|
|
687
|
+
|
|
633
688
|
/**
|
|
634
689
|
* 레이아웃 체인으로 컨텐츠 래핑
|
|
635
690
|
*
|
|
@@ -643,7 +698,8 @@ async function wrapWithLayouts(
|
|
|
643
698
|
content: React.ReactElement,
|
|
644
699
|
layoutChain: string[],
|
|
645
700
|
registry: ServerRegistry,
|
|
646
|
-
params: Record<string, string
|
|
701
|
+
params: Record<string, string>,
|
|
702
|
+
layoutData?: Map<string, unknown>
|
|
647
703
|
): Promise<React.ReactElement> {
|
|
648
704
|
if (!layoutChain || layoutChain.length === 0) {
|
|
649
705
|
return content;
|
|
@@ -659,7 +715,16 @@ async function wrapWithLayouts(
|
|
|
659
715
|
for (let i = layouts.length - 1; i >= 0; i--) {
|
|
660
716
|
const Layout = layouts[i];
|
|
661
717
|
if (Layout) {
|
|
662
|
-
|
|
718
|
+
// layout별 loader 데이터가 있으면 props로 전달
|
|
719
|
+
const data = layoutData?.get(layoutChain[i]);
|
|
720
|
+
const baseProps = { params, children: wrapped };
|
|
721
|
+
if (data && typeof data === "object") {
|
|
722
|
+
// data에서 children/params 키 제거 → 구조적 props 보호
|
|
723
|
+
const { children: _, params: __, ...safeData } = data as Record<string, unknown>;
|
|
724
|
+
wrapped = React.createElement(Layout as React.ComponentType<Record<string, unknown>>, { ...safeData, ...baseProps });
|
|
725
|
+
} else {
|
|
726
|
+
wrapped = React.createElement(Layout, baseProps);
|
|
727
|
+
}
|
|
663
728
|
}
|
|
664
729
|
}
|
|
665
730
|
|
|
@@ -687,6 +752,24 @@ function createDefaultAppFactory(registry: ServerRegistry) {
|
|
|
687
752
|
|
|
688
753
|
// ========== Static File Serving ==========
|
|
689
754
|
|
|
755
|
+
interface StaticFileResult {
|
|
756
|
+
handled: boolean;
|
|
757
|
+
response?: Response;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const INTERNAL_CACHE_ENDPOINT = "/_mandu/cache";
|
|
761
|
+
|
|
762
|
+
function createStaticErrorResponse(status: 400 | 403 | 404 | 500): Response {
|
|
763
|
+
const body = {
|
|
764
|
+
400: "Bad Request",
|
|
765
|
+
403: "Forbidden",
|
|
766
|
+
404: "Not Found",
|
|
767
|
+
500: "Internal Server Error",
|
|
768
|
+
}[status];
|
|
769
|
+
|
|
770
|
+
return new Response(body, { status });
|
|
771
|
+
}
|
|
772
|
+
|
|
690
773
|
/**
|
|
691
774
|
* 경로가 허용된 디렉토리 내에 있는지 검증
|
|
692
775
|
* Path traversal 공격 방지
|
|
@@ -728,17 +811,12 @@ async function isPathSafe(filePath: string, allowedDir: string): Promise<boolean
|
|
|
728
811
|
*
|
|
729
812
|
* 보안: Path traversal 공격 방지를 위해 모든 경로를 검증합니다.
|
|
730
813
|
*/
|
|
731
|
-
async function serveStaticFile(pathname: string, settings: ServerRegistrySettings): Promise<
|
|
814
|
+
async function serveStaticFile(pathname: string, settings: ServerRegistrySettings, request?: Request): Promise<StaticFileResult> {
|
|
732
815
|
let filePath: string | null = null;
|
|
733
816
|
let isBundleFile = false;
|
|
734
817
|
let allowedBaseDir: string;
|
|
735
818
|
let relativePath: string;
|
|
736
819
|
|
|
737
|
-
// Path traversal 시도 조기 차단 (정규화 전 raw 체크)
|
|
738
|
-
if (pathname.includes("..")) {
|
|
739
|
-
return null;
|
|
740
|
-
}
|
|
741
|
-
|
|
742
820
|
// 1. 클라이언트 번들 파일 (/.mandu/client/*)
|
|
743
821
|
if (pathname.startsWith("/.mandu/client/")) {
|
|
744
822
|
// pathname에서 prefix 제거 후 안전하게 조합
|
|
@@ -762,7 +840,7 @@ async function serveStaticFile(pathname: string, settings: ServerRegistrySetting
|
|
|
762
840
|
relativePath = path.basename(pathname);
|
|
763
841
|
allowedBaseDir = path.join(settings.rootDir, settings.publicDir);
|
|
764
842
|
} else {
|
|
765
|
-
return
|
|
843
|
+
return { handled: false }; // 정적 파일이 아님
|
|
766
844
|
}
|
|
767
845
|
|
|
768
846
|
// URL 디코딩 (실패 시 차단)
|
|
@@ -770,30 +848,29 @@ async function serveStaticFile(pathname: string, settings: ServerRegistrySetting
|
|
|
770
848
|
try {
|
|
771
849
|
decodedPath = decodeURIComponent(relativePath);
|
|
772
850
|
} catch {
|
|
773
|
-
return
|
|
851
|
+
return { handled: true, response: createStaticErrorResponse(400) };
|
|
774
852
|
}
|
|
775
853
|
|
|
776
854
|
// 정규화 + Null byte 방지
|
|
777
855
|
const normalizedPath = path.posix.normalize(decodedPath);
|
|
778
856
|
if (normalizedPath.includes("\0")) {
|
|
779
857
|
console.warn(`[Mandu Security] Null byte attack detected: ${pathname}`);
|
|
780
|
-
return
|
|
858
|
+
return { handled: true, response: createStaticErrorResponse(400) };
|
|
781
859
|
}
|
|
782
860
|
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
// 상대 경로 탈출 차단
|
|
787
|
-
if (safeRelativePath.startsWith("..")) {
|
|
788
|
-
return null;
|
|
861
|
+
const normalizedSegments = normalizedPath.split("/");
|
|
862
|
+
if (normalizedSegments.some((segment) => segment === "..")) {
|
|
863
|
+
return { handled: true, response: createStaticErrorResponse(403) };
|
|
789
864
|
}
|
|
790
865
|
|
|
866
|
+
// 선행 슬래시 제거 → path.join이 base를 무시하지 않도록 보장
|
|
867
|
+
const safeRelativePath = normalizedPath.replace(/^\/+/, "");
|
|
791
868
|
filePath = path.join(allowedBaseDir, safeRelativePath);
|
|
792
869
|
|
|
793
870
|
// 최종 경로 검증: 허용된 디렉토리 내에 있는지 확인
|
|
794
871
|
if (!(await isPathSafe(filePath, allowedBaseDir!))) {
|
|
795
872
|
console.warn(`[Mandu Security] Path traversal attempt blocked: ${pathname}`);
|
|
796
|
-
return
|
|
873
|
+
return { handled: true, response: createStaticErrorResponse(403) };
|
|
797
874
|
}
|
|
798
875
|
|
|
799
876
|
try {
|
|
@@ -801,7 +878,7 @@ async function serveStaticFile(pathname: string, settings: ServerRegistrySetting
|
|
|
801
878
|
const exists = await file.exists();
|
|
802
879
|
|
|
803
880
|
if (!exists) {
|
|
804
|
-
return
|
|
881
|
+
return { handled: true, response: createStaticErrorResponse(404) };
|
|
805
882
|
}
|
|
806
883
|
|
|
807
884
|
const mimeType = getMimeType(filePath);
|
|
@@ -819,20 +896,124 @@ async function serveStaticFile(pathname: string, settings: ServerRegistrySetting
|
|
|
819
896
|
cacheControl = "public, max-age=86400";
|
|
820
897
|
}
|
|
821
898
|
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
899
|
+
// ETag: weak validator (파일 크기 + 최종 수정 시간)
|
|
900
|
+
const etag = `W/"${file.size.toString(36)}-${file.lastModified.toString(36)}"`;
|
|
901
|
+
|
|
902
|
+
// 304 Not Modified — 불필요한 전송 방지
|
|
903
|
+
const ifNoneMatch = request?.headers.get("If-None-Match");
|
|
904
|
+
if (ifNoneMatch === etag) {
|
|
905
|
+
return {
|
|
906
|
+
handled: true,
|
|
907
|
+
response: new Response(null, {
|
|
908
|
+
status: 304,
|
|
909
|
+
headers: { "ETag": etag, "Cache-Control": cacheControl },
|
|
910
|
+
}),
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
return {
|
|
915
|
+
handled: true,
|
|
916
|
+
response: new Response(file, {
|
|
917
|
+
headers: {
|
|
918
|
+
"Content-Type": mimeType,
|
|
919
|
+
"Cache-Control": cacheControl,
|
|
920
|
+
"ETag": etag,
|
|
921
|
+
},
|
|
922
|
+
}),
|
|
923
|
+
};
|
|
828
924
|
} catch {
|
|
829
|
-
return
|
|
925
|
+
return { handled: true, response: createStaticErrorResponse(500) };
|
|
830
926
|
}
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
// ========== Request Handler ==========
|
|
834
|
-
|
|
835
|
-
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// ========== Request Handler ==========
|
|
930
|
+
|
|
931
|
+
function unauthorizedControlResponse(): Response {
|
|
932
|
+
return Response.json({ error: "Unauthorized runtime control request" }, { status: 401 });
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function resolveInternalCacheTarget(payload: Record<string, unknown>): string {
|
|
936
|
+
if (typeof payload.path === "string" && payload.path.length > 0) {
|
|
937
|
+
return `path=${payload.path}`;
|
|
938
|
+
}
|
|
939
|
+
if (typeof payload.tag === "string" && payload.tag.length > 0) {
|
|
940
|
+
return `tag=${payload.tag}`;
|
|
941
|
+
}
|
|
942
|
+
if (payload.all === true) {
|
|
943
|
+
return "all";
|
|
944
|
+
}
|
|
945
|
+
return "unknown";
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
async function handleInternalCacheControlRequest(
|
|
949
|
+
req: Request,
|
|
950
|
+
settings: ServerRegistrySettings
|
|
951
|
+
): Promise<Response> {
|
|
952
|
+
const expectedToken = settings.managementToken;
|
|
953
|
+
const providedToken = req.headers.get("x-mandu-control-token");
|
|
954
|
+
|
|
955
|
+
if (!expectedToken || providedToken !== expectedToken) {
|
|
956
|
+
return unauthorizedControlResponse();
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
const store = settings.cacheStore ?? null;
|
|
960
|
+
if (!store) {
|
|
961
|
+
return Response.json({
|
|
962
|
+
enabled: false,
|
|
963
|
+
message: "Runtime cache is disabled for this server instance.",
|
|
964
|
+
stats: null,
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
if (req.method === "GET") {
|
|
969
|
+
const stats = getCacheStoreStats(store);
|
|
970
|
+
return Response.json({
|
|
971
|
+
enabled: true,
|
|
972
|
+
message: "Runtime cache is available.",
|
|
973
|
+
stats,
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
if (req.method === "POST" || req.method === "DELETE") {
|
|
978
|
+
let payload: Record<string, unknown> = {};
|
|
979
|
+
if (req.method === "POST") {
|
|
980
|
+
try {
|
|
981
|
+
payload = await req.json() as Record<string, unknown>;
|
|
982
|
+
} catch {
|
|
983
|
+
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
|
984
|
+
}
|
|
985
|
+
} else {
|
|
986
|
+
payload = { all: true };
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const before = store.size;
|
|
990
|
+
if (typeof payload.path === "string" && payload.path.length > 0) {
|
|
991
|
+
store.deleteByPath(payload.path);
|
|
992
|
+
} else if (typeof payload.tag === "string" && payload.tag.length > 0) {
|
|
993
|
+
store.deleteByTag(payload.tag);
|
|
994
|
+
} else if (payload.all === true) {
|
|
995
|
+
store.clear();
|
|
996
|
+
} else {
|
|
997
|
+
return Response.json({
|
|
998
|
+
error: "Provide one of: { path }, { tag }, or { all: true }",
|
|
999
|
+
}, { status: 400 });
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
const after = store.size;
|
|
1003
|
+
const stats: CacheStoreStats | null = getCacheStoreStats(store);
|
|
1004
|
+
|
|
1005
|
+
return Response.json({
|
|
1006
|
+
enabled: true,
|
|
1007
|
+
cleared: Math.max(0, before - after),
|
|
1008
|
+
target: resolveInternalCacheTarget(payload),
|
|
1009
|
+
stats,
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
async function handleRequest(req: Request, router: Router, registry: ServerRegistry): Promise<Response> {
|
|
836
1017
|
const result = await handleRequestInternal(req, router, registry);
|
|
837
1018
|
|
|
838
1019
|
if (!result.ok) {
|
|
@@ -872,6 +1053,9 @@ async function handleApiRoute(
|
|
|
872
1053
|
|
|
873
1054
|
interface PageLoadResult {
|
|
874
1055
|
loaderData: unknown;
|
|
1056
|
+
cookies?: CookieManager;
|
|
1057
|
+
/** Layout별 loader 데이터 (모듈 경로 → 데이터) */
|
|
1058
|
+
layoutData?: Map<string, unknown>;
|
|
875
1059
|
}
|
|
876
1060
|
|
|
877
1061
|
/**
|
|
@@ -879,7 +1063,7 @@ interface PageLoadResult {
|
|
|
879
1063
|
*/
|
|
880
1064
|
async function loadPageData(
|
|
881
1065
|
req: Request,
|
|
882
|
-
route: { id: string; pattern: string },
|
|
1066
|
+
route: { id: string; pattern: string; layoutChain?: string[] },
|
|
883
1067
|
params: Record<string, string>,
|
|
884
1068
|
registry: ServerRegistry
|
|
885
1069
|
): Promise<Result<PageLoadResult>> {
|
|
@@ -888,15 +1072,17 @@ async function loadPageData(
|
|
|
888
1072
|
// 1. PageHandler 방식 (신규 - filling 포함)
|
|
889
1073
|
const pageHandler = registry.pageHandlers.get(route.id);
|
|
890
1074
|
if (pageHandler) {
|
|
1075
|
+
let cookies: CookieManager | undefined;
|
|
891
1076
|
try {
|
|
892
|
-
const registration = await pageHandler
|
|
893
|
-
const component = registration.component as RouteComponent;
|
|
894
|
-
registry.registerRouteComponent(route.id, component);
|
|
1077
|
+
const registration = await ensurePageRouteMetadata(route.id, registry, pageHandler);
|
|
895
1078
|
|
|
896
1079
|
// Filling의 loader 실행
|
|
897
1080
|
if (registration.filling?.hasLoader()) {
|
|
898
1081
|
const ctx = new ManduContext(req, params);
|
|
899
1082
|
loaderData = await registration.filling.executeLoader(ctx);
|
|
1083
|
+
if (ctx.cookies.hasPendingCookies()) {
|
|
1084
|
+
cookies = ctx.cookies;
|
|
1085
|
+
}
|
|
900
1086
|
}
|
|
901
1087
|
} catch (error) {
|
|
902
1088
|
const pageError = createPageLoadErrorResponse(
|
|
@@ -908,7 +1094,7 @@ async function loadPageData(
|
|
|
908
1094
|
return err(pageError);
|
|
909
1095
|
}
|
|
910
1096
|
|
|
911
|
-
return ok({ loaderData });
|
|
1097
|
+
return ok({ loaderData, cookies });
|
|
912
1098
|
}
|
|
913
1099
|
|
|
914
1100
|
// 2. PageLoader 방식 (레거시 호환)
|
|
@@ -923,12 +1109,21 @@ async function loadPageData(
|
|
|
923
1109
|
: (exportedObj?.component ?? exported);
|
|
924
1110
|
registry.registerRouteComponent(route.id, component as RouteComponent);
|
|
925
1111
|
|
|
926
|
-
// filling이 있으면 loader 실행
|
|
1112
|
+
// filling이 있으면 캐시 옵션 등록 + loader 실행
|
|
1113
|
+
let cookies: CookieManager | undefined;
|
|
927
1114
|
const filling = typeof exported === "object" && exported !== null ? (exportedObj as Record<string, unknown>)?.filling as ManduFilling | null : null;
|
|
1115
|
+
if (filling?.getCacheOptions?.()) {
|
|
1116
|
+
registry.cacheOptions.set(route.id, filling.getCacheOptions()!);
|
|
1117
|
+
}
|
|
928
1118
|
if (filling?.hasLoader?.()) {
|
|
929
1119
|
const ctx = new ManduContext(req, params);
|
|
930
1120
|
loaderData = await filling.executeLoader(ctx);
|
|
1121
|
+
if (ctx.cookies.hasPendingCookies()) {
|
|
1122
|
+
cookies = ctx.cookies;
|
|
1123
|
+
}
|
|
931
1124
|
}
|
|
1125
|
+
|
|
1126
|
+
return ok({ loaderData, cookies });
|
|
932
1127
|
} catch (error) {
|
|
933
1128
|
const pageError = createPageLoadErrorResponse(
|
|
934
1129
|
route.id,
|
|
@@ -943,17 +1138,103 @@ async function loadPageData(
|
|
|
943
1138
|
return ok({ loaderData });
|
|
944
1139
|
}
|
|
945
1140
|
|
|
1141
|
+
/**
|
|
1142
|
+
* Layout chain의 모든 loader를 병렬 실행
|
|
1143
|
+
* 각 layout.slot.ts가 있으면 해당 데이터를 layout props로 전달
|
|
1144
|
+
*/
|
|
1145
|
+
async function loadLayoutData(
|
|
1146
|
+
req: Request,
|
|
1147
|
+
layoutChain: string[] | undefined,
|
|
1148
|
+
params: Record<string, string>,
|
|
1149
|
+
registry: ServerRegistry
|
|
1150
|
+
): Promise<Map<string, unknown>> {
|
|
1151
|
+
const layoutData = new Map<string, unknown>();
|
|
1152
|
+
if (!layoutChain || layoutChain.length === 0) return layoutData;
|
|
1153
|
+
|
|
1154
|
+
// layout.slot.ts 파일 검색: layout 모듈 경로에서 .slot.ts 파일 경로 유도
|
|
1155
|
+
// 예: app/layout.tsx → spec/slots/layout.slot.ts (auto-link 규칙)
|
|
1156
|
+
// 또는 직접 등록된 layout loader에서 filling 추출
|
|
1157
|
+
|
|
1158
|
+
const loaderEntries: { modulePath: string; slotPath: string }[] = [];
|
|
1159
|
+
for (const modulePath of layoutChain) {
|
|
1160
|
+
// 캐시된 결과 확인
|
|
1161
|
+
if (registry.layoutSlotPaths.has(modulePath)) {
|
|
1162
|
+
const cached = registry.layoutSlotPaths.get(modulePath);
|
|
1163
|
+
if (cached) loaderEntries.push({ modulePath, slotPath: cached });
|
|
1164
|
+
continue;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// layout.tsx → layout 이름 추출 → 같은 디렉토리에서 .slot.ts 검색
|
|
1168
|
+
const layoutName = path.basename(modulePath, path.extname(modulePath));
|
|
1169
|
+
const slotCandidates = [
|
|
1170
|
+
path.join(path.dirname(modulePath), `${layoutName}.slot.ts`),
|
|
1171
|
+
path.join(path.dirname(modulePath), `${layoutName}.slot.tsx`),
|
|
1172
|
+
];
|
|
1173
|
+
let found = false;
|
|
1174
|
+
for (const slotPath of slotCandidates) {
|
|
1175
|
+
try {
|
|
1176
|
+
const fullPath = path.join(registry.settings.rootDir, slotPath);
|
|
1177
|
+
const file = Bun.file(fullPath);
|
|
1178
|
+
if (await file.exists()) {
|
|
1179
|
+
registry.layoutSlotPaths.set(modulePath, fullPath);
|
|
1180
|
+
loaderEntries.push({ modulePath, slotPath: fullPath });
|
|
1181
|
+
found = true;
|
|
1182
|
+
break;
|
|
1183
|
+
}
|
|
1184
|
+
} catch {
|
|
1185
|
+
// 파일 없으면 스킵
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
if (!found) {
|
|
1189
|
+
registry.layoutSlotPaths.set(modulePath, null); // 없음 캐시
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
if (loaderEntries.length === 0) return layoutData;
|
|
1194
|
+
|
|
1195
|
+
const results = await Promise.all(
|
|
1196
|
+
loaderEntries.map(async ({ modulePath, slotPath }) => {
|
|
1197
|
+
try {
|
|
1198
|
+
const module = await import(slotPath);
|
|
1199
|
+
const exported = module.default;
|
|
1200
|
+
// layout.slot.ts가 ManduFilling이면 loader 실행
|
|
1201
|
+
if (exported && typeof exported === "object" && "executeLoader" in exported) {
|
|
1202
|
+
const filling = exported as ManduFilling;
|
|
1203
|
+
if (filling.hasLoader()) {
|
|
1204
|
+
const ctx = new ManduContext(req, params);
|
|
1205
|
+
const data = await filling.executeLoader(ctx);
|
|
1206
|
+
return { modulePath, data };
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
} catch (error) {
|
|
1210
|
+
console.warn(`[Mandu] Layout loader failed for ${modulePath}:`, error);
|
|
1211
|
+
}
|
|
1212
|
+
return { modulePath, data: undefined };
|
|
1213
|
+
})
|
|
1214
|
+
);
|
|
1215
|
+
|
|
1216
|
+
for (const { modulePath, data } of results) {
|
|
1217
|
+
if (data !== undefined) {
|
|
1218
|
+
layoutData.set(modulePath, data);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
return layoutData;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
946
1225
|
// ---------- SSR Renderer ----------
|
|
947
1226
|
|
|
948
1227
|
/**
|
|
949
1228
|
* SSR 렌더링 (Streaming/Non-streaming)
|
|
950
1229
|
*/
|
|
951
1230
|
async function renderPageSSR(
|
|
952
|
-
route: { id: string; pattern: string; layoutChain?: string[]; streaming?: boolean; hydration?: HydrationConfig },
|
|
1231
|
+
route: { id: string; pattern: string; layoutChain?: string[]; streaming?: boolean; hydration?: HydrationConfig; errorModule?: string },
|
|
953
1232
|
params: Record<string, string>,
|
|
954
1233
|
loaderData: unknown,
|
|
955
1234
|
url: string,
|
|
956
|
-
registry: ServerRegistry
|
|
1235
|
+
registry: ServerRegistry,
|
|
1236
|
+
cookies?: CookieManager,
|
|
1237
|
+
layoutData?: Map<string, unknown>
|
|
957
1238
|
): Promise<Result<Response>> {
|
|
958
1239
|
const settings = registry.settings;
|
|
959
1240
|
const defaultAppCreator = createDefaultAppFactory(registry);
|
|
@@ -967,9 +1248,28 @@ async function renderPageSSR(
|
|
|
967
1248
|
loaderData,
|
|
968
1249
|
});
|
|
969
1250
|
|
|
970
|
-
// 레이아웃
|
|
1251
|
+
// Island 래핑: 레이아웃 적용 전에 페이지 콘텐츠만 island div로 감쌈
|
|
1252
|
+
// 이렇게 하면 레이아웃은 island 바깥에 위치하여 하이드레이션 시 레이아웃이 유지됨
|
|
1253
|
+
const needsIslandWrap =
|
|
1254
|
+
route.hydration &&
|
|
1255
|
+
route.hydration.strategy !== "none" &&
|
|
1256
|
+
settings.bundleManifest;
|
|
1257
|
+
|
|
1258
|
+
if (needsIslandWrap) {
|
|
1259
|
+
const bundle = settings.bundleManifest?.bundles[route.id];
|
|
1260
|
+
const bundleSrc = bundle?.js ? `${bundle.js}?t=${Date.now()}` : "";
|
|
1261
|
+
const priority = route.hydration!.priority || "visible";
|
|
1262
|
+
app = React.createElement("div", {
|
|
1263
|
+
"data-mandu-island": route.id,
|
|
1264
|
+
"data-mandu-src": bundleSrc,
|
|
1265
|
+
"data-mandu-priority": priority,
|
|
1266
|
+
style: { display: "contents" },
|
|
1267
|
+
}, app);
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// 레이아웃 체인 적용 (island 래핑 후 → 레이아웃은 island 바깥)
|
|
971
1271
|
if (route.layoutChain && route.layoutChain.length > 0) {
|
|
972
|
-
app = await wrapWithLayouts(app, route.layoutChain, registry, params);
|
|
1272
|
+
app = await wrapWithLayouts(app, route.layoutChain, registry, params, layoutData);
|
|
973
1273
|
}
|
|
974
1274
|
|
|
975
1275
|
const serverData = loaderData
|
|
@@ -982,7 +1282,7 @@ async function renderPageSSR(
|
|
|
982
1282
|
: settings.streaming;
|
|
983
1283
|
|
|
984
1284
|
if (useStreaming) {
|
|
985
|
-
|
|
1285
|
+
const streamingResponse = await renderStreamingResponse(app, {
|
|
986
1286
|
title: `${route.id} - Mandu`,
|
|
987
1287
|
isDev: settings.isDev,
|
|
988
1288
|
hmrPort: settings.hmrPort,
|
|
@@ -1007,11 +1307,15 @@ async function renderPageSSR(
|
|
|
1007
1307
|
});
|
|
1008
1308
|
}
|
|
1009
1309
|
},
|
|
1010
|
-
})
|
|
1310
|
+
});
|
|
1311
|
+
return ok(cookies ? cookies.applyToResponse(streamingResponse) : streamingResponse);
|
|
1011
1312
|
}
|
|
1012
1313
|
|
|
1013
1314
|
// 기존 renderToString 방식
|
|
1014
|
-
|
|
1315
|
+
// Note: hydration 래핑은 위에서 React 엘리먼트 레벨로 이미 처리됨
|
|
1316
|
+
// renderToHTML에서 중복 래핑하지 않도록 hydration을 전달하되 strategy를 "none"으로 설정
|
|
1317
|
+
// 단, hydration 스크립트(importmap, runtime 등)는 여전히 필요하므로 bundleManifest는 유지
|
|
1318
|
+
const ssrResponse = renderSSR(app, {
|
|
1015
1319
|
title: `${route.id} - Mandu`,
|
|
1016
1320
|
isDev: settings.isDev,
|
|
1017
1321
|
hmrPort: settings.hmrPort,
|
|
@@ -1022,12 +1326,46 @@ async function renderPageSSR(
|
|
|
1022
1326
|
enableClientRouter: true,
|
|
1023
1327
|
routePattern: route.pattern,
|
|
1024
1328
|
cssPath: settings.cssPath,
|
|
1025
|
-
|
|
1329
|
+
islandPreWrapped: !!needsIslandWrap,
|
|
1330
|
+
});
|
|
1331
|
+
return ok(cookies ? cookies.applyToResponse(ssrResponse) : ssrResponse);
|
|
1026
1332
|
} catch (error) {
|
|
1333
|
+
const renderError = error instanceof Error ? error : new Error(String(error));
|
|
1334
|
+
|
|
1335
|
+
// Route-level ErrorBoundary: errorModule이 있으면 해당 컴포넌트로 에러 렌더링
|
|
1336
|
+
if (route.errorModule) {
|
|
1337
|
+
try {
|
|
1338
|
+
const errorMod = await import(path.join(settings.rootDir, route.errorModule));
|
|
1339
|
+
const ErrorComponent = errorMod.default as React.ComponentType<ErrorFallbackProps>;
|
|
1340
|
+
if (ErrorComponent) {
|
|
1341
|
+
const errorElement = React.createElement(ErrorComponent, {
|
|
1342
|
+
error: renderError,
|
|
1343
|
+
errorInfo: undefined,
|
|
1344
|
+
resetError: () => {}, // SSR에서는 noop — 클라이언트 hydration 시 실제 동작
|
|
1345
|
+
});
|
|
1346
|
+
|
|
1347
|
+
// 레이아웃은 유지하면서 에러 컴포넌트만 교체
|
|
1348
|
+
let errorApp: React.ReactElement = errorElement;
|
|
1349
|
+
if (route.layoutChain && route.layoutChain.length > 0) {
|
|
1350
|
+
errorApp = await wrapWithLayouts(errorApp, route.layoutChain, registry, params, layoutData);
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
const errorHtml = renderSSR(errorApp, {
|
|
1354
|
+
title: `Error - ${route.id}`,
|
|
1355
|
+
isDev: settings.isDev,
|
|
1356
|
+
cssPath: settings.cssPath,
|
|
1357
|
+
});
|
|
1358
|
+
return ok(cookies ? cookies.applyToResponse(errorHtml) : errorHtml);
|
|
1359
|
+
}
|
|
1360
|
+
} catch (errorBoundaryError) {
|
|
1361
|
+
console.error(`[Mandu] Error boundary failed for ${route.id}:`, errorBoundaryError);
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1027
1365
|
const ssrError = createSSRErrorResponse(
|
|
1028
1366
|
route.id,
|
|
1029
1367
|
route.pattern,
|
|
1030
|
-
|
|
1368
|
+
renderError
|
|
1031
1369
|
);
|
|
1032
1370
|
console.error(`[Mandu] ${ssrError.errorType}:`, ssrError.message);
|
|
1033
1371
|
return err(ssrError);
|
|
@@ -1036,6 +1374,9 @@ async function renderPageSSR(
|
|
|
1036
1374
|
|
|
1037
1375
|
// ---------- Page Route Handler ----------
|
|
1038
1376
|
|
|
1377
|
+
/** SWR 백그라운드 재생성 중복 방지 */
|
|
1378
|
+
const pendingRevalidations = new Set<string>();
|
|
1379
|
+
|
|
1039
1380
|
/**
|
|
1040
1381
|
* 페이지 라우트 처리
|
|
1041
1382
|
*/
|
|
@@ -1046,27 +1387,191 @@ async function handlePageRoute(
|
|
|
1046
1387
|
params: Record<string, string>,
|
|
1047
1388
|
registry: ServerRegistry
|
|
1048
1389
|
): Promise<Result<Response>> {
|
|
1049
|
-
|
|
1050
|
-
const
|
|
1390
|
+
const settings = registry.settings;
|
|
1391
|
+
const cache = settings.cacheStore;
|
|
1392
|
+
// Only call ensurePageRouteMetadata when a pageHandler exists;
|
|
1393
|
+
// routes registered via registerPageLoader are handled by loadPageData instead.
|
|
1394
|
+
if (registry.pageHandlers.has(route.id)) {
|
|
1395
|
+
await ensurePageRouteMetadata(route.id, registry);
|
|
1396
|
+
}
|
|
1397
|
+
const renderMode = getRenderModeForRoute(route.id, registry);
|
|
1398
|
+
|
|
1399
|
+
// _data 요청 (SPA 네비게이션)은 캐시하지 않음
|
|
1400
|
+
const isDataRequest = url.searchParams.has("_data");
|
|
1401
|
+
|
|
1402
|
+
// ISR/SWR 캐시 확인 (SSR 렌더링 요청에만 적용)
|
|
1403
|
+
if (cache && !isDataRequest && renderMode !== "dynamic") {
|
|
1404
|
+
const cacheKey = buildRouteCacheKey(route.id, url);
|
|
1405
|
+
const lookup = lookupCache(cache, cacheKey);
|
|
1406
|
+
|
|
1407
|
+
if (lookup.status === "HIT" && lookup.entry) {
|
|
1408
|
+
return ok(createCachedResponse(lookup.entry, "HIT"));
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
if (lookup.status === "STALE" && lookup.entry) {
|
|
1412
|
+
// Stale-While-Revalidate: 이전 캐시 즉시 반환 + 백그라운드 재생성
|
|
1413
|
+
// 중복 재생성 방지: 이미 진행 중이면 스킵
|
|
1414
|
+
if (!pendingRevalidations.has(cacheKey)) {
|
|
1415
|
+
pendingRevalidations.add(cacheKey);
|
|
1416
|
+
queueMicrotask(async () => {
|
|
1417
|
+
try {
|
|
1418
|
+
await regenerateCache(req, url, route, params, registry, cache, cacheKey);
|
|
1419
|
+
} catch (error) {
|
|
1420
|
+
console.warn(`[Mandu Cache] Background revalidation failed for ${cacheKey}:`, error);
|
|
1421
|
+
} finally {
|
|
1422
|
+
pendingRevalidations.delete(cacheKey);
|
|
1423
|
+
}
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
return ok(createCachedResponse(lookup.entry, "STALE"));
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// 1. 페이지 + 레이아웃 데이터 병렬 로딩
|
|
1431
|
+
const [loadResult, layoutData] = await Promise.all([
|
|
1432
|
+
loadPageData(req, route, params, registry),
|
|
1433
|
+
loadLayoutData(req, route.layoutChain, params, registry),
|
|
1434
|
+
]);
|
|
1051
1435
|
if (!loadResult.ok) {
|
|
1052
1436
|
return loadResult;
|
|
1053
1437
|
}
|
|
1054
1438
|
|
|
1055
|
-
const { loaderData } = loadResult.value;
|
|
1439
|
+
const { loaderData, cookies } = loadResult.value;
|
|
1056
1440
|
|
|
1057
1441
|
// 2. Client-side Routing: 데이터만 반환 (JSON)
|
|
1058
|
-
|
|
1059
|
-
|
|
1442
|
+
// 참고: layoutData는 SSR 시에만 사용 — SPA 네비게이션은 전체 페이지 SSR을 받지 않으므로 제외
|
|
1443
|
+
if (isDataRequest) {
|
|
1444
|
+
const jsonResponse = Response.json({
|
|
1060
1445
|
routeId: route.id,
|
|
1061
1446
|
pattern: route.pattern,
|
|
1062
1447
|
params,
|
|
1063
1448
|
loaderData: loaderData ?? null,
|
|
1064
1449
|
timestamp: Date.now(),
|
|
1065
|
-
})
|
|
1450
|
+
});
|
|
1451
|
+
return ok(cookies ? cookies.applyToResponse(jsonResponse) : jsonResponse);
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// 3. SSR 렌더링 (layoutData 전달)
|
|
1455
|
+
const ssrResult = await renderPageSSR(route, params, loaderData, req.url, registry, cookies, layoutData);
|
|
1456
|
+
|
|
1457
|
+
// 4. 캐시 저장 (revalidate 설정이 있는 경우 — non-blocking)
|
|
1458
|
+
if (cache && ssrResult.ok && renderMode !== "dynamic") {
|
|
1459
|
+
const cacheOptions = getCacheOptionsForRoute(route.id, registry);
|
|
1460
|
+
if (cacheOptions?.revalidate && cacheOptions.revalidate > 0) {
|
|
1461
|
+
const cloned = ssrResult.value.clone();
|
|
1462
|
+
const status = ssrResult.value.status;
|
|
1463
|
+
const headers = Object.fromEntries(ssrResult.value.headers.entries());
|
|
1464
|
+
const cacheKey = buildRouteCacheKey(route.id, url);
|
|
1465
|
+
// streaming 응답도 블로킹하지 않도록 백그라운드에서 캐시 저장
|
|
1466
|
+
cloned.text().then((html) => {
|
|
1467
|
+
cache.set(cacheKey, createCacheEntry(
|
|
1468
|
+
html, loaderData, cacheOptions.revalidate!, cacheOptions.tags ?? [], status, headers
|
|
1469
|
+
));
|
|
1470
|
+
}).catch(() => {});
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
return ssrResult;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
/**
|
|
1478
|
+
* 백그라운드 캐시 재생성 (SWR 패턴)
|
|
1479
|
+
*/
|
|
1480
|
+
async function regenerateCache(
|
|
1481
|
+
req: Request,
|
|
1482
|
+
url: URL,
|
|
1483
|
+
route: { id: string; pattern: string; layoutChain?: string[]; streaming?: boolean; hydration?: HydrationConfig },
|
|
1484
|
+
params: Record<string, string>,
|
|
1485
|
+
registry: ServerRegistry,
|
|
1486
|
+
cache: CacheStore,
|
|
1487
|
+
cacheKey: string
|
|
1488
|
+
): Promise<void> {
|
|
1489
|
+
const [loadResult, layoutData] = await Promise.all([
|
|
1490
|
+
loadPageData(req, route, params, registry),
|
|
1491
|
+
loadLayoutData(req, route.layoutChain, params, registry),
|
|
1492
|
+
]);
|
|
1493
|
+
if (!loadResult.ok) return;
|
|
1494
|
+
|
|
1495
|
+
const { loaderData } = loadResult.value;
|
|
1496
|
+
const ssrResult = await renderPageSSR(route, params, loaderData, req.url, registry, undefined, layoutData);
|
|
1497
|
+
if (!ssrResult.ok) return;
|
|
1498
|
+
|
|
1499
|
+
const cacheOptions = getCacheOptionsForRoute(route.id, registry);
|
|
1500
|
+
if (!cacheOptions?.revalidate) return;
|
|
1501
|
+
|
|
1502
|
+
const html = await ssrResult.value.text();
|
|
1503
|
+
const entry = createCacheEntry(
|
|
1504
|
+
html,
|
|
1505
|
+
loaderData,
|
|
1506
|
+
cacheOptions.revalidate,
|
|
1507
|
+
cacheOptions.tags ?? [],
|
|
1508
|
+
ssrResult.value.status,
|
|
1509
|
+
Object.fromEntries(ssrResult.value.headers.entries())
|
|
1510
|
+
);
|
|
1511
|
+
cache.set(cacheKey, entry);
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
/**
|
|
1515
|
+
* 라우트의 캐시 옵션 가져오기 (pageHandler의 filling에서 추출)
|
|
1516
|
+
*/
|
|
1517
|
+
function getCacheOptionsForRoute(
|
|
1518
|
+
routeId: string,
|
|
1519
|
+
registry: ServerRegistry
|
|
1520
|
+
): { revalidate?: number; tags?: string[] } | null {
|
|
1521
|
+
const pageHandler = registry.pageHandlers.get(routeId);
|
|
1522
|
+
if (!pageHandler) return null;
|
|
1523
|
+
|
|
1524
|
+
// pageHandler는 async () => { component, filling } 형태
|
|
1525
|
+
// filling의 getCacheOptions()를 호출하려면 filling 인스턴스에 접근해야 하지만
|
|
1526
|
+
// pageHandler 실행 없이는 접근 불가 → 등록 시점에 캐시 옵션을 별도 저장
|
|
1527
|
+
return registry.cacheOptions?.get(routeId) ?? null;
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
function getRenderModeForRoute(routeId: string, registry: ServerRegistry): RenderMode {
|
|
1531
|
+
return registry.renderModes.get(routeId) ?? "dynamic";
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
async function ensurePageRouteMetadata(
|
|
1535
|
+
routeId: string,
|
|
1536
|
+
registry: ServerRegistry,
|
|
1537
|
+
pageHandler?: PageHandler
|
|
1538
|
+
): Promise<PageRegistration> {
|
|
1539
|
+
const handler = pageHandler ?? registry.pageHandlers.get(routeId);
|
|
1540
|
+
if (!handler) {
|
|
1541
|
+
throw new Error(`Page handler not found for route: ${routeId}`);
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
const existingComponent = registry.routeComponents.get(routeId);
|
|
1545
|
+
const existingFilling = registry.pageFillings.get(routeId);
|
|
1546
|
+
if (existingComponent && existingFilling) {
|
|
1547
|
+
return { component: existingComponent, filling: existingFilling };
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
const registration = await handler();
|
|
1551
|
+
const component = registration.component as RouteComponent;
|
|
1552
|
+
registry.registerRouteComponent(routeId, component);
|
|
1553
|
+
|
|
1554
|
+
if (registration.filling) {
|
|
1555
|
+
registry.pageFillings.set(routeId, registration.filling);
|
|
1556
|
+
const cacheOptions = registration.filling.getCacheOptions?.();
|
|
1557
|
+
if (cacheOptions) {
|
|
1558
|
+
registry.cacheOptions.set(routeId, cacheOptions);
|
|
1559
|
+
}
|
|
1560
|
+
registry.renderModes.set(routeId, registration.filling.getRenderMode());
|
|
1066
1561
|
}
|
|
1067
1562
|
|
|
1068
|
-
|
|
1069
|
-
|
|
1563
|
+
return registration;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
function buildRouteCacheKey(routeId: string, url: URL): string {
|
|
1567
|
+
const entries = [...url.searchParams.entries()].sort(([aKey, aValue], [bKey, bValue]) => {
|
|
1568
|
+
if (aKey === bKey) {
|
|
1569
|
+
return aValue.localeCompare(bValue);
|
|
1570
|
+
}
|
|
1571
|
+
return aKey.localeCompare(bKey);
|
|
1572
|
+
});
|
|
1573
|
+
const search = entries.length > 0 ? `?${new URLSearchParams(entries).toString()}` : "";
|
|
1574
|
+
return `${routeId}:${url.pathname}${search}`;
|
|
1070
1575
|
}
|
|
1071
1576
|
|
|
1072
1577
|
// ---------- Main Request Dispatcher ----------
|
|
@@ -1090,8 +1595,9 @@ async function handleRequestInternal(
|
|
|
1090
1595
|
}
|
|
1091
1596
|
|
|
1092
1597
|
// 1. 정적 파일 서빙 시도 (최우선)
|
|
1093
|
-
const
|
|
1094
|
-
if (
|
|
1598
|
+
const staticFileResult = await serveStaticFile(pathname, settings, req);
|
|
1599
|
+
if (staticFileResult.handled) {
|
|
1600
|
+
const staticResponse = staticFileResult.response!;
|
|
1095
1601
|
if (settings.cors && isCorsRequest(req)) {
|
|
1096
1602
|
const corsOptions: CorsOptions = typeof settings.cors === 'object' ? settings.cors : {};
|
|
1097
1603
|
return ok(applyCorsToResponse(staticResponse, req, corsOptions));
|
|
@@ -1099,7 +1605,24 @@ async function handleRequestInternal(
|
|
|
1099
1605
|
return ok(staticResponse);
|
|
1100
1606
|
}
|
|
1101
1607
|
|
|
1102
|
-
//
|
|
1608
|
+
// 1.5. Image optimization handler (/_mandu/image)
|
|
1609
|
+
if (pathname === "/_mandu/image") {
|
|
1610
|
+
const imageResponse = await handleImageRequest(req, settings.rootDir, settings.publicDir);
|
|
1611
|
+
if (imageResponse) return ok(imageResponse);
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
// 1.6. Internal runtime cache control endpoint
|
|
1615
|
+
if (pathname === INTERNAL_CACHE_ENDPOINT) {
|
|
1616
|
+
return ok(await handleInternalCacheControlRequest(req, settings));
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
// 2. Kitchen dev dashboard (dev mode only)
|
|
1620
|
+
if (settings.isDev && pathname.startsWith(KITCHEN_PREFIX) && registry.kitchen) {
|
|
1621
|
+
const kitchenResponse = await registry.kitchen.handle(req, pathname);
|
|
1622
|
+
if (kitchenResponse) return ok(kitchenResponse);
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
// 3. 라우트 매칭
|
|
1103
1626
|
const match = router.match(pathname);
|
|
1104
1627
|
if (!match) {
|
|
1105
1628
|
return err(createNotFoundResponse(pathname));
|
|
@@ -1159,19 +1682,18 @@ function isPortInUseError(error: unknown): boolean {
|
|
|
1159
1682
|
function startBunServerWithFallback(options: {
|
|
1160
1683
|
port: number;
|
|
1161
1684
|
hostname?: string;
|
|
1162
|
-
fetch: (req: Request) => Promise<Response>;
|
|
1685
|
+
fetch: (req: Request, server: Server<undefined>) => Promise<Response | undefined>;
|
|
1686
|
+
websocket?: Record<string, unknown>;
|
|
1163
1687
|
}): { server: Server<undefined>; port: number; attempts: number } {
|
|
1164
|
-
const { port: startPort, hostname, fetch } = options;
|
|
1688
|
+
const { port: startPort, hostname, fetch, websocket } = options;
|
|
1165
1689
|
let lastError: unknown = null;
|
|
1166
1690
|
|
|
1691
|
+
const serveOptions: Record<string, unknown> = { hostname, fetch, idleTimeout: 255 };
|
|
1692
|
+
if (websocket) serveOptions.websocket = websocket;
|
|
1693
|
+
|
|
1167
1694
|
// Port 0: let Bun/OS pick an available ephemeral port.
|
|
1168
1695
|
if (startPort === 0) {
|
|
1169
|
-
const server = Bun.serve({
|
|
1170
|
-
port: 0,
|
|
1171
|
-
hostname,
|
|
1172
|
-
fetch,
|
|
1173
|
-
idleTimeout: 255,
|
|
1174
|
-
});
|
|
1696
|
+
const server = Bun.serve({ port: 0, ...serveOptions } as any);
|
|
1175
1697
|
return { server, port: server.port ?? 0, attempts: 0 };
|
|
1176
1698
|
}
|
|
1177
1699
|
|
|
@@ -1181,12 +1703,7 @@ function startBunServerWithFallback(options: {
|
|
|
1181
1703
|
continue;
|
|
1182
1704
|
}
|
|
1183
1705
|
try {
|
|
1184
|
-
const server = Bun.serve({
|
|
1185
|
-
port: candidate,
|
|
1186
|
-
hostname,
|
|
1187
|
-
fetch,
|
|
1188
|
-
idleTimeout: 255,
|
|
1189
|
-
});
|
|
1706
|
+
const server = Bun.serve({ port: candidate, ...serveOptions } as any);
|
|
1190
1707
|
return { server, port: server.port ?? candidate, attempts: attempt };
|
|
1191
1708
|
} catch (error) {
|
|
1192
1709
|
if (!isPortInUseError(error)) {
|
|
@@ -1215,7 +1732,10 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
1215
1732
|
rateLimit = false,
|
|
1216
1733
|
cssPath: cssPathOption,
|
|
1217
1734
|
registry = defaultRegistry,
|
|
1218
|
-
|
|
1735
|
+
guardConfig = null,
|
|
1736
|
+
cache: cacheOption,
|
|
1737
|
+
managementToken,
|
|
1738
|
+
} = options;
|
|
1219
1739
|
|
|
1220
1740
|
// cssPath 처리:
|
|
1221
1741
|
// - string: 해당 경로로 <link> 주입
|
|
@@ -1246,31 +1766,99 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
1246
1766
|
rootDir,
|
|
1247
1767
|
publicDir,
|
|
1248
1768
|
cors: corsOptions,
|
|
1249
|
-
streaming,
|
|
1250
|
-
rateLimit: rateLimitOptions,
|
|
1251
|
-
cssPath,
|
|
1252
|
-
|
|
1769
|
+
streaming,
|
|
1770
|
+
rateLimit: rateLimitOptions,
|
|
1771
|
+
cssPath,
|
|
1772
|
+
managementToken,
|
|
1773
|
+
};
|
|
1253
1774
|
|
|
1254
1775
|
registry.rateLimiter = rateLimitOptions ? new MemoryRateLimiter() : null;
|
|
1255
1776
|
|
|
1777
|
+
// ISR/SWR 캐시 초기화
|
|
1778
|
+
if (cacheOption) {
|
|
1779
|
+
const store = cacheOption === true ? new MemoryCacheStore() : cacheOption;
|
|
1780
|
+
registry.settings.cacheStore = store;
|
|
1781
|
+
setGlobalCache(store); // revalidatePath/revalidateTag API에서 사용
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
// Kitchen dev dashboard (dev mode only)
|
|
1785
|
+
if (isDev) {
|
|
1786
|
+
const kitchen = new KitchenHandler({ rootDir, manifest, guardConfig });
|
|
1787
|
+
kitchen.start();
|
|
1788
|
+
registry.kitchen = kitchen;
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1256
1791
|
const router = new Router(manifest.routes);
|
|
1257
1792
|
|
|
1258
|
-
//
|
|
1259
|
-
|
|
1260
|
-
|
|
1793
|
+
// 글로벌 미들웨어 (middleware.ts) — 동기 로드로 첫 요청부터 보장
|
|
1794
|
+
let middlewareFn: MiddlewareFn | null = null;
|
|
1795
|
+
let middlewareConfig: MiddlewareConfig | null = null;
|
|
1261
1796
|
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1797
|
+
const mwResult = loadMiddlewareSync(rootDir);
|
|
1798
|
+
if (mwResult) {
|
|
1799
|
+
middlewareFn = mwResult.fn;
|
|
1800
|
+
middlewareConfig = mwResult.config;
|
|
1801
|
+
console.log("🔗 Global middleware loaded");
|
|
1802
|
+
}
|
|
1266
1803
|
|
|
1267
|
-
|
|
1268
|
-
|
|
1804
|
+
// Fetch handler: 미들웨어 + CORS + 라우트 디스패치 (런타임 중립 팩토리 사용)
|
|
1805
|
+
const fetchHandler = createFetchHandler({
|
|
1806
|
+
router,
|
|
1807
|
+
registry,
|
|
1808
|
+
corsOptions,
|
|
1809
|
+
middlewareFn,
|
|
1810
|
+
middlewareConfig,
|
|
1811
|
+
handleRequest,
|
|
1812
|
+
});
|
|
1813
|
+
|
|
1814
|
+
// WebSocket 핸들러 빌드 (등록된 WS 라우트가 있을 때만)
|
|
1815
|
+
const hasWsRoutes = registry.wsHandlers.size > 0;
|
|
1816
|
+
const wsConfig = hasWsRoutes ? {
|
|
1817
|
+
open(ws: any) {
|
|
1818
|
+
const data = ws.data as WSUpgradeData;
|
|
1819
|
+
const handlers = registry.wsHandlers.get(data.routeId);
|
|
1820
|
+
handlers?.open?.(wrapBunWebSocket(ws));
|
|
1821
|
+
},
|
|
1822
|
+
message(ws: any, message: string | ArrayBuffer) {
|
|
1823
|
+
const data = ws.data as WSUpgradeData;
|
|
1824
|
+
const handlers = registry.wsHandlers.get(data.routeId);
|
|
1825
|
+
handlers?.message?.(wrapBunWebSocket(ws), message);
|
|
1826
|
+
},
|
|
1827
|
+
close(ws: any, code: number, reason: string) {
|
|
1828
|
+
const data = ws.data as WSUpgradeData;
|
|
1829
|
+
const handlers = registry.wsHandlers.get(data.routeId);
|
|
1830
|
+
handlers?.close?.(wrapBunWebSocket(ws), code, reason);
|
|
1831
|
+
},
|
|
1832
|
+
drain(ws: any) {
|
|
1833
|
+
const data = ws.data as WSUpgradeData;
|
|
1834
|
+
const handlers = registry.wsHandlers.get(data.routeId);
|
|
1835
|
+
handlers?.drain?.(wrapBunWebSocket(ws));
|
|
1836
|
+
},
|
|
1837
|
+
} : undefined;
|
|
1838
|
+
|
|
1839
|
+
// fetch handler: WS upgrade 감지 추가
|
|
1840
|
+
const wrappedFetch = hasWsRoutes
|
|
1841
|
+
? async (req: Request, bunServer: Server<undefined>): Promise<Response | undefined> => {
|
|
1842
|
+
// WebSocket upgrade 요청 감지
|
|
1843
|
+
if (req.headers.get("upgrade") === "websocket") {
|
|
1844
|
+
const url = new URL(req.url);
|
|
1845
|
+
const match = router.match(url.pathname);
|
|
1846
|
+
if (match && registry.wsHandlers.has(match.route.id)) {
|
|
1847
|
+
const upgraded = (bunServer as any).upgrade(req, {
|
|
1848
|
+
data: { routeId: match.route.id, params: match.params, id: crypto.randomUUID() },
|
|
1849
|
+
});
|
|
1850
|
+
return upgraded ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
return fetchHandler(req);
|
|
1854
|
+
}
|
|
1855
|
+
: async (req: Request): Promise<Response> => fetchHandler(req);
|
|
1269
1856
|
|
|
1270
1857
|
const { server, port: actualPort, attempts } = startBunServerWithFallback({
|
|
1271
1858
|
port,
|
|
1272
1859
|
hostname,
|
|
1273
|
-
fetch:
|
|
1860
|
+
fetch: wrappedFetch as any,
|
|
1861
|
+
websocket: wsConfig,
|
|
1274
1862
|
});
|
|
1275
1863
|
|
|
1276
1864
|
if (attempts > 0) {
|
|
@@ -1293,6 +1881,9 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
1293
1881
|
if (streaming) {
|
|
1294
1882
|
console.log(`🌊 Streaming SSR enabled`);
|
|
1295
1883
|
}
|
|
1884
|
+
if (registry.kitchen) {
|
|
1885
|
+
console.log(`🍳 Kitchen dashboard at http://${hostname}:${actualPort}/__kitchen`);
|
|
1886
|
+
}
|
|
1296
1887
|
} else {
|
|
1297
1888
|
console.log(`🥟 Mandu server running at http://${hostname}:${actualPort}`);
|
|
1298
1889
|
if (streaming) {
|
|
@@ -1304,7 +1895,10 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
1304
1895
|
server,
|
|
1305
1896
|
router,
|
|
1306
1897
|
registry,
|
|
1307
|
-
stop: () =>
|
|
1898
|
+
stop: () => {
|
|
1899
|
+
registry.kitchen?.stop();
|
|
1900
|
+
server.stop();
|
|
1901
|
+
},
|
|
1308
1902
|
};
|
|
1309
1903
|
}
|
|
1310
1904
|
|