@mandujs/core 0.18.22 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.18.22",
3
+ "version": "0.19.0",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -222,6 +222,71 @@ export class CookieManager {
222
222
  hasPendingCookies(): boolean {
223
223
  return this.responseCookies.size > 0;
224
224
  }
225
+
226
+ /**
227
+ * 서명된 쿠키 읽기 (HMAC-SHA256 검증)
228
+ * @returns 값(검증 성공), null(쿠키 없음), false(서명 불일치)
229
+ * @example
230
+ * const userId = await ctx.cookies.getSigned('session', SECRET);
231
+ * if (userId === false) return ctx.unauthorized('Invalid session');
232
+ * if (userId === null) return ctx.unauthorized('No session');
233
+ */
234
+ async getSigned(name: string, secret: string): Promise<string | null | false> {
235
+ const raw = this.get(name);
236
+ if (!raw) return null;
237
+ const dotIndex = raw.lastIndexOf(".");
238
+ if (dotIndex === -1) return false;
239
+ const value = raw.slice(0, dotIndex);
240
+ const signature = raw.slice(dotIndex + 1);
241
+ if (!value || !signature) return false;
242
+ const expected = await hmacSign(value, secret);
243
+ return signature === expected ? decodeURIComponent(value) : false;
244
+ }
245
+
246
+ /**
247
+ * 서명된 쿠키 설정 (HMAC-SHA256)
248
+ * @example
249
+ * await ctx.cookies.setSigned('session', userId, SECRET, { httpOnly: true });
250
+ */
251
+ async setSigned(name: string, value: string, secret: string, options?: CookieOptions): Promise<void> {
252
+ const encoded = encodeURIComponent(value);
253
+ const signature = await hmacSign(encoded, secret);
254
+ this.set(name, `${encoded}.${signature}`, options);
255
+ }
256
+
257
+ /**
258
+ * JSON 쿠키를 스키마로 파싱 + 검증 (Zod 호환 duck typing)
259
+ * @returns 파싱된 값 또는 null(쿠키 없음/파싱 실패/검증 실패)
260
+ * @example
261
+ * const prefs = ctx.cookies.getParsed('prefs', z.object({ theme: z.string() }));
262
+ */
263
+ getParsed<T>(name: string, schema: { parse: (v: unknown) => T }): T | null {
264
+ const raw = this.get(name);
265
+ if (raw == null) return null;
266
+ try {
267
+ const decoded = decodeURIComponent(raw);
268
+ const parsed = JSON.parse(decoded);
269
+ return schema.parse(parsed);
270
+ } catch {
271
+ return null;
272
+ }
273
+ }
274
+ }
275
+
276
+ /**
277
+ * HMAC-SHA256 서명 생성 (WebCrypto API)
278
+ */
279
+ async function hmacSign(data: string, secret: string): Promise<string> {
280
+ const encoder = new TextEncoder();
281
+ const key = await crypto.subtle.importKey(
282
+ "raw",
283
+ encoder.encode(secret),
284
+ { name: "HMAC", hash: "SHA-256" },
285
+ false,
286
+ ["sign"]
287
+ );
288
+ const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
289
+ return btoa(String.fromCharCode(...new Uint8Array(sig))).replace(/=+$/, "");
225
290
  }
226
291
 
227
292
  // ========== ManduContext ==========
@@ -2,7 +2,7 @@ import type { Server } from "bun";
2
2
  import type { RoutesManifest, RouteSpec, HydrationConfig } from "../spec/schema";
3
3
  import type { BundleManifest } from "../bundler/types";
4
4
  import type { ManduFilling } 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 방식 (레거시 호환)
