@mandujs/core 0.18.21 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/package.json +3 -1
  2. package/src/brain/doctor/analyzer.ts +1 -0
  3. package/src/brain/doctor/patcher.ts +10 -6
  4. package/src/brain/doctor/reporter.ts +2 -2
  5. package/src/brain/types.ts +14 -10
  6. package/src/bundler/build.ts +17 -17
  7. package/src/bundler/dev.ts +1 -1
  8. package/src/client/island.ts +10 -9
  9. package/src/client/router.ts +1 -1
  10. package/src/config/mcp-ref.ts +6 -6
  11. package/src/config/metadata.test.ts +1 -1
  12. package/src/config/metadata.ts +36 -16
  13. package/src/config/symbols.ts +1 -1
  14. package/src/config/validate.ts +16 -0
  15. package/src/content/content.test.ts +3 -3
  16. package/src/content/loaders/file.ts +3 -0
  17. package/src/content/loaders/glob.ts +1 -0
  18. package/src/contract/client-safe.test.ts +1 -1
  19. package/src/contract/client.ts +18 -18
  20. package/src/contract/define.ts +29 -14
  21. package/src/contract/handler.ts +11 -11
  22. package/src/contract/normalize.test.ts +1 -1
  23. package/src/contract/normalize.ts +16 -10
  24. package/src/contract/registry.test.ts +1 -1
  25. package/src/contract/zod-utils.ts +155 -0
  26. package/src/devtools/client/catchers/network-proxy.ts +5 -1
  27. package/src/devtools/init.ts +2 -2
  28. package/src/devtools/server/source-context.ts +9 -3
  29. package/src/devtools/worker/redaction-worker.ts +12 -5
  30. package/src/error/index.ts +1 -1
  31. package/src/error/result.ts +14 -0
  32. package/src/filling/context.ts +65 -0
  33. package/src/filling/deps.ts +1 -1
  34. package/src/generator/templates.ts +2 -2
  35. package/src/guard/contract-guard.test.ts +1 -0
  36. package/src/guard/file-type.test.ts +1 -1
  37. package/src/guard/negotiation.ts +29 -1
  38. package/src/index.ts +1 -0
  39. package/src/intent/index.ts +28 -17
  40. package/src/island/index.ts +2 -2
  41. package/src/openapi/generator.ts +49 -31
  42. package/src/plugins/registry.ts +28 -18
  43. package/src/resource/__tests__/backward-compat.test.ts +2 -2
  44. package/src/resource/__tests__/edge-cases.test.ts +14 -13
  45. package/src/resource/__tests__/fixtures.ts +2 -2
  46. package/src/resource/__tests__/generator.test.ts +1 -1
  47. package/src/resource/__tests__/performance.test.ts +8 -6
  48. package/src/resource/schema.ts +1 -1
  49. package/src/router/fs-routes.ts +29 -35
  50. package/src/runtime/logger.test.ts +3 -3
  51. package/src/runtime/logger.ts +1 -1
  52. package/src/runtime/server.ts +34 -17
  53. package/src/runtime/stable-selector.ts +1 -2
  54. package/src/runtime/streaming-ssr.ts +11 -2
  55. package/src/seo/index.ts +5 -0
  56. package/src/seo/integration/ssr.ts +2 -2
  57. package/src/seo/resolve/url.ts +7 -0
  58. package/src/seo/types.ts +13 -0
  59. package/src/spec/schema.ts +82 -54
  60. package/src/types/branded.ts +56 -0
  61. package/src/types/index.ts +1 -0
  62. package/src/utils/hasher.test.ts +6 -6
