@mandujs/core 0.18.22 → 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 (91) 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/context.ts +65 -0
  38. package/src/filling/filling.ts +336 -14
  39. package/src/filling/index.ts +5 -1
  40. package/src/filling/session.ts +216 -0
  41. package/src/filling/ws.ts +78 -0
  42. package/src/generator/generate.ts +2 -2
  43. package/src/guard/auto-correct.ts +0 -29
  44. package/src/guard/check.ts +14 -31
  45. package/src/guard/presets/index.ts +296 -294
  46. package/src/guard/rules.ts +15 -19
  47. package/src/guard/validator.ts +834 -834
  48. package/src/index.ts +5 -1
  49. package/src/island/index.ts +373 -304
  50. package/src/kitchen/api/contract-api.ts +225 -0
  51. package/src/kitchen/api/diff-parser.ts +108 -0
  52. package/src/kitchen/api/file-api.ts +273 -0
  53. package/src/kitchen/api/guard-api.ts +83 -0
  54. package/src/kitchen/api/guard-decisions.ts +100 -0
  55. package/src/kitchen/api/routes-api.ts +50 -0
  56. package/src/kitchen/index.ts +21 -0
  57. package/src/kitchen/kitchen-handler.ts +256 -0
  58. package/src/kitchen/kitchen-ui.ts +1732 -0
  59. package/src/kitchen/stream/activity-sse.ts +145 -0
  60. package/src/kitchen/stream/file-tailer.ts +99 -0
  61. package/src/middleware/compress.ts +62 -0
  62. package/src/middleware/cors.ts +47 -0
  63. package/src/middleware/index.ts +10 -0
  64. package/src/middleware/jwt.ts +134 -0
  65. package/src/middleware/logger.ts +58 -0
  66. package/src/middleware/timeout.ts +55 -0
  67. package/src/paths.ts +0 -4
  68. package/src/plugins/hooks.ts +64 -0
  69. package/src/plugins/index.ts +3 -0
  70. package/src/plugins/types.ts +5 -0
  71. package/src/report/build.ts +0 -6
  72. package/src/resource/__tests__/backward-compat.test.ts +0 -1
  73. package/src/router/fs-patterns.ts +11 -1
  74. package/src/router/fs-routes.ts +78 -14
  75. package/src/router/fs-scanner.ts +2 -2
  76. package/src/router/fs-types.ts +2 -1
  77. package/src/runtime/adapter-bun.ts +62 -0
  78. package/src/runtime/adapter.ts +47 -0
  79. package/src/runtime/cache.ts +310 -0
  80. package/src/runtime/handler.ts +65 -0
  81. package/src/runtime/image-handler.ts +195 -0
  82. package/src/runtime/index.ts +12 -0
  83. package/src/runtime/middleware.ts +263 -0
  84. package/src/runtime/server.ts +686 -92
  85. package/src/runtime/ssr.ts +55 -29
  86. package/src/runtime/streaming-ssr.ts +106 -82
  87. package/src/spec/index.ts +0 -1
  88. package/src/spec/schema.ts +1 -0
  89. package/src/testing/index.ts +144 -0
  90. package/src/watcher/watcher.ts +27 -1
  91. package/src/spec/lock.ts +0 -56
@@ -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 ==========
@@ -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";