@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.
- package/README.ko.md +0 -14
- package/package.json +4 -1
- package/src/brain/architecture/analyzer.ts +4 -4
- package/src/brain/doctor/analyzer.ts +18 -14
- package/src/bundler/build.test.ts +127 -0
- package/src/bundler/build.ts +291 -113
- package/src/bundler/css.ts +20 -5
- package/src/bundler/dev.ts +55 -2
- package/src/bundler/prerender.ts +195 -0
- package/src/change/snapshot.ts +4 -23
- package/src/change/types.ts +2 -3
- package/src/client/Form.tsx +105 -0
- package/src/client/__tests__/use-sse.test.ts +153 -0
- package/src/client/hooks.ts +105 -6
- package/src/client/index.ts +35 -6
- package/src/client/router.ts +670 -433
- package/src/client/rpc.ts +140 -0
- package/src/client/runtime.ts +24 -21
- package/src/client/use-fetch.ts +239 -0
- package/src/client/use-head.ts +197 -0
- package/src/client/use-sse.ts +378 -0
- package/src/components/Image.tsx +162 -0
- package/src/config/mandu.ts +5 -0
- package/src/config/validate.ts +34 -0
- package/src/content/index.ts +5 -1
- package/src/devtools/client/catchers/error-catcher.ts +17 -0
- package/src/devtools/client/catchers/network-proxy.ts +390 -367
- package/src/devtools/client/components/kitchen-root.tsx +479 -467
- package/src/devtools/client/components/panel/diff-viewer.tsx +219 -0
- package/src/devtools/client/components/panel/guard-panel.tsx +374 -244
- package/src/devtools/client/components/panel/index.ts +45 -32
- package/src/devtools/client/components/panel/panel-container.tsx +332 -312
- package/src/devtools/client/components/panel/preview-panel.tsx +188 -0
- package/src/devtools/client/state-manager.ts +535 -478
- package/src/devtools/design-tokens.ts +265 -264
- package/src/devtools/types.ts +345 -319
- package/src/filling/context.ts +65 -0
- package/src/filling/filling.ts +336 -14
- package/src/filling/index.ts +5 -1
- package/src/filling/session.ts +216 -0
- package/src/filling/ws.ts +78 -0
- package/src/generator/generate.ts +2 -2
- package/src/guard/auto-correct.ts +0 -29
- package/src/guard/check.ts +14 -31
- package/src/guard/presets/index.ts +296 -294
- package/src/guard/rules.ts +15 -19
- package/src/guard/validator.ts +834 -834
- package/src/index.ts +5 -1
- package/src/island/index.ts +373 -304
- package/src/kitchen/api/contract-api.ts +225 -0
- package/src/kitchen/api/diff-parser.ts +108 -0
- package/src/kitchen/api/file-api.ts +273 -0
- package/src/kitchen/api/guard-api.ts +83 -0
- package/src/kitchen/api/guard-decisions.ts +100 -0
- package/src/kitchen/api/routes-api.ts +50 -0
- package/src/kitchen/index.ts +21 -0
- package/src/kitchen/kitchen-handler.ts +256 -0
- package/src/kitchen/kitchen-ui.ts +1732 -0
- package/src/kitchen/stream/activity-sse.ts +145 -0
- package/src/kitchen/stream/file-tailer.ts +99 -0
- package/src/middleware/compress.ts +62 -0
- package/src/middleware/cors.ts +47 -0
- package/src/middleware/index.ts +10 -0
- package/src/middleware/jwt.ts +134 -0
- package/src/middleware/logger.ts +58 -0
- package/src/middleware/timeout.ts +55 -0
- package/src/paths.ts +0 -4
- package/src/plugins/hooks.ts +64 -0
- package/src/plugins/index.ts +3 -0
- package/src/plugins/types.ts +5 -0
- package/src/report/build.ts +0 -6
- package/src/resource/__tests__/backward-compat.test.ts +0 -1
- package/src/router/fs-patterns.ts +11 -1
- package/src/router/fs-routes.ts +78 -14
- package/src/router/fs-scanner.ts +2 -2
- package/src/router/fs-types.ts +2 -1
- package/src/runtime/adapter-bun.ts +62 -0
- package/src/runtime/adapter.ts +47 -0
- package/src/runtime/cache.ts +310 -0
- package/src/runtime/handler.ts +65 -0
- package/src/runtime/image-handler.ts +195 -0
- package/src/runtime/index.ts +12 -0
- package/src/runtime/middleware.ts +263 -0
- package/src/runtime/server.ts +686 -92
- package/src/runtime/ssr.ts +55 -29
- package/src/runtime/streaming-ssr.ts +106 -82
- package/src/spec/index.ts +0 -1
- package/src/spec/schema.ts +1 -0
- package/src/testing/index.ts +144 -0
- package/src/watcher/watcher.ts +27 -1
- package/src/spec/lock.ts +0 -56
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/filling/filling.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
320
|
-
const
|
|
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
|
package/src/filling/index.ts
CHANGED
|
@@ -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";
|