@@ -51,49 +51,43 @@ export interface GenerateOptions {
51
51
  * FSRouteConfig를 RouteSpec으로 변환
52
52
  */
53
53
  export function fsRouteToRouteSpec(fsRoute: FSRouteConfig): RouteSpec {
54
- const routeSpec: RouteSpec = {
54
+ const base = {
55
55
  id: fsRoute.id,
56
56
  pattern: fsRoute.pattern,
57
- kind: fsRoute.kind,
58
57
  module: fsRoute.module,
59
58
  };
60
59
 
61
- // 페이지 라우트의 경우
62
60
  if (fsRoute.kind === "page") {
63
- routeSpec.componentModule = fsRoute.componentModule;
64
-
65
- // Island (클라이언트 모듈)
66
- if (fsRoute.clientModule) {
67
- routeSpec.clientModule = fsRoute.clientModule;
68
- routeSpec.hydration = fsRoute.hydration ?? {
69
- strategy: "island",
70
- priority: "visible",
71
- preload: false,
72
- };
73
- }
74
-
75
- // Layout 체인
76
- if (fsRoute.layoutChain && fsRoute.layoutChain.length > 0) {
77
- routeSpec.layoutChain = fsRoute.layoutChain;
78
- }
79
-
80
- // Loading UI
81
- if (fsRoute.loadingModule) {
82
- routeSpec.loadingModule = fsRoute.loadingModule;
83
- }
84
-
85
- // Error UI
86
- if (fsRoute.errorModule) {
87
- routeSpec.errorModule = fsRoute.errorModule;
88
- }
61
+ const pageRoute: RouteSpec = {
62
+ ...base,
63
+ kind: "page" as const,
64
+ componentModule: fsRoute.componentModule ?? "",
65
+ ...(fsRoute.clientModule
66
+ ? {
67
+ clientModule: fsRoute.clientModule,
68
+ hydration: fsRoute.hydration ?? {
69
+ strategy: "island" as const,
70
+ priority: "visible" as const,
71
+ preload: false,
72
+ },
73
+ }
74
+ : {}),
75
+ ...(fsRoute.layoutChain && fsRoute.layoutChain.length > 0
76
+ ? { layoutChain: fsRoute.layoutChain }
77
+ : {}),
78
+ ...(fsRoute.loadingModule ? { loadingModule: fsRoute.loadingModule } : {}),
79
+ ...(fsRoute.errorModule ? { errorModule: fsRoute.errorModule } : {}),
80
+ };
81
+ return pageRoute;
89
82
  }
90
83
 
91
- // API 라우트의 경우
92
- if (fsRoute.kind === "api" && fsRoute.methods) {
93
- routeSpec.methods = fsRoute.methods;
94
- }
95
-
96
- return routeSpec;
84
+ // API 라우트
85
+ const apiRoute: RouteSpec = {
86
+ ...base,
87
+ kind: "api" as const,
88
+ ...(fsRoute.methods ? { methods: fsRoute.methods } : {}),
89
+ };
90
+ return apiRoute;
97
91
  }
98
92
 
99
93
  /**
@@ -62,7 +62,7 @@ describe("logger", () => {
62
62
 
63
63
  log.onError(ctx, error);
64
64
 
65
- expect(ctx.get("__mandu_logger_error")).toBe(error);
65
+ expect(ctx.get<Error>("__mandu_logger_error")).toBe(error);
66
66
  });
67
67
 
68
68
  test("afterHandle은 응답을 저장하고 반환", () => {
@@ -72,7 +72,7 @@ describe("logger", () => {
72
72
 
73
73
  const result = log.afterHandle(ctx, response);
74
74
 
75
- expect(ctx.get("__mandu_logger_response")).toBe(response);
75
+ expect(ctx.get<Response>("__mandu_logger_response")).toBe(response);
76
76
  expect(result).toBe(response);
77
77
  });
78
78
  });
@@ -147,7 +147,7 @@ describe("logger", () => {
147
147
 
148
148
  log.onRequest(ctx);
149
149
 
150
- expect(ctx.get("__mandu_logger_request_id")).toBe("custom-POST-123");
150
+ expect(ctx.get<string>("__mandu_logger_request_id")).toBe("custom-POST-123");
151
151
  });
152
152
  });
153
153
 
@@ -639,7 +639,7 @@ export function applyLogger(
639
639
  loggerInstance: ReturnType<typeof logger>
640
640
  ): void {
641
641
  lifecycle.onRequest.push({ fn: loggerInstance.onRequest, scope: "global" });
642
- lifecycle.onError.push({ fn: loggerInstance.onError as any, scope: "global" });
642
+ lifecycle.onError.push({ fn: loggerInstance.onError as (ctx: ManduContext, error: Error) => void, scope: "global" });
643
643
  lifecycle.afterHandle.push({ fn: loggerInstance.afterHandle, scope: "global" });
644
644
  lifecycle.afterResponse.push({ fn: loggerInstance.afterResponse, scope: "global" });
645
645
  }
@@ -1,8 +1,8 @@
1
1
  import type { Server } from "bun";
2
- import type { RoutesManifest, HydrationConfig } from "../spec/schema";
2
+ import type { RoutesManifest, RouteSpec, HydrationConfig } from "../spec/schema";
3
3
  import type { BundleManifest } from "../bundler/types";
4
4
  import type { ManduFilling } from "../filling/filling";
5
- import { ManduContext } from "../filling/context";
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";
@@ -872,6 +872,7 @@ async function handleApiRoute(
872
872
 
873
873
  interface PageLoadResult {
874
874
  loaderData: unknown;
875
+ cookies?: CookieManager;
875
876
  }
876
877
 
877
878
  /**
@@ -888,6 +889,7 @@ async function loadPageData(
888
889
  // 1. PageHandler 방식 (신규 - filling 포함)
889
890
  const pageHandler = registry.pageHandlers.get(route.id);
890
891
  if (pageHandler) {
892
+ let cookies: CookieManager | undefined;
891
893
  try {
892
894
  const registration = await pageHandler();
893
895
  const component = registration.component as RouteComponent;
@@ -897,6 +899,9 @@ async function loadPageData(
897
899
  if (registration.filling?.hasLoader()) {
898
900
  const ctx = new ManduContext(req, params);
899
901
  loaderData = await registration.filling.executeLoader(ctx);
902
+ if (ctx.cookies.hasPendingCookies()) {
903
+ cookies = ctx.cookies;
904
+ }
900
905
  }
901
906
  } catch (error) {
902
907
  const pageError = createPageLoadErrorResponse(
@@ -908,7 +913,7 @@ async function loadPageData(
908
913
  return err(pageError);
909
914
  }
910
915
 
911
- return ok({ loaderData });
916
+ return ok({ loaderData, cookies });
912
917
  }
913
918
 
914
919
  // 2. PageLoader 방식 (레거시 호환)
@@ -917,17 +922,24 @@ async function loadPageData(
917
922
  try {
918
923
  const module = await loader();
919
924
  const exported: unknown = module.default;
925
+ const exportedObj = exported as Record<string, unknown> | null;
920
926
  const component = typeof exported === "function"
921
927
  ? (exported as RouteComponent)
922
- : (exported as any)?.component ?? exported;
928
+ : (exportedObj?.component ?? exported);
923
929
  registry.registerRouteComponent(route.id, component as RouteComponent);
924
930
 
925
931
  // filling이 있으면 loader 실행
926
- const filling = typeof exported === "object" && exported !== null ? (exported as any)?.filling : null;
932
+ let cookies: CookieManager | undefined;
933
+ const filling = typeof exported === "object" && exported !== null ? (exportedObj as Record<string, unknown>)?.filling as ManduFilling | null : null;
927
934
  if (filling?.hasLoader?.()) {
928
935
  const ctx = new ManduContext(req, params);
929
936
  loaderData = await filling.executeLoader(ctx);
937
+ if (ctx.cookies.hasPendingCookies()) {
938
+ cookies = ctx.cookies;
939
+ }
930
940
  }
941
+
942
+ return ok({ loaderData, cookies });
931
943
  } catch (error) {
932
944
  const pageError = createPageLoadErrorResponse(
933
945
  route.id,
@@ -952,7 +964,8 @@ async function renderPageSSR(
952
964
  params: Record<string, string>,
953
965
  loaderData: unknown,
954
966
  url: string,
955
- registry: ServerRegistry
967
+ registry: ServerRegistry,
968
+ cookies?: CookieManager
956
969
  ): Promise<Result<Response>> {
957
970
  const settings = registry.settings;
958
971
  const defaultAppCreator = createDefaultAppFactory(registry);
@@ -981,7 +994,7 @@ async function renderPageSSR(
981
994
  : settings.streaming;
982
995
 
983
996
  if (useStreaming) {
984
- return ok(await renderStreamingResponse(app, {
997
+ const streamingResponse = await renderStreamingResponse(app, {
985
998
  title: `${route.id} - Mandu`,
986
999
  isDev: settings.isDev,
987
1000
  hmrPort: settings.hmrPort,
@@ -1006,11 +1019,12 @@ async function renderPageSSR(
1006
1019
  });
1007
1020
  }
1008
1021
  },
1009
- }));
1022
+ });
1023
+ return ok(cookies ? cookies.applyToResponse(streamingResponse) : streamingResponse);
1010
1024
  }
1011
1025
 
1012
1026
  // 기존 renderToString 방식
1013
- return ok(renderSSR(app, {
1027
+ const ssrResponse = renderSSR(app, {
1014
1028
  title: `${route.id} - Mandu`,
1015
1029
  isDev: settings.isDev,
1016
1030
  hmrPort: settings.hmrPort,
@@ -1021,7 +1035,8 @@ async function renderPageSSR(
1021
1035
  enableClientRouter: true,
1022
1036
  routePattern: route.pattern,
1023
1037
  cssPath: settings.cssPath,
1024
- }));
1038
+ });
1039
+ return ok(cookies ? cookies.applyToResponse(ssrResponse) : ssrResponse);
1025
1040
  } catch (error) {
1026
1041
  const ssrError = createSSRErrorResponse(
1027
1042
  route.id,
@@ -1051,21 +1066,22 @@ async function handlePageRoute(
1051
1066
  return loadResult;
1052
1067
  }
1053
1068
 
1054
- const { loaderData } = loadResult.value;
1069
+ const { loaderData, cookies } = loadResult.value;
1055
1070
 
1056
1071
  // 2. Client-side Routing: 데이터만 반환 (JSON)
1057
1072
  if (url.searchParams.has("_data")) {
1058
- return ok(Response.json({
1073
+ const jsonResponse = Response.json({
1059
1074
  routeId: route.id,
1060
1075
  pattern: route.pattern,
1061
1076
  params,
1062
1077
  loaderData: loaderData ?? null,
1063
1078
  timestamp: Date.now(),
1064
- }));
1079
+ });
1080
+ return ok(cookies ? cookies.applyToResponse(jsonResponse) : jsonResponse);
1065
1081
  }
1066
1082
 
1067
1083
  // 3. SSR 렌더링
1068
- return renderPageSSR(route, params, loaderData, req.url, registry);
1084
+ return renderPageSSR(route, params, loaderData, req.url, registry, cookies);
1069
1085
  }
1070
1086
 
1071
1087
  // ---------- Main Request Dispatcher ----------
@@ -1127,18 +1143,19 @@ async function handleRequestInternal(
1127
1143
  return handlePageRoute(req, url, route, params, registry);
1128
1144
  }
1129
1145
 
1130
- // 4. 알 수 없는 라우트 종류
1146
+ // 4. 알 수 없는 라우트 종류 — exhaustiveness check
1147
+ const _exhaustive: never = route;
1131
1148
  return err({
1132
1149
  errorType: "FRAMEWORK_BUG",
1133
1150
  code: "MANDU_F003",
1134
1151
  httpStatus: 500,
1135
- message: `Unknown route kind: ${route.kind}`,
1152
+ message: `Unknown route kind: ${(_exhaustive as RouteSpec).kind}`,
1136
1153
  summary: "알 수 없는 라우트 종류 - 프레임워크 버그",
1137
1154
  fix: {
1138
1155
  file: ".mandu/routes.manifest.json",
1139
1156
  suggestion: "라우트의 kind는 'api' 또는 'page'여야 합니다",
1140
1157
  },
1141
- route: { id: route.id, pattern: route.pattern },
1158
+ route: { id: (_exhaustive as RouteSpec).id, pattern: (_exhaustive as RouteSpec).pattern },
1142
1159
  timestamp: new Date().toISOString(),
1143
1160
  });
1144
1161
  }
@@ -11,8 +11,7 @@ function fnv1a64Hex(input: string): string {
11
11
 
12
12
  function getBuildSaltFallback(): string {
13
13
  try {
14
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
15
- const salt = (typeof process !== "undefined" && (process as any)?.env?.MANDU_BUILD_SALT) as string | undefined;
14
+ const salt = (typeof process !== "undefined" && process.env?.MANDU_BUILD_SALT) as string | undefined;
16
15
  return salt ?? "dev";
17
16
  } catch {
18
17
  return "dev";
@@ -244,6 +244,15 @@ function validateCriticalData(data: Record<string, unknown> | undefined, isDev:
244
244
 
245
245
  // ========== Streaming Warnings ==========
246
246
 
247
+ /**
248
+ * Streaming 경고 상태 (module-level, globalThis as any 제거)
249
+ */
250
+ const streamingWarnings = {
251
+ _warned: false,
252
+ markWarned() { this._warned = true; },
253
+ hasWarned() { return this._warned; },
254
+ };
255
+
247
256
  /**
248
257
  * 프록시/버퍼링 관련 경고 (개발 모드)
249
258
  */
@@ -685,9 +694,9 @@ export async function renderToStream(
685
694
  validateCriticalData(criticalData, isDev);
686
695
 
687
696
  // 스트리밍 주의사항 경고 (첫 요청 시 1회만)
688
- if (isDev && !(globalThis as any).__MANDU_STREAMING_WARNED__) {
697
+ if (isDev && !streamingWarnings.hasWarned()) {
689
698
  warnStreamingCaveats(isDev);
690
- (globalThis as any).__MANDU_STREAMING_WARNED__ = true;
699
+ streamingWarnings.markWarned();
691
700
  }
692
701
 
693
702
  const encoder = new TextEncoder();
package/src/seo/index.ts CHANGED
@@ -102,6 +102,11 @@ export type {
102
102
  JsonLd,
103
103
  JsonLdType,
104
104
 
105
+ // Template Literal Types
106
+ OGProperty,
107
+ TwitterProperty,
108
+ MetaProperty,
109
+
105
110
  // Google SEO 최적화
106
111
  GoogleMeta,
107
112
  FormatDetection,
@@ -118,12 +118,12 @@ export function resolveSEOSync(staticMetadata: Metadata): SEOResult {
118
118
  resolvedTitle = { absolute: staticMetadata.title, template: null }
119
119
  } else if ('absolute' in staticMetadata.title) {
120
120
  resolvedTitle = {
121
- absolute: staticMetadata.title.absolute,
121
+ absolute: staticMetadata.title.absolute ?? '',
122
122
  template: staticMetadata.title.template ?? null,
123
123
  }
124
124
  } else if ('default' in staticMetadata.title) {
125
125
  resolvedTitle = {
126
- absolute: staticMetadata.title.default,
126
+ absolute: staticMetadata.title.default ?? '',
127
127
  template: staticMetadata.title.template ?? null,
128
128
  }
129
129
  }
@@ -27,6 +27,9 @@ export function normalizeMetadataBase(
27
27
  /**
28
28
  * 상대 URL을 절대 URL로 변환
29
29
  */
30
+ export function resolveUrl(url: URL, metadataBase: URL | null): URL;
31
+ export function resolveUrl(url: string | null | undefined, metadataBase: URL | null): URL | null;
32
+ export function resolveUrl(url: string | URL | null | undefined, metadataBase: URL | null): URL | null;
30
33
  export function resolveUrl(
31
34
  url: string | URL | null | undefined,
32
35
  metadataBase: URL | null
@@ -69,6 +72,10 @@ export function resolveUrl(
69
72
  /**
70
73
  * URL을 문자열로 변환 (렌더링용)
71
74
  */
75
+ export function urlToString(url: URL): string;
76
+ export function urlToString(url: string): string;
77
+ export function urlToString(url: null | undefined): null;
78
+ export function urlToString(url: URL | string | null | undefined): string | null;
72
79
  export function urlToString(url: URL | string | null | undefined): string | null {
73
80
  if (!url) return null
74
81
  if (url instanceof URL) return url.href
package/src/seo/types.ts CHANGED
@@ -223,6 +223,19 @@ export interface ResolvedVerification {
223
223
  other: Record<string, string[]> | null
224
224
  }
225
225
 
226
+ // ============================================================================
227
+ // Template Literal Types for Meta Properties
228
+ // ============================================================================
229
+
230
+ /** OpenGraph meta property name (og:title, og:description, etc.) */
231
+ export type OGProperty = `og:${string}`;
232
+
233
+ /** Twitter meta property name (twitter:card, twitter:site, etc.) */
234
+ export type TwitterProperty = `twitter:${string}`;
235
+
236
+ /** Union of well-known meta property prefixes plus arbitrary strings */
237
+ export type MetaProperty = OGProperty | TwitterProperty | string;
238
+
226
239
  // ============================================================================
227
240
  // Open Graph Types
228
241
  // ============================================================================
@@ -59,68 +59,34 @@ export type RouteKind = z.infer<typeof RouteKind>;
59
59
  export const SpecHttpMethod = z.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]);
60
60
  export type SpecHttpMethod = z.infer<typeof SpecHttpMethod>;
61
61
 
62
- export const RouteSpec = z
62
+ // ---- 공통 필드 ----
63
+ const RouteSpecBase = {
64
+ id: z.string().min(1, "id는 필수입니다"),
65
+ pattern: z.string().startsWith("/", "pattern은 /로 시작해야 합니다"),
66
+ module: z.string().min(1, "module 경로는 필수입니다"),
67
+ slotModule: z.string().optional(),
68
+ clientModule: z.string().optional(),
69
+ contractModule: z.string().optional(),
70
+ hydration: HydrationConfig.optional(),
71
+ loader: LoaderConfig.optional(),
72
+ streaming: z.boolean().optional(),
73
+ };
74
+
75
+ // ---- Page 라우트 ----
76
+ export const PageRouteSpec = z
63
77
  .object({
64
- id: z.string().min(1, "id는 필수입니다"),
65
- pattern: z.string().startsWith("/", "pattern은 /로 시작해야 합니다"),
66
- kind: RouteKind,
67
-
68
- // HTTP 메서드 (API용)
78
+ ...RouteSpecBase,
79
+ kind: z.literal("page"),
80
+ // page 필수
81
+ componentModule: z.string().min(1, "kind가 'page'인 경우 componentModule은 필수입니다"),
82
+ // page 전용 optional
69
83
  methods: z.array(SpecHttpMethod).optional(),
70
-
71
- // 서버 모듈 (generated route handler)
72
- module: z.string().min(1, "module 경로는 필수입니다"),
73
-
74
- // 페이지 컴포넌트 모듈 (generated)
75
- componentModule: z.string().optional(),
76
-
77
- // 서버 슬롯 (비즈니스 로직)
78
- slotModule: z.string().optional(),
79
-
80
- // 클라이언트 슬롯 (interactive 로직) [NEW]
81
- clientModule: z.string().optional(),
82
-
83
- // Contract 모듈 (API 스키마 정의) [NEW]
84
- contractModule: z.string().optional(),
85
-
86
- // Hydration 설정 [NEW]
87
- hydration: HydrationConfig.optional(),
88
-
89
- // Loader 설정 [NEW]
90
- loader: LoaderConfig.optional(),
91
-
92
- // Streaming SSR 설정 [NEW v0.9.18]
93
- // - true: 이 라우트에 Streaming SSR 적용
94
- // - false: 이 라우트에 전통적 SSR 적용
95
- // - undefined: 서버 설정 따름
96
- streaming: z.boolean().optional(),
97
-
98
- // Layout 체인 [NEW v0.9.33]
99
- // - 페이지에 적용할 레이아웃 모듈 경로 배열
100
- // - 배열 순서: 외부 → 내부 (Root → Parent → Child)
101
84
  layoutChain: z.array(z.string()).optional(),
102
-
103
- // Loading UI 모듈 경로 [NEW v0.9.33]
104
85
  loadingModule: z.string().optional(),
105
-
106
- // Error UI 모듈 경로 [NEW v0.9.33]
107
86
  errorModule: z.string().optional(),
108
87
  })
109
88
  .refine(
110
89
  (route) => {
111
- if (route.kind === "page" && !route.componentModule) {
112
- return false;
113
- }
114
- return true;
115
- },
116
- {
117
- message: "kind가 'page'인 경우 componentModule은 필수입니다",
118
- path: ["componentModule"],
119
- }
120
- )
121
- .refine(
122
- (route) => {
123
- // clientModule이 있으면 hydration.strategy가 none이 아니어야 함
124
90
  if (route.clientModule && route.hydration?.strategy === "none") {
125
91
  return false;
126
92
  }
@@ -132,6 +98,46 @@ export const RouteSpec = z
132
98
  }
133
99
  );
134
100
 
101
+ export type PageRouteSpec = z.infer<typeof PageRouteSpec>;
102
+
103
+ // ---- API 라우트 ----
104
+ export const ApiRouteSpec = z.object({
105
+ ...RouteSpecBase,
106
+ kind: z.literal("api"),
107
+ // api 전용
108
+ methods: z.array(SpecHttpMethod).optional(),
109
+ // page 전용 필드도 optional로 허용 (호환성)
110
+ componentModule: z.string().optional(),
111
+ layoutChain: z.array(z.string()).optional(),
112
+ loadingModule: z.string().optional(),
113
+ errorModule: z.string().optional(),
114
+ });
115
+
116
+ export type ApiRouteSpec = z.infer<typeof ApiRouteSpec>;
117
+
118
+ // ---- discriminatedUnion ----
119
+ export const RouteSpec = z.discriminatedUnion("kind", [
120
+ // PageRouteSpec에 .refine()이 적용되어 있으므로 내부 shape를 직접 사용
121
+ z.object({
122
+ ...RouteSpecBase,
123
+ kind: z.literal("page"),
124
+ componentModule: z.string().min(1, "kind가 'page'인 경우 componentModule은 필수입니다"),
125
+ methods: z.array(SpecHttpMethod).optional(),
126
+ layoutChain: z.array(z.string()).optional(),
127
+ loadingModule: z.string().optional(),
128
+ errorModule: z.string().optional(),
129
+ }),
130
+ z.object({
131
+ ...RouteSpecBase,
132
+ kind: z.literal("api"),
133
+ methods: z.array(SpecHttpMethod).optional(),
134
+ componentModule: z.string().optional(),
135
+ layoutChain: z.array(z.string()).optional(),
136
+ loadingModule: z.string().optional(),
137
+ errorModule: z.string().optional(),
138
+ }),
139
+ ]);
140
+
135
141
  export type RouteSpec = z.infer<typeof RouteSpec>;
136
142
 
137
143
  // ========== Manifest ==========
@@ -166,6 +172,28 @@ export const RoutesManifest = z
166
172
 
167
173
  export type RoutesManifest = z.infer<typeof RoutesManifest>;
168
174
 
175
+ // ========== Assertion Functions ==========
176
+
177
+ /**
178
+ * Asserts that the given route is a page route.
179
+ * After this call, TypeScript narrows the type to PageRouteSpec.
180
+ */
181
+ export function assertPageRoute(route: RouteSpec): asserts route is PageRouteSpec {
182
+ if (route.kind !== "page") {
183
+ throw new Error(`Expected page route, got "${route.kind}" (id: ${route.id})`);
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Asserts that the given route is an API route.
189
+ * After this call, TypeScript narrows the type to ApiRouteSpec.
190
+ */
191
+ export function assertApiRoute(route: RouteSpec): asserts route is ApiRouteSpec {
192
+ if (route.kind !== "api") {
193
+ throw new Error(`Expected API route, got "${route.kind}" (id: ${route.id})`);
194
+ }
195
+ }
196
+
169
197
  // ========== 유틸리티 함수 ==========
170
198
 
171
199
  /**
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Branded Types - Nominal typing for string identifiers
3
+ *
4
+ * Prevents accidental mixing of different ID types at compile time.
5
+ * All branded types are structurally compatible with plain strings at runtime.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * const col = collectionId("blog");
10
+ * const entry = entryId("hello-world");
11
+ * // col and entry are both strings at runtime,
12
+ * // but TypeScript treats them as distinct types.
13
+ * ```
14
+ */
15
+
16
+ declare const __brand: unique symbol;
17
+
18
+ /**
19
+ * Brand utility type - creates nominal types from structural types.
20
+ * The brand exists only at the type level and has zero runtime cost.
21
+ */
22
+ export type Brand<T, B extends string> = T & { readonly [__brand]: B };
23
+
24
+ /** Branded string identifying a content collection */
25
+ export type CollectionId = Brand<string, "CollectionId">;
26
+
27
+ /** Branded string identifying a content entry within a collection */
28
+ export type EntryId = Brand<string, "EntryId">;
29
+
30
+ /** Branded string containing sanitized HTML (XSS-safe) */
31
+ export type SafeHTML = Brand<string, "SafeHTML">;
32
+
33
+ /** Branded string identifying a route in the manifest */
34
+ export type RouteId = Brand<string, "RouteId">;
35
+
36
+ // -- Constructor functions --
37
+
38
+ /** Create a branded CollectionId from a plain string */
39
+ export function collectionId(id: string): CollectionId {
40
+ return id as CollectionId;
41
+ }
42
+
43
+ /** Create a branded EntryId from a plain string */
44
+ export function entryId(id: string): EntryId {
45
+ return id as EntryId;
46
+ }
47
+
48
+ /** Create a branded SafeHTML from a sanitized string */
49
+ export function safeHTML(html: string): SafeHTML {
50
+ return html as SafeHTML;
51
+ }
52
+
53
+ /** Create a branded RouteId from a plain string */
54
+ export function routeId(id: string): RouteId {
55
+ return id as RouteId;
56
+ }
@@ -0,0 +1 @@
1
+ export * from "./branded";
@@ -146,25 +146,25 @@ describe("normalizeForHash", () => {
146
146
  ["a", 2],
147
147
  ["m", 3],
148
148
  ]);
149
- const normalized = normalizeForHash({ data: map }) as any;
149
+ const normalized = normalizeForHash({ data: map }) as Record<string, Record<string, unknown>>;
150
150
 
151
151
  expect(normalized.data.__type__).toBe("Map");
152
- expect(normalized.data.entries[0][0]).toBe("a"); // 정렬됨
152
+ expect((normalized.data.entries as string[][])[0][0]).toBe("a"); // 정렬됨
153
153
  });
154
154
 
155
155
  it("should handle Set as sorted array", () => {
156
156
  const set = new Set([3, 1, 2]);
157
- const normalized = normalizeForHash({ data: set }) as any;
157
+ const normalized = normalizeForHash({ data: set }) as Record<string, Record<string, unknown>>;
158
158
 
159
159
  expect(normalized.data.__type__).toBe("Set");
160
160
  expect(normalized.data.items).toEqual([1, 2, 3]); // 정렬됨
161
161
  });
162
162
 
163
163
  it("should detect circular references", () => {
164
- const obj: any = { a: 1 };
164
+ const obj: Record<string, unknown> = { a: 1 };
165
165
  obj.self = obj;
166
166
 
167
- const normalized = normalizeForHash(obj) as any;
167
+ const normalized = normalizeForHash(obj) as Record<string, unknown>;
168
168
  expect(normalized.self).toBe("__circular__");
169
169
  });
170
170
 
@@ -184,7 +184,7 @@ describe("normalizeForHash", () => {
184
184
 
185
185
  it("should normalize Error objects", () => {
186
186
  const error = new TypeError("test error");
187
- const normalized = normalizeForHash({ error }) as any;
187
+ const normalized = normalizeForHash({ error }) as Record<string, Record<string, unknown>>;
188
188
 
189
189
  expect(normalized.error.__type__).toBe("Error");
190
190
  expect(normalized.error.name).toBe("TypeError");