@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
@@ -11,6 +11,7 @@ import { type FillingDeps, globalDeps } from "./deps";
11
11
  import { ErrorClassifier, formatErrorResponse, ErrorCode } from "../error";
12
12
  import { TIMEOUTS } from "../constants";
13
13
  import { createContract, type ContractDefinition, type ContractInstance } from "../contract";
14
+ import type { WSHandlers } from "./ws";
14
15
  import {
15
16
  type Middleware as RuntimeMiddleware,
16
17
  type MiddlewareEntry,
@@ -40,6 +41,13 @@ export type Guard = BeforeHandleHandler;
40
41
  /** HTTP methods */
41
42
  export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
42
43
 
44
+ /** 미들웨어 플러그인 (여러 lifecycle 단계를 조합) */
45
+ export interface MiddlewarePlugin {
46
+ beforeHandle?: BeforeHandleHandler;
47
+ afterHandle?: AfterHandleHandler;
48
+ mapResponse?: MapResponseHandler;
49
+ }
50
+
43
51
  /** Loader function type - SSR 데이터 로딩 */
44
52
  export type Loader<T = unknown> = (ctx: ManduContext) => T | Promise<T>;
45
53
 
@@ -51,6 +59,17 @@ export interface LoaderOptions<T = unknown> {
51
59
  fallback?: T;
52
60
  }
53
61
 
62
+ /** Loader 캐시/ISR 옵션 */
63
+ export interface LoaderCacheOptions {
64
+ /** 캐시 유지 시간 (초). 0이면 캐시 안 함, Infinity면 영구 */
65
+ revalidate?: number;
66
+ /** 온디맨드 무효화 태그 */
67
+ tags?: string[];
68
+ }
69
+
70
+ /** 렌더링 모드 */
71
+ export type RenderMode = "dynamic" | "isr" | "swr";
72
+
54
73
  /** Loader 타임아웃 에러 */
55
74
  export class LoaderTimeoutError extends Error {
56
75
  constructor(timeout: number) {
@@ -59,9 +78,16 @@ export class LoaderTimeoutError extends Error {
59
78
  }
60
79
  }
61
80
 
81
+ /** Action handler type — named mutation handler */
82
+ export type ActionHandler = (ctx: ManduContext) => Response | Promise<Response>;
83
+
62
84
  interface FillingConfig<TLoaderData = unknown> {
63
85
  handlers: Map<HttpMethod, Handler>;
86
+ actions: Map<string, ActionHandler>;
64
87
  loader?: Loader<TLoaderData>;
88
+ loaderCache?: LoaderCacheOptions;
89
+ renderMode?: RenderMode;
90
+ wsHandlers?: WSHandlers;
65
91
  lifecycle: LifecycleStore;
66
92
  middleware: MiddlewareEntry[];
67
93
  /** Semantic slot metadata */
@@ -71,6 +97,7 @@ interface FillingConfig<TLoaderData = unknown> {
71
97
  export class ManduFilling<TLoaderData = unknown> {
72
98
  private config: FillingConfig<TLoaderData> = {
73
99
  handlers: new Map(),
100
+ actions: new Map(),
74
101
  lifecycle: createLifecycleStore(),
75
102
  middleware: [],
76
103
  semantic: {},
@@ -154,11 +181,56 @@ export class ManduFilling<TLoaderData = unknown> {
154
181
  return { ...this.config.semantic };
155
182
  }
156
183
 
157
- loader(loaderFn: Loader<TLoaderData>): this {
184
+ /**
185
+ * SSR 데이터 로더 등록
186
+ *
187
+ * @example
188
+ * ```typescript
189
+ * // 기본 (캐시 없음)
190
+ * .loader(async (ctx) => ({ posts: await db.getPosts() }))
191
+ *
192
+ * // ISR: 60초 캐시 후 백그라운드 재생성
193
+ * .loader(async (ctx) => ({ posts: await db.getPosts() }), { revalidate: 60 })
194
+ *
195
+ * // 태그 기반 무효화
196
+ * .loader(async (ctx) => ({ posts: await db.getPosts() }), { revalidate: 3600, tags: ["posts"] })
197
+ * ```
198
+ */
199
+ loader(loaderFn: Loader<TLoaderData>, cacheOptions?: LoaderCacheOptions): this {
158
200
  this.config.loader = loaderFn;
201
+ if (cacheOptions) {
202
+ this.config.loaderCache = cacheOptions;
203
+ }
159
204
  return this;
160
205
  }
161
206
 
207
+ /**
208
+ * 렌더링 모드 설정
209
+ *
210
+ * @example
211
+ * ```typescript
212
+ * .render("isr", { revalidate: 120 })
213
+ * .render("swr", { revalidate: 300, tags: ["blog"] })
214
+ * ```
215
+ */
216
+ render(mode: RenderMode, cacheOptions?: LoaderCacheOptions): this {
217
+ this.config.renderMode = mode;
218
+ if (cacheOptions) {
219
+ this.config.loaderCache = { ...this.config.loaderCache, ...cacheOptions };
220
+ }
221
+ return this;
222
+ }
223
+
224
+ /** 현재 캐시/ISR 설정 반환 */
225
+ getCacheOptions(): LoaderCacheOptions | undefined {
226
+ return this.config.loaderCache;
227
+ }
228
+
229
+ /** 현재 렌더링 모드 반환 */
230
+ getRenderMode(): RenderMode {
231
+ return this.config.renderMode ?? "dynamic";
232
+ }
233
+
162
234
  async executeLoader(
163
235
  ctx: ManduContext,
164
236
  options: LoaderOptions<TLoaderData> = {}
@@ -227,6 +299,71 @@ export class ManduFilling<TLoaderData = unknown> {
227
299
  return this;
228
300
  }
229
301
 
302
+ /**
303
+ * Named action — mutation 핸들러 등록
304
+ * POST 요청에서 _action 파라미터로 디스패치됨
305
+ *
306
+ * action 완료 후 loader가 있으면 자동 revalidation:
307
+ * 응답에 { _action, _revalidated, loaderData } 포함
308
+ *
309
+ * @example
310
+ * ```typescript
311
+ * Mandu.filling()
312
+ * .loader(async (ctx) => ({ todos: await db.getTodos() }))
313
+ * .action("create", async (ctx) => {
314
+ * const { title } = await ctx.body<{ title: string }>();
315
+ * await db.createTodo(title);
316
+ * return ctx.ok({ created: true });
317
+ * })
318
+ * .action("delete", async (ctx) => {
319
+ * const { id } = await ctx.body<{ id: string }>();
320
+ * await db.deleteTodo(id);
321
+ * return ctx.ok({ deleted: true });
322
+ * });
323
+ * ```
324
+ */
325
+ action(name: string, handler: ActionHandler): this {
326
+ if (!name || name.trim().length === 0) {
327
+ throw new Error("[Mandu] Action name must be a non-empty string");
328
+ }
329
+ this.config.actions.set(name, handler);
330
+ return this;
331
+ }
332
+
333
+ hasAction(name: string): boolean {
334
+ return this.config.actions.has(name);
335
+ }
336
+
337
+ getActionNames(): string[] {
338
+ return Array.from(this.config.actions.keys());
339
+ }
340
+
341
+ /**
342
+ * WebSocket 핸들러 등록
343
+ *
344
+ * @example
345
+ * ```typescript
346
+ * Mandu.filling()
347
+ * .ws({
348
+ * open(ws) { ws.subscribe("chat"); },
349
+ * message(ws, msg) { ws.publish("chat", msg); },
350
+ * close(ws) { console.log("Disconnected:", ws.id); },
351
+ * });
352
+ * ```
353
+ */
354
+ ws(handlers: WSHandlers): this {
355
+ this.config.wsHandlers = handlers;
356
+ return this;
357
+ }
358
+
359
+ getWSHandlers(): WSHandlers | undefined {
360
+ return this.config.wsHandlers;
361
+ }
362
+
363
+ hasWS(): boolean {
364
+ return !!this.config.wsHandlers;
365
+ }
366
+
230
367
  /**
231
368
  * 요청 시작 훅
232
369
  */
@@ -271,10 +408,27 @@ export class ManduFilling<TLoaderData = unknown> {
271
408
  }
272
409
 
273
410
  /**
274
- * Middleware alias (guard와 동일)
411
+ * 미들웨어 등록 (Guard 함수 또는 lifecycle 객체)
412
+ *
413
+ * @example
414
+ * ```typescript
415
+ * // 단순 guard
416
+ * .use(authGuard)
417
+ *
418
+ * // lifecycle 객체 (beforeHandle + afterHandle)
419
+ * .use(compress())
420
+ * .use(cors({ origin: "https://example.com" }))
421
+ * ```
275
422
  */
276
- use(fn: Guard): this {
277
- return this.guard(fn);
423
+ use(fn: Guard | MiddlewarePlugin): this {
424
+ if (typeof fn === "function") {
425
+ return this.guard(fn);
426
+ }
427
+ // 미들웨어 플러그인 객체: 각 lifecycle 단계를 개별 등록
428
+ if (fn.beforeHandle) this.beforeHandle(fn.beforeHandle);
429
+ if (fn.afterHandle) this.afterHandle(fn.afterHandle);
430
+ if (fn.mapResponse) this.mapResponse(fn.mapResponse);
431
+ return this;
278
432
  }
279
433
 
280
434
  /**
@@ -309,15 +463,23 @@ export class ManduFilling<TLoaderData = unknown> {
309
463
  return this;
310
464
  }
311
465
 
312
- async handle(
313
- request: Request,
314
- params: Record<string, string> = {},
315
- routeContext?: { routeId: string; pattern: string },
316
- options?: ExecuteOptions & { deps?: FillingDeps }
317
- ): Promise<Response> {
318
- const deps = options?.deps ?? globalDeps.get();
319
- const ctx = new ManduContext(request, params, deps);
320
- const method = request.method.toUpperCase() as HttpMethod;
466
+ async handle(
467
+ request: Request,
468
+ params: Record<string, string> = {},
469
+ routeContext?: { routeId: string; pattern: string },
470
+ options?: ExecuteOptions & { deps?: FillingDeps }
471
+ ): Promise<Response> {
472
+ const deps = options?.deps ?? globalDeps.get();
473
+ const normalizedRequest = await applyMethodOverride(request);
474
+ const ctx = new ManduContext(normalizedRequest, params, deps);
475
+ const method = normalizedRequest.method.toUpperCase() as HttpMethod;
476
+
477
+ // Action 디스패치: POST/PUT/PATCH/DELETE + 등록된 action이 있을 때
478
+ if (this.config.actions.size > 0 && method !== "GET" && method !== "HEAD" && method !== "OPTIONS") {
479
+ const actionResult = await this.tryDispatchAction(ctx, routeContext, options);
480
+ if (actionResult) return actionResult;
481
+ }
482
+
321
483
  const handler = this.config.handlers.get(method);
322
484
  if (!handler) {
323
485
  return ctx.json({ status: "error", message: `Method ${method} not allowed`, allowed: Array.from(this.config.handlers.keys()) }, 405);
@@ -339,6 +501,113 @@ export class ManduFilling<TLoaderData = unknown> {
339
501
  return composed(ctx);
340
502
  };
341
503
  return executeLifecycle(lifecycleWithDefaults, ctx, runHandler, options);
504
+ }
505
+
506
+ /**
507
+ * Action 디스패치 시도
508
+ * _action 파라미터가 있고 매칭되는 action이 있으면 실행 + revalidation
509
+ * 매칭 안 되면 null 반환 → 기존 핸들러로 fallback
510
+ */
511
+ private async tryDispatchAction(
512
+ ctx: ManduContext,
513
+ routeContext?: { routeId: string; pattern: string },
514
+ options?: ExecuteOptions & { deps?: FillingDeps }
515
+ ): Promise<Response | null> {
516
+ const actionName = await this.resolveActionName(ctx);
517
+ if (!actionName) return null;
518
+
519
+ const actionHandler = this.config.actions.get(actionName);
520
+ if (!actionHandler) return null;
521
+
522
+ // action 이름을 ctx에 저장 (다른 lifecycle 훅에서 접근 가능)
523
+ ctx.set("_actionName", actionName);
524
+
525
+ const lifecycleWithDefaults = this.createLifecycleWithDefaults(routeContext);
526
+
527
+ const runAction = async () => {
528
+ if (this.config.middleware.length === 0) {
529
+ return actionHandler(ctx);
530
+ }
531
+ const chain: MiddlewareEntry[] = [
532
+ ...this.config.middleware,
533
+ { fn: async (innerCtx) => actionHandler(innerCtx), name: `action:${actionName}`, isAsync: true },
534
+ ];
535
+ return compose(chain)(ctx);
536
+ };
537
+
538
+ const actionResponse = await executeLifecycle(lifecycleWithDefaults, ctx, runAction, options);
539
+
540
+ // Action 성공 + loader 있으면 자동 revalidation
541
+ if (actionResponse.ok && this.config.loader) {
542
+ // fetch 요청(JS 환경)만 revalidation JSON 반환
543
+ // HTML form 제출(Accept: text/html)은 action 응답 그대로 반환
544
+ const accept = ctx.headers.get("accept") ?? "";
545
+ const isFetchRequest = accept.includes("application/json")
546
+ || ctx.headers.get("x-requested-with") === "ManduAction";
547
+
548
+ if (isFetchRequest) {
549
+ try {
550
+ const freshData = await this.executeLoader(ctx);
551
+
552
+ // action 응답 본문 보존 (actionData로 포함)
553
+ let actionData: unknown = null;
554
+ const actionContentType = actionResponse.headers.get("content-type") ?? "";
555
+ if (actionContentType.includes("application/json")) {
556
+ actionData = await actionResponse.clone().json().catch(() => null);
557
+ }
558
+
559
+ const revalidatedResponse = ctx.json({
560
+ _action: actionName,
561
+ _revalidated: true,
562
+ actionData,
563
+ loaderData: freshData,
564
+ });
565
+
566
+ // action 응답의 Set-Cookie 헤더 보존
567
+ const setCookies = actionResponse.headers.getSetCookie?.() ?? [];
568
+ for (const cookie of setCookies) {
569
+ revalidatedResponse.headers.append("Set-Cookie", cookie);
570
+ }
571
+
572
+ return revalidatedResponse;
573
+ } catch {
574
+ // Loader 실패 시 action 결과만 반환
575
+ return actionResponse;
576
+ }
577
+ }
578
+ }
579
+
580
+ return actionResponse;
581
+ }
582
+
583
+ /**
584
+ * 요청에서 action 이름 추출
585
+ * 우선순위: body._action > URL ?_action=
586
+ * (body를 우선하여 URL query 조작에 의한 action hijacking 방지)
587
+ */
588
+ private async resolveActionName(ctx: ManduContext): Promise<string | null> {
589
+ // 1. Request body에서 먼저 확인 (form 제어 하에 있으므로 더 안전)
590
+ const contentType = ctx.headers.get("content-type") ?? "";
591
+ try {
592
+ if (contentType.includes("application/json")) {
593
+ const cloned = ctx.request.clone();
594
+ const body = await cloned.json() as Record<string, unknown>;
595
+ if (typeof body._action === "string") return body._action;
596
+ } else if (contentType.includes("form")) {
597
+ const cloned = ctx.request.clone();
598
+ const formData = await cloned.formData();
599
+ const action = formData.get("_action");
600
+ if (typeof action === "string") return action;
601
+ }
602
+ } catch {
603
+ // 파싱 실패 시 query fallback
604
+ }
605
+
606
+ // 2. URL query parameter (body에 없을 때만)
607
+ const fromQuery = ctx.query._action;
608
+ if (fromQuery) return fromQuery;
609
+
610
+ return null;
342
611
  }
343
612
 
344
613
  private createLifecycleWithDefaults(routeContext?: { routeId: string; pattern: string }): LifecycleStore {
@@ -390,7 +659,60 @@ export class ManduFilling<TLoaderData = unknown> {
390
659
  hasMethod(method: HttpMethod): boolean {
391
660
  return this.config.handlers.has(method);
392
661
  }
393
- }
662
+ }
663
+
664
+ const OVERRIDABLE_METHODS = new Set<HttpMethod>(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
665
+
666
+ async function applyMethodOverride(request: Request): Promise<Request> {
667
+ if (request.method.toUpperCase() !== "POST") {
668
+ return request;
669
+ }
670
+
671
+ const override = await detectMethodOverride(request);
672
+ if (!override || override === "POST") {
673
+ return request;
674
+ }
675
+
676
+ return new Request(request, { method: override });
677
+ }
678
+
679
+ async function detectMethodOverride(request: Request): Promise<HttpMethod | null> {
680
+ const headerOverride = normalizeOverrideMethod(request.headers.get("X-HTTP-Method-Override"));
681
+ if (headerOverride) return headerOverride;
682
+
683
+ const url = new URL(request.url);
684
+ const queryOverride = normalizeOverrideMethod(url.searchParams.get("_method"));
685
+ if (queryOverride) return queryOverride;
686
+
687
+ const contentType = request.headers.get("content-type") ?? "";
688
+ const cloned = request.clone();
689
+
690
+ try {
691
+ if (contentType.includes("application/json")) {
692
+ const body = await cloned.json() as { _method?: unknown };
693
+ return normalizeOverrideMethod(typeof body?._method === "string" ? body._method : null);
694
+ }
695
+
696
+ if (
697
+ contentType.includes("application/x-www-form-urlencoded") ||
698
+ contentType.includes("multipart/form-data")
699
+ ) {
700
+ const form = await cloned.formData();
701
+ const override = form.get("_method");
702
+ return normalizeOverrideMethod(typeof override === "string" ? override : null);
703
+ }
704
+ } catch {
705
+ return null;
706
+ }
707
+
708
+ return null;
709
+ }
710
+
711
+ function normalizeOverrideMethod(value: string | null): HttpMethod | null {
712
+ if (!value) return null;
713
+ const method = value.toUpperCase() as HttpMethod;
714
+ return OVERRIDABLE_METHODS.has(method) ? method : null;
715
+ }
394
716
 
395
717
  /**
396
718
  * Mandu Filling factory functions
@@ -7,7 +7,11 @@
7
7
  export { ManduContext, ValidationError, CookieManager } from "./context";
8
8
  export type { CookieOptions } from "./context";
9
9
  export { ManduFilling, ManduFillingFactory, LoaderTimeoutError } from "./filling";
10
- export type { Handler, Guard, HttpMethod, Loader, LoaderOptions } from "./filling";
10
+ export type { Handler, Guard, ActionHandler, HttpMethod, Loader, LoaderOptions, LoaderCacheOptions, RenderMode, MiddlewarePlugin } from "./filling";
11
+ export { createCookieSessionStorage, Session } from "./session";
12
+ export type { SessionStorage, SessionData, CookieSessionOptions } from "./session";
13
+ export { wrapBunWebSocket } from "./ws";
14
+ export type { WSHandlers, ManduWebSocket, WSUpgradeData } from "./ws";
11
15
  export { SSEConnection, createSSEConnection } from "./sse";
12
16
  export type { SSEOptions, SSESendOptions, SSECleanup } from "./sse";
13
17
  export { resolveResumeCursor, catchupFromCursor, mergeUniqueById } from "./sse-catchup";
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Mandu Session Storage
3
+ * 쿠키 기반 서버 사이드 세션 관리
4
+ */
5
+
6
+ import { type CookieManager, type CookieOptions } from "./context";
7
+
8
+ // ========== Types ==========
9
+
10
+ export interface SessionData {
11
+ [key: string]: unknown;
12
+ }
13
+
14
+ export interface SessionStorage {
15
+ /** 요청의 쿠키에서 세션 가져오기 */
16
+ getSession(cookies: CookieManager): Promise<Session>;
17
+ /** 세션을 직렬화하여 Set-Cookie 헤더 문자열 반환 */
18
+ commitSession(session: Session): Promise<string>;
19
+ /** 세션 파기 (쿠키 삭제) */
20
+ destroySession(session: Session): Promise<string>;
21
+ }
22
+
23
+ export interface CookieSessionOptions {
24
+ cookie: {
25
+ /** 쿠키 이름 (기본: "__session") */
26
+ name?: string;
27
+ /** HMAC 서명 시크릿 */
28
+ secrets: string[];
29
+ /** 기본 쿠키 옵션 */
30
+ httpOnly?: boolean;
31
+ secure?: boolean;
32
+ sameSite?: "strict" | "lax" | "none";
33
+ maxAge?: number;
34
+ path?: string;
35
+ domain?: string;
36
+ };
37
+ }
38
+
39
+ // ========== Session Class ==========
40
+
41
+ export class Session {
42
+ private data: SessionData;
43
+ private flash: Map<string, unknown> = new Map();
44
+ readonly id: string;
45
+
46
+ constructor(data: SessionData = {}, id?: string) {
47
+ this.data = { ...data };
48
+ this.id = id ?? crypto.randomUUID();
49
+ }
50
+
51
+ get<T = unknown>(key: string): T | undefined {
52
+ // flash 데이터는 한번 읽으면 제거
53
+ if (this.flash.has(key)) {
54
+ const value = this.flash.get(key);
55
+ this.flash.delete(key);
56
+ return value as T;
57
+ }
58
+ return this.data[key] as T | undefined;
59
+ }
60
+
61
+ set(key: string, value: unknown): void {
62
+ this.data[key] = value;
63
+ }
64
+
65
+ has(key: string): boolean {
66
+ return key in this.data || this.flash.has(key);
67
+ }
68
+
69
+ unset(key: string): void {
70
+ delete this.data[key];
71
+ }
72
+
73
+ /**
74
+ * Flash 메시지 — 다음 요청에서 한번만 읽을 수 있는 데이터
75
+ * 로그인 성공 메시지, 에러 알림 등에 사용
76
+ */
77
+ setFlash(key: string, value: unknown): void {
78
+ this.flash.set(key, value);
79
+ // flash 데이터도 직렬화에 포함
80
+ this.data[`__flash_${key}`] = value;
81
+ }
82
+
83
+ /** 내부 직렬화용 */
84
+ toJSON(): SessionData {
85
+ return { ...this.data };
86
+ }
87
+
88
+ /** flash 데이터 복원 */
89
+ static fromJSON(data: SessionData): Session {
90
+ const session = new Session();
91
+ const flashKeys: string[] = [];
92
+
93
+ for (const [key, value] of Object.entries(data)) {
94
+ if (key.startsWith("__flash_")) {
95
+ const realKey = key.slice(8);
96
+ session.flash.set(realKey, value);
97
+ flashKeys.push(key);
98
+ } else {
99
+ session.data[key] = value;
100
+ }
101
+ }
102
+
103
+ // flash 키는 data에서 제거 (한번 복원되면 끝)
104
+ for (const key of flashKeys) {
105
+ delete session.data[key];
106
+ }
107
+
108
+ return session;
109
+ }
110
+ }
111
+
112
+ // ========== Cookie Session Storage ==========
113
+
114
+ /**
115
+ * 쿠키 기반 세션 스토리지 생성
116
+ *
117
+ * @example
118
+ * ```typescript
119
+ * import { createCookieSessionStorage } from "@mandujs/core";
120
+ *
121
+ * const sessionStorage = createCookieSessionStorage({
122
+ * cookie: {
123
+ * name: "__session",
124
+ * secrets: [process.env.SESSION_SECRET!],
125
+ * httpOnly: true,
126
+ * secure: true,
127
+ * sameSite: "lax",
128
+ * maxAge: 60 * 60 * 24, // 1일
129
+ * },
130
+ * });
131
+ *
132
+ * // filling에서 사용
133
+ * .action("login", async (ctx) => {
134
+ * const session = await sessionStorage.getSession(ctx.cookies);
135
+ * session.set("userId", user.id);
136
+ * session.setFlash("message", "로그인 성공!");
137
+ * const setCookie = await sessionStorage.commitSession(session);
138
+ * return ctx.redirect("/dashboard", {
139
+ * headers: { "Set-Cookie": setCookie },
140
+ * });
141
+ * });
142
+ * ```
143
+ */
144
+ export function createCookieSessionStorage(options: CookieSessionOptions): SessionStorage {
145
+ const {
146
+ name = "__session",
147
+ secrets,
148
+ httpOnly = true,
149
+ secure = process.env.NODE_ENV === "production",
150
+ sameSite = "lax",
151
+ maxAge = 86400,
152
+ path = "/",
153
+ domain,
154
+ } = options.cookie;
155
+
156
+ if (!secrets.length) {
157
+ throw new Error("[Mandu Session] At least one secret is required");
158
+ }
159
+
160
+ const cookieOptions: CookieOptions = {
161
+ httpOnly,
162
+ secure,
163
+ sameSite,
164
+ maxAge,
165
+ path,
166
+ domain,
167
+ };
168
+
169
+ return {
170
+ async getSession(cookies: CookieManager): Promise<Session> {
171
+ // Secret rotation: 모든 시크릿으로 검증 시도 (서명은 항상 secrets[0]으로)
172
+ for (const secret of secrets) {
173
+ const raw = await cookies.getSigned(name, secret);
174
+ if (typeof raw === "string" && raw.length > 0) {
175
+ try {
176
+ const data = JSON.parse(raw) as SessionData;
177
+ return Session.fromJSON(data);
178
+ } catch {
179
+ continue;
180
+ }
181
+ }
182
+ }
183
+ return new Session();
184
+ },
185
+
186
+ async commitSession(session: Session): Promise<string> {
187
+ const value = JSON.stringify(session.toJSON());
188
+ // 서명된 쿠키로 직렬화
189
+ const encoder = new TextEncoder();
190
+ const key = await crypto.subtle.importKey(
191
+ "raw",
192
+ encoder.encode(secrets[0]),
193
+ { name: "HMAC", hash: "SHA-256" },
194
+ false,
195
+ ["sign"]
196
+ );
197
+ const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(value));
198
+ const sigBase64 = btoa(String.fromCharCode(...new Uint8Array(signature))).replace(/=+$/, "");
199
+
200
+ const cookieValue = `${value}.${sigBase64}`;
201
+ const parts = [`${name}=${encodeURIComponent(cookieValue)}`];
202
+ if (cookieOptions.path) parts.push(`Path=${cookieOptions.path}`);
203
+ if (cookieOptions.domain) parts.push(`Domain=${cookieOptions.domain}`);
204
+ if (cookieOptions.maxAge) parts.push(`Max-Age=${cookieOptions.maxAge}`);
205
+ if (cookieOptions.httpOnly) parts.push("HttpOnly");
206
+ if (cookieOptions.secure) parts.push("Secure");
207
+ if (cookieOptions.sameSite) parts.push(`SameSite=${cookieOptions.sameSite}`);
208
+
209
+ return parts.join("; ");
210
+ },
211
+
212
+ async destroySession(_session: Session): Promise<string> {
213
+ return `${name}=; Path=${path}; Max-Age=0; HttpOnly${secure ? "; Secure" : ""}`;
214
+ },
215
+ };
216
+ }