@mandujs/core 0.19.0 → 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.
Files changed (90) hide show
  1. package/README.ko.md +0 -14
  2. package/package.json +4 -1
  3. package/src/brain/architecture/analyzer.ts +4 -4
  4. package/src/brain/doctor/analyzer.ts +18 -14
  5. package/src/bundler/build.test.ts +127 -0
  6. package/src/bundler/build.ts +291 -113
  7. package/src/bundler/css.ts +20 -5
  8. package/src/bundler/dev.ts +55 -2
  9. package/src/bundler/prerender.ts +195 -0
  10. package/src/change/snapshot.ts +4 -23
  11. package/src/change/types.ts +2 -3
  12. package/src/client/Form.tsx +105 -0
  13. package/src/client/__tests__/use-sse.test.ts +153 -0
  14. package/src/client/hooks.ts +105 -6
  15. package/src/client/index.ts +35 -6
  16. package/src/client/router.ts +670 -433
  17. package/src/client/rpc.ts +140 -0
  18. package/src/client/runtime.ts +24 -21
  19. package/src/client/use-fetch.ts +239 -0
  20. package/src/client/use-head.ts +197 -0
  21. package/src/client/use-sse.ts +378 -0
  22. package/src/components/Image.tsx +162 -0
  23. package/src/config/mandu.ts +5 -0
  24. package/src/config/validate.ts +34 -0
  25. package/src/content/index.ts +5 -1
  26. package/src/devtools/client/catchers/error-catcher.ts +17 -0
  27. package/src/devtools/client/catchers/network-proxy.ts +390 -367
  28. package/src/devtools/client/components/kitchen-root.tsx +479 -467
  29. package/src/devtools/client/components/panel/diff-viewer.tsx +219 -0
  30. package/src/devtools/client/components/panel/guard-panel.tsx +374 -244
  31. package/src/devtools/client/components/panel/index.ts +45 -32
  32. package/src/devtools/client/components/panel/panel-container.tsx +332 -312
  33. package/src/devtools/client/components/panel/preview-panel.tsx +188 -0
  34. package/src/devtools/client/state-manager.ts +535 -478
  35. package/src/devtools/design-tokens.ts +265 -264
  36. package/src/devtools/types.ts +345 -319
  37. package/src/filling/filling.ts +336 -14
  38. package/src/filling/index.ts +5 -1
  39. package/src/filling/session.ts +216 -0
  40. package/src/filling/ws.ts +78 -0
  41. package/src/generator/generate.ts +2 -2
  42. package/src/guard/auto-correct.ts +0 -29
  43. package/src/guard/check.ts +14 -31
  44. package/src/guard/presets/index.ts +296 -294
  45. package/src/guard/rules.ts +15 -19
  46. package/src/guard/validator.ts +834 -834
  47. package/src/index.ts +5 -1
  48. package/src/island/index.ts +373 -304
  49. package/src/kitchen/api/contract-api.ts +225 -0
  50. package/src/kitchen/api/diff-parser.ts +108 -0
  51. package/src/kitchen/api/file-api.ts +273 -0
  52. package/src/kitchen/api/guard-api.ts +83 -0
  53. package/src/kitchen/api/guard-decisions.ts +100 -0
  54. package/src/kitchen/api/routes-api.ts +50 -0
  55. package/src/kitchen/index.ts +21 -0
  56. package/src/kitchen/kitchen-handler.ts +256 -0
  57. package/src/kitchen/kitchen-ui.ts +1732 -0
  58. package/src/kitchen/stream/activity-sse.ts +145 -0
  59. package/src/kitchen/stream/file-tailer.ts +99 -0
  60. package/src/middleware/compress.ts +62 -0
  61. package/src/middleware/cors.ts +47 -0
  62. package/src/middleware/index.ts +10 -0
  63. package/src/middleware/jwt.ts +134 -0
  64. package/src/middleware/logger.ts +58 -0
  65. package/src/middleware/timeout.ts +55 -0
  66. package/src/paths.ts +0 -4
  67. package/src/plugins/hooks.ts +64 -0
  68. package/src/plugins/index.ts +3 -0
  69. package/src/plugins/types.ts +5 -0
  70. package/src/report/build.ts +0 -6
  71. package/src/resource/__tests__/backward-compat.test.ts +0 -1
  72. package/src/router/fs-patterns.ts +11 -1
  73. package/src/router/fs-routes.ts +78 -14
  74. package/src/router/fs-scanner.ts +2 -2
  75. package/src/router/fs-types.ts +2 -1
  76. package/src/runtime/adapter-bun.ts +62 -0
  77. package/src/runtime/adapter.ts +47 -0
  78. package/src/runtime/cache.ts +310 -0
  79. package/src/runtime/handler.ts +65 -0
  80. package/src/runtime/image-handler.ts +195 -0
  81. package/src/runtime/index.ts +12 -0
  82. package/src/runtime/middleware.ts +263 -0
  83. package/src/runtime/server.ts +662 -83
  84. package/src/runtime/ssr.ts +55 -29
  85. package/src/runtime/streaming-ssr.ts +106 -82
  86. package/src/spec/index.ts +0 -1
  87. package/src/spec/schema.ts +1 -0
  88. package/src/testing/index.ts +144 -0
  89. package/src/watcher/watcher.ts +27 -1
  90. package/src/spec/lock.ts +0 -56