@@ -924,11 +929,17 @@ async function loadPageData(
924
929
  registry.registerRouteComponent(route.id, component as RouteComponent);
925
930
 
926
931
  // filling이 있으면 loader 실행
932
+ let cookies: CookieManager | undefined;
927
933
  const filling = typeof exported === "object" && exported !== null ? (exportedObj as Record<string, unknown>)?.filling as ManduFilling | null : null;
928
934
  if (filling?.hasLoader?.()) {
929
935
  const ctx = new ManduContext(req, params);
930
936
  loaderData = await filling.executeLoader(ctx);
937
+ if (ctx.cookies.hasPendingCookies()) {
938
+ cookies = ctx.cookies;
939
+ }
931
940
  }
941
+
942
+ return ok({ loaderData, cookies });
932
943
  } catch (error) {
933
944
  const pageError = createPageLoadErrorResponse(
934
945
  route.id,
@@ -953,7 +964,8 @@ async function renderPageSSR(
953
964
  params: Record<string, string>,
954
965
  loaderData: unknown,
955
966
  url: string,
956
- registry: ServerRegistry
967
+ registry: ServerRegistry,
968
+ cookies?: CookieManager
957
969
  ): Promise<Result<Response>> {
958
970
  const settings = registry.settings;
959
971
  const defaultAppCreator = createDefaultAppFactory(registry);
@@ -982,7 +994,7 @@ async function renderPageSSR(
982
994
  : settings.streaming;
983
995
 
984
996
  if (useStreaming) {
985
- return ok(await renderStreamingResponse(app, {
997
+ const streamingResponse = await renderStreamingResponse(app, {
986
998
  title: `${route.id} - Mandu`,
987
999
  isDev: settings.isDev,
988
1000
  hmrPort: settings.hmrPort,
@@ -1007,11 +1019,12 @@ async function renderPageSSR(
1007
1019
  });
1008
1020
  }
1009
1021
  },
1010
- }));
1022
+ });
1023
+ return ok(cookies ? cookies.applyToResponse(streamingResponse) : streamingResponse);
1011
1024
  }
1012
1025
 
1013
1026
  // 기존 renderToString 방식
1014
- return ok(renderSSR(app, {
1027
+ const ssrResponse = renderSSR(app, {
1015
1028
  title: `${route.id} - Mandu`,
1016
1029
  isDev: settings.isDev,
1017
1030
  hmrPort: settings.hmrPort,
@@ -1022,7 +1035,8 @@ async function renderPageSSR(
1022
1035
  enableClientRouter: true,
1023
1036
  routePattern: route.pattern,
1024
1037
  cssPath: settings.cssPath,
1025
- }));
1038
+ });
1039
+ return ok(cookies ? cookies.applyToResponse(ssrResponse) : ssrResponse);
1026
1040
  } catch (error) {
1027
1041
  const ssrError = createSSRErrorResponse(
1028
1042
  route.id,
@@ -1052,21 +1066,22 @@ async function handlePageRoute(
1052
1066
  return loadResult;
1053
1067
  }
1054
1068
 
1055
- const { loaderData } = loadResult.value;
1069
+ const { loaderData, cookies } = loadResult.value;
1056
1070
 
1057
1071
  // 2. Client-side Routing: 데이터만 반환 (JSON)
1058
1072
  if (url.searchParams.has("_data")) {
1059
- return ok(Response.json({
1073
+ const jsonResponse = Response.json({
1060
1074
  routeId: route.id,
1061
1075
  pattern: route.pattern,
1062
1076
  params,
1063
1077
  loaderData: loaderData ?? null,
1064
1078
  timestamp: Date.now(),
1065
- }));
1079
+ });
1080
+ return ok(cookies ? cookies.applyToResponse(jsonResponse) : jsonResponse);
1066
1081
  }
1067
1082
 
1068
1083
  // 3. SSR 렌더링
1069
- return renderPageSSR(route, params, loaderData, req.url, registry);
1084
+ return renderPageSSR(route, params, loaderData, req.url, registry, cookies);
1070
1085
  }
1071
1086
 
1072
1087
  // ---------- Main Request Dispatcher ----------