@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 +1 -1
- package/src/filling/context.ts +65 -0
- package/src/runtime/server.ts +26 -11
package/package.json
CHANGED
package/src/filling/context.ts
CHANGED
|
@@ -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 ==========
|
package/src/runtime/server.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ----------
|