@@ -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,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
- wrapped = React.createElement(Layout, { params, children: wrapped });
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<Response | null> {
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 null; // 정적 파일이 아님
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 null;
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 null;
858
+ return { handled: true, response: createStaticErrorResponse(400) };
781
859
  }
782
860
 
783
- // 선행 슬래시 제거 → path.join이 base를 무시하지 않도록 보장
784
- const safeRelativePath = normalizedPath.replace(/^\/+/, "");
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 null;
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 null; // 파일 없음 - 라우트 매칭으로 넘김
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
- return new Response(file, {
823
- headers: {
824
- "Content-Type": mimeType,
825
- "Cache-Control": cacheControl,
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 null; // 파일 읽기 실패 - 라우트 매칭으로 넘김
925
+ return { handled: true, response: createStaticErrorResponse(500) };
830
926
  }
831
- }
832
-
833
- // ========== Request Handler ==========
834
-
835
- async function handleRequest(req: Request, router: Router, registry: ServerRegistry): Promise<Response> {
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) {
@@ -873,6 +1054,8 @@ async function handleApiRoute(
873
1054
  interface PageLoadResult {
874
1055
  loaderData: unknown;
875
1056
  cookies?: CookieManager;
1057
+ /** Layout별 loader 데이터 (모듈 경로 → 데이터) */
1058
+ layoutData?: Map<string, unknown>;
876
1059
  }
877
1060
 
878
1061
  /**
@@ -880,7 +1063,7 @@ interface PageLoadResult {
880
1063
  */
881
1064
  async function loadPageData(
882
1065
  req: Request,
883
- route: { id: string; pattern: string },
1066
+ route: { id: string; pattern: string; layoutChain?: string[] },
884
1067
  params: Record<string, string>,
885
1068
  registry: ServerRegistry
886
1069
  ): Promise<Result<PageLoadResult>> {
@@ -891,9 +1074,7 @@ async function loadPageData(
891
1074
  if (pageHandler) {
892
1075
  let cookies: CookieManager | undefined;
893
1076
  try {
894
- const registration = await pageHandler();
895
- const component = registration.component as RouteComponent;
896
- registry.registerRouteComponent(route.id, component);
1077
+ const registration = await ensurePageRouteMetadata(route.id, registry, pageHandler);
897
1078
 
898
1079
  // Filling의 loader 실행
899
1080
  if (registration.filling?.hasLoader()) {
@@ -928,9 +1109,12 @@ async function loadPageData(
928
1109
  : (exportedObj?.component ?? exported);
929
1110
  registry.registerRouteComponent(route.id, component as RouteComponent);
930
1111
 
931
- // filling이 있으면 loader 실행
1112
+ // filling이 있으면 캐시 옵션 등록 + loader 실행
932
1113
  let cookies: CookieManager | undefined;
933
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
+ }
934
1118
  if (filling?.hasLoader?.()) {
935
1119
  const ctx = new ManduContext(req, params);
936
1120
  loaderData = await filling.executeLoader(ctx);
@@ -954,18 +1138,103 @@ async function loadPageData(
954
1138
  return ok({ loaderData });
955
1139
  }
956
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
+
957
1225
  // ---------- SSR Renderer ----------
958
1226
 
959
1227
  /**
960
1228
  * SSR 렌더링 (Streaming/Non-streaming)
961
1229
  */
962
1230
  async function renderPageSSR(
963
- 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 },
964
1232
  params: Record<string, string>,
965
1233
  loaderData: unknown,
966
1234
  url: string,
967
1235
  registry: ServerRegistry,
968
- cookies?: CookieManager
1236
+ cookies?: CookieManager,
1237
+ layoutData?: Map<string, unknown>
969
1238
  ): Promise<Result<Response>> {
970
1239
  const settings = registry.settings;
971
1240
  const defaultAppCreator = createDefaultAppFactory(registry);
@@ -979,9 +1248,28 @@ async function renderPageSSR(
979
1248
  loaderData,
980
1249
  });
981
1250
 
982
- // 레이아웃 체인 적용
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 바깥)
983
1271
  if (route.layoutChain && route.layoutChain.length > 0) {
984
- app = await wrapWithLayouts(app, route.layoutChain, registry, params);
1272
+ app = await wrapWithLayouts(app, route.layoutChain, registry, params, layoutData);
985
1273
  }
986
1274
 
987
1275
  const serverData = loaderData
@@ -1024,6 +1312,9 @@ async function renderPageSSR(
1024
1312
  }
1025
1313
 
1026
1314
  // 기존 renderToString 방식
1315
+ // Note: hydration 래핑은 위에서 React 엘리먼트 레벨로 이미 처리됨
1316
+ // renderToHTML에서 중복 래핑하지 않도록 hydration을 전달하되 strategy를 "none"으로 설정
1317
+ // 단, hydration 스크립트(importmap, runtime 등)는 여전히 필요하므로 bundleManifest는 유지
1027
1318
  const ssrResponse = renderSSR(app, {
1028
1319
  title: `${route.id} - Mandu`,
1029
1320
  isDev: settings.isDev,
@@ -1035,13 +1326,46 @@ async function renderPageSSR(
1035
1326
  enableClientRouter: true,
1036
1327
  routePattern: route.pattern,
1037
1328
  cssPath: settings.cssPath,
1329
+ islandPreWrapped: !!needsIslandWrap,
1038
1330
  });
1039
1331
  return ok(cookies ? cookies.applyToResponse(ssrResponse) : ssrResponse);
1040
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
+
1041
1365
  const ssrError = createSSRErrorResponse(
1042
1366
  route.id,
1043
1367
  route.pattern,
1044
- error instanceof Error ? error : new Error(String(error))
1368
+ renderError
1045
1369
  );
1046
1370
  console.error(`[Mandu] ${ssrError.errorType}:`, ssrError.message);
1047
1371
  return err(ssrError);
@@ -1050,6 +1374,9 @@ async function renderPageSSR(
1050
1374
 
1051
1375
  // ---------- Page Route Handler ----------
1052
1376
 
1377
+ /** SWR 백그라운드 재생성 중복 방지 */
1378
+ const pendingRevalidations = new Set<string>();
1379
+
1053
1380
  /**
1054
1381
  * 페이지 라우트 처리
1055
1382
  */
@@ -1060,8 +1387,51 @@ async function handlePageRoute(
1060
1387
  params: Record<string, string>,
1061
1388
  registry: ServerRegistry
1062
1389
  ): Promise<Result<Response>> {
1063
- // 1. 데이터 로딩
1064
- const loadResult = await loadPageData(req, route, params, registry);
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
+ ]);
1065
1435
  if (!loadResult.ok) {
1066
1436
  return loadResult;
1067
1437
  }
@@ -1069,7 +1439,8 @@ async function handlePageRoute(
1069
1439
  const { loaderData, cookies } = loadResult.value;
1070
1440
 
1071
1441
  // 2. Client-side Routing: 데이터만 반환 (JSON)
1072
- if (url.searchParams.has("_data")) {
1442
+ // 참고: layoutData는 SSR 시에만 사용 — SPA 네비게이션은 전체 페이지 SSR을 받지 않으므로 제외
1443
+ if (isDataRequest) {
1073
1444
  const jsonResponse = Response.json({
1074
1445
  routeId: route.id,
1075
1446
  pattern: route.pattern,
@@ -1080,8 +1451,127 @@ async function handlePageRoute(
1080
1451
  return ok(cookies ? cookies.applyToResponse(jsonResponse) : jsonResponse);
1081
1452
  }
1082
1453
 
1083
- // 3. SSR 렌더링
1084
- return renderPageSSR(route, params, loaderData, req.url, registry, cookies);
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());
1561
+ }
1562
+
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}`;
1085
1575
  }
1086
1576
 
1087
1577
  // ---------- Main Request Dispatcher ----------
@@ -1105,8 +1595,9 @@ async function handleRequestInternal(
1105
1595
  }
1106
1596
 
1107
1597
  // 1. 정적 파일 서빙 시도 (최우선)
1108
- const staticResponse = await serveStaticFile(pathname, settings);
1109
- if (staticResponse) {
1598
+ const staticFileResult = await serveStaticFile(pathname, settings, req);
1599
+ if (staticFileResult.handled) {
1600
+ const staticResponse = staticFileResult.response!;
1110
1601
  if (settings.cors && isCorsRequest(req)) {
1111
1602
  const corsOptions: CorsOptions = typeof settings.cors === 'object' ? settings.cors : {};
1112
1603
  return ok(applyCorsToResponse(staticResponse, req, corsOptions));
@@ -1114,7 +1605,24 @@ async function handleRequestInternal(
1114
1605
  return ok(staticResponse);
1115
1606
  }
1116
1607
 
1117
- // 2. 라우트 매칭
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. 라우트 매칭
1118
1626
  const match = router.match(pathname);
1119
1627
  if (!match) {
1120
1628
  return err(createNotFoundResponse(pathname));
@@ -1174,19 +1682,18 @@ function isPortInUseError(error: unknown): boolean {
1174
1682
  function startBunServerWithFallback(options: {
1175
1683
  port: number;
1176
1684
  hostname?: string;
1177
- fetch: (req: Request) => Promise<Response>;
1685
+ fetch: (req: Request, server: Server<undefined>) => Promise<Response | undefined>;
1686
+ websocket?: Record<string, unknown>;
1178
1687
  }): { server: Server<undefined>; port: number; attempts: number } {
1179
- const { port: startPort, hostname, fetch } = options;
1688
+ const { port: startPort, hostname, fetch, websocket } = options;
1180
1689
  let lastError: unknown = null;
1181
1690
 
1691
+ const serveOptions: Record<string, unknown> = { hostname, fetch, idleTimeout: 255 };
1692
+ if (websocket) serveOptions.websocket = websocket;
1693
+
1182
1694
  // Port 0: let Bun/OS pick an available ephemeral port.
1183
1695
  if (startPort === 0) {
1184
- const server = Bun.serve({
1185
- port: 0,
1186
- hostname,
1187
- fetch,
1188
- idleTimeout: 255,
1189
- });
1696
+ const server = Bun.serve({ port: 0, ...serveOptions } as any);
1190
1697
  return { server, port: server.port ?? 0, attempts: 0 };
1191
1698
  }
1192
1699
 
@@ -1196,12 +1703,7 @@ function startBunServerWithFallback(options: {
1196
1703
  continue;
1197
1704
  }
1198
1705
  try {
1199
- const server = Bun.serve({
1200
- port: candidate,
1201
- hostname,
1202
- fetch,
1203
- idleTimeout: 255,
1204
- });
1706
+ const server = Bun.serve({ port: candidate, ...serveOptions } as any);
1205
1707
  return { server, port: server.port ?? candidate, attempts: attempt };
1206
1708
  } catch (error) {
1207
1709
  if (!isPortInUseError(error)) {
@@ -1230,7 +1732,10 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
1230
1732
  rateLimit = false,
1231
1733
  cssPath: cssPathOption,
1232
1734
  registry = defaultRegistry,
1233
- } = options;
1735
+ guardConfig = null,
1736
+ cache: cacheOption,
1737
+ managementToken,
1738
+ } = options;
1234
1739
 
1235
1740
  // cssPath 처리:
1236
1741
  // - string: 해당 경로로 <link> 주입
@@ -1261,31 +1766,99 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
1261
1766
  rootDir,
1262
1767
  publicDir,
1263
1768
  cors: corsOptions,
1264
- streaming,
1265
- rateLimit: rateLimitOptions,
1266
- cssPath,
1267
- };
1769
+ streaming,
1770
+ rateLimit: rateLimitOptions,
1771
+ cssPath,
1772
+ managementToken,
1773
+ };
1268
1774
 
1269
1775
  registry.rateLimiter = rateLimitOptions ? new MemoryRateLimiter() : null;
1270
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
+
1271
1791
  const router = new Router(manifest.routes);
1272
1792
 
1273
- // Fetch handler with CORS support (registry를 클로저로 캡처)
1274
- const fetchHandler = async (req: Request): Promise<Response> => {
1275
- const response = await handleRequest(req, router, registry);
1793
+ // 글로벌 미들웨어 (middleware.ts) 동기 로드로 요청부터 보장
1794
+ let middlewareFn: MiddlewareFn | null = null;
1795
+ let middlewareConfig: MiddlewareConfig | null = null;
1276
1796
 
1277
- // API 라우트 응답에 CORS 헤더 적용
1278
- if (corsOptions && isCorsRequest(req)) {
1279
- return applyCorsToResponse(response, req, corsOptions);
1280
- }
1797
+ const mwResult = loadMiddlewareSync(rootDir);
1798
+ if (mwResult) {
1799
+ middlewareFn = mwResult.fn;
1800
+ middlewareConfig = mwResult.config;
1801
+ console.log("🔗 Global middleware loaded");
1802
+ }
1281
1803
 
1282
- return response;
1283
- };
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);
1284
1856
 
1285
1857
  const { server, port: actualPort, attempts } = startBunServerWithFallback({
1286
1858
  port,
1287
1859
  hostname,
1288
- fetch: fetchHandler,
1860
+ fetch: wrappedFetch as any,
1861
+ websocket: wsConfig,
1289
1862
  });
1290
1863
 
1291
1864
  if (attempts > 0) {
@@ -1308,6 +1881,9 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
1308
1881
  if (streaming) {
1309
1882
  console.log(`🌊 Streaming SSR enabled`);
1310
1883
  }
1884
+ if (registry.kitchen) {
1885
+ console.log(`🍳 Kitchen dashboard at http://${hostname}:${actualPort}/__kitchen`);
1886
+ }
1311
1887
  } else {
1312
1888
  console.log(`🥟 Mandu server running at http://${hostname}:${actualPort}`);
1313
1889
  if (streaming) {
@@ -1319,7 +1895,10 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
1319
1895
  server,
1320
1896
  router,
1321
1897
  registry,
1322
- stop: () => server.stop(),
1898
+ stop: () => {
1899
+ registry.kitchen?.stop();
1900
+ server.stop();
1901
+ },
1323
1902
  };
1324
1903
  }
1325
1904