@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.
- 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/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 +662 -83
- 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/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";
|
|
@@ -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
|
+
}
|