@mandujs/core 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -208,6 +208,7 @@ export { islandRegistry, hydratedRoots };
208
208
 
209
209
  /**
210
210
  * React shim 소스 생성 (import map용)
211
+ * 주의: export *는 Bun bundler에서 제대로 작동하지 않으므로 명시적 export 필요
211
212
  */
212
213
  function generateReactShimSource(): string {
213
214
  return `
@@ -215,42 +216,139 @@ function generateReactShimSource(): string {
215
216
  * Mandu React Shim (Generated)
216
217
  * import map을 통해 bare specifier 해결
217
218
  */
218
- import * as React from 'react';
219
- export * from 'react';
219
+ import React, {
220
+ // Core
221
+ createElement,
222
+ cloneElement,
223
+ createContext,
224
+ createRef,
225
+ forwardRef,
226
+ isValidElement,
227
+ memo,
228
+ lazy,
229
+ // Hooks
230
+ useState,
231
+ useEffect,
232
+ useContext,
233
+ useReducer,
234
+ useCallback,
235
+ useMemo,
236
+ useRef,
237
+ useLayoutEffect,
238
+ useImperativeHandle,
239
+ useDebugValue,
240
+ useDeferredValue,
241
+ useTransition,
242
+ useId,
243
+ useSyncExternalStore,
244
+ useInsertionEffect,
245
+ // Components
246
+ Fragment,
247
+ Suspense,
248
+ StrictMode,
249
+ Profiler,
250
+ // Types
251
+ Component,
252
+ PureComponent,
253
+ Children,
254
+ } from 'react';
255
+
256
+ // Named exports
257
+ export {
258
+ createElement,
259
+ cloneElement,
260
+ createContext,
261
+ createRef,
262
+ forwardRef,
263
+ isValidElement,
264
+ memo,
265
+ lazy,
266
+ useState,
267
+ useEffect,
268
+ useContext,
269
+ useReducer,
270
+ useCallback,
271
+ useMemo,
272
+ useRef,
273
+ useLayoutEffect,
274
+ useImperativeHandle,
275
+ useDebugValue,
276
+ useDeferredValue,
277
+ useTransition,
278
+ useId,
279
+ useSyncExternalStore,
280
+ useInsertionEffect,
281
+ Fragment,
282
+ Suspense,
283
+ StrictMode,
284
+ Profiler,
285
+ Component,
286
+ PureComponent,
287
+ Children,
288
+ };
289
+
290
+ // Default export
220
291
  export default React;
221
292
  `;
222
293
  }
223
294
 
224
295
  /**
225
296
  * React DOM shim 소스 생성
297
+ * 주의: export *는 Bun bundler에서 제대로 작동하지 않으므로 명시적 export 필요
226
298
  */
227
299
  function generateReactDOMShimSource(): string {
228
300
  return `
229
301
  /**
230
302
  * Mandu React DOM Shim (Generated)
231
303
  */
232
- import * as ReactDOM from 'react-dom';
233
- export * from 'react-dom';
304
+ import ReactDOM, {
305
+ createPortal,
306
+ flushSync,
307
+ render,
308
+ unmountComponentAtNode,
309
+ findDOMNode,
310
+ hydrate,
311
+ version,
312
+ } from 'react-dom';
313
+
314
+ // Named exports
315
+ export {
316
+ createPortal,
317
+ flushSync,
318
+ render,
319
+ unmountComponentAtNode,
320
+ findDOMNode,
321
+ hydrate,
322
+ version,
323
+ };
324
+
325
+ // Default export
234
326
  export default ReactDOM;
235
327
  `;
236
328
  }
237
329
 
238
330
  /**
239
331
  * React DOM Client shim 소스 생성
332
+ * 주의: export *는 Bun bundler에서 제대로 작동하지 않으므로 명시적 export 필요
240
333
  */
241
334
  function generateReactDOMClientShimSource(): string {
242
335
  return `
243
336
  /**
244
337
  * Mandu React DOM Client Shim (Generated)
245
338
  */
246
- import * as ReactDOMClient from 'react-dom/client';
247
- export * from 'react-dom/client';
248
- export default ReactDOMClient;
339
+ import { createRoot, hydrateRoot } from 'react-dom/client';
340
+
341
+ // Named exports (명시적으로 re-export)
342
+ export { createRoot, hydrateRoot };
343
+
344
+ // Default export
345
+ export default { createRoot, hydrateRoot };
249
346
  `;
250
347
  }
251
348
 
252
349
  /**
253
350
  * JSX Runtime shim 소스 생성
351
+ * 주의: export *는 Bun bundler에서 제대로 작동하지 않으므로 명시적 export 필요
254
352
  */
255
353
  function generateJsxRuntimeShimSource(): string {
256
354
  return `
@@ -258,14 +356,19 @@ function generateJsxRuntimeShimSource(): string {
258
356
  * Mandu JSX Runtime Shim (Generated)
259
357
  * Production JSX 변환용
260
358
  */
261
- import * as jsxRuntime from 'react/jsx-runtime';
262
- export * from 'react/jsx-runtime';
263
- export default jsxRuntime;
359
+ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
360
+
361
+ // Named exports
362
+ export { jsx, jsxs, Fragment };
363
+
364
+ // Default export
365
+ export default { jsx, jsxs, Fragment };
264
366
  `;
265
367
  }
266
368
 
267
369
  /**
268
370
  * JSX Dev Runtime shim 소스 생성
371
+ * 주의: export *는 Bun bundler에서 제대로 작동하지 않으므로 명시적 export 필요
269
372
  */
270
373
  function generateJsxDevRuntimeShimSource(): string {
271
374
  return `
@@ -273,9 +376,13 @@ function generateJsxDevRuntimeShimSource(): string {
273
376
  * Mandu JSX Dev Runtime Shim (Generated)
274
377
  * Development JSX 변환용
275
378
  */
276
- import * as jsxDevRuntime from 'react/jsx-dev-runtime';
277
- export * from 'react/jsx-dev-runtime';
278
- export default jsxDevRuntime;
379
+ import { jsxDEV, Fragment } from 'react/jsx-dev-runtime';
380
+
381
+ // Named exports
382
+ export { jsxDEV, Fragment };
383
+
384
+ // Default export
385
+ export default { jsxDEV, Fragment };
279
386
  `;
280
387
  }
281
388
 
@@ -89,6 +89,15 @@ export {
89
89
  useRouterState,
90
90
  } from "./hooks";
91
91
 
92
+ // Props Serialization (Fresh 스타일)
93
+ export {
94
+ serializeProps,
95
+ deserializeProps,
96
+ isSerializable,
97
+ generatePropsScript,
98
+ parsePropsScript,
99
+ } from "./serialize";
100
+
92
101
  // Re-export as Mandu namespace for consistent API
93
102
  import { island, wrapComponent } from "./island";
94
103
  import { hydrateIslands, initializeRuntime } from "./runtime";
@@ -0,0 +1,404 @@
1
+ /**
2
+ * Mandu Props Serialization 📦
3
+ * Fresh 스타일 고급 직렬화/역직렬화
4
+ *
5
+ * @see https://fresh.deno.dev/docs/concepts/islands
6
+ *
7
+ * 지원 타입:
8
+ * - 원시형: null, boolean, number, string, bigint, undefined
9
+ * - 특수 객체: Date, URL, RegExp, Map, Set
10
+ * - 순환 참조
11
+ * - 중첩 객체/배열
12
+ */
13
+
14
+ // ============================================
15
+ // 타입 마커
16
+ // ============================================
17
+
18
+ const TYPE_MARKERS = {
19
+ /** undefined */
20
+ UNDEFINED: "\x00_",
21
+ /** Date */
22
+ DATE: "\x00D",
23
+ /** URL */
24
+ URL: "\x00U",
25
+ /** RegExp */
26
+ REGEXP: "\x00R",
27
+ /** Map */
28
+ MAP: "\x00M",
29
+ /** Set */
30
+ SET: "\x00S",
31
+ /** 순환 참조 */
32
+ REF: "\x00$",
33
+ /** BigInt */
34
+ BIGINT: "\x00B",
35
+ /** Symbol (제한적 지원) */
36
+ SYMBOL: "\x00Y",
37
+ /** Error */
38
+ ERROR: "\x00E",
39
+ } as const;
40
+
41
+ // ============================================
42
+ // 직렬화
43
+ // ============================================
44
+
45
+ /**
46
+ * 직렬화 컨텍스트 (순환 참조 추적)
47
+ */
48
+ interface SerializeContext {
49
+ /** 이미 본 객체 → 인덱스 */
50
+ seen: Map<object, number>;
51
+ /** 참조 테이블 */
52
+ refs: object[];
53
+ }
54
+
55
+ /**
56
+ * Props 직렬화
57
+ *
58
+ * @example
59
+ * ```typescript
60
+ * const props = {
61
+ * date: new Date(),
62
+ * url: new URL('https://example.com'),
63
+ * items: new Set([1, 2, 3]),
64
+ * cache: new Map([['key', 'value']]),
65
+ * };
66
+ *
67
+ * const json = serializeProps(props);
68
+ * // 클라이언트로 전송
69
+ * ```
70
+ */
71
+ export function serializeProps(props: Record<string, unknown>): string {
72
+ const ctx: SerializeContext = { seen: new Map(), refs: [] };
73
+ return JSON.stringify(serialize(props, ctx));
74
+ }
75
+
76
+ /**
77
+ * 값 직렬화 (재귀)
78
+ */
79
+ function serialize(value: unknown, ctx: SerializeContext): unknown {
80
+ // null
81
+ if (value === null) return null;
82
+
83
+ // undefined
84
+ if (value === undefined) return TYPE_MARKERS.UNDEFINED;
85
+
86
+ // 원시형
87
+ if (typeof value === "boolean" || typeof value === "number") {
88
+ return value;
89
+ }
90
+
91
+ if (typeof value === "string") {
92
+ // 타입 마커와 충돌 방지 (첫 문자가 \x00인 경우)
93
+ if (value.startsWith("\x00")) {
94
+ return "\x00\x00" + value;
95
+ }
96
+ return value;
97
+ }
98
+
99
+ if (typeof value === "bigint") {
100
+ return TYPE_MARKERS.BIGINT + value.toString();
101
+ }
102
+
103
+ if (typeof value === "symbol") {
104
+ // Symbol은 description만 보존
105
+ return TYPE_MARKERS.SYMBOL + (value.description ?? "");
106
+ }
107
+
108
+ // 함수는 직렬화 불가
109
+ if (typeof value === "function") {
110
+ console.warn("[Mandu Serialize] Functions cannot be serialized, skipping");
111
+ return undefined;
112
+ }
113
+
114
+ // 객체 순환 참조 체크
115
+ if (typeof value === "object") {
116
+ const existing = ctx.seen.get(value);
117
+ if (existing !== undefined) {
118
+ return TYPE_MARKERS.REF + existing;
119
+ }
120
+
121
+ const idx = ctx.refs.length;
122
+ ctx.seen.set(value, idx);
123
+ ctx.refs.push(value);
124
+ }
125
+
126
+ // Date
127
+ if (value instanceof Date) {
128
+ return TYPE_MARKERS.DATE + value.toISOString();
129
+ }
130
+
131
+ // URL
132
+ if (value instanceof URL) {
133
+ return TYPE_MARKERS.URL + value.href;
134
+ }
135
+
136
+ // RegExp
137
+ if (value instanceof RegExp) {
138
+ return TYPE_MARKERS.REGEXP + value.toString();
139
+ }
140
+
141
+ // Error
142
+ if (value instanceof Error) {
143
+ return [
144
+ TYPE_MARKERS.ERROR,
145
+ value.name,
146
+ value.message,
147
+ value.stack ?? "",
148
+ ];
149
+ }
150
+
151
+ // Map
152
+ if (value instanceof Map) {
153
+ const entries: [unknown, unknown][] = [];
154
+ for (const [k, v] of value.entries()) {
155
+ entries.push([serialize(k, ctx), serialize(v, ctx)]);
156
+ }
157
+ return [TYPE_MARKERS.MAP, ...entries];
158
+ }
159
+
160
+ // Set
161
+ if (value instanceof Set) {
162
+ const items: unknown[] = [];
163
+ for (const item of value) {
164
+ items.push(serialize(item, ctx));
165
+ }
166
+ return [TYPE_MARKERS.SET, ...items];
167
+ }
168
+
169
+ // 배열
170
+ if (Array.isArray(value)) {
171
+ return value.map((item) => serialize(item, ctx));
172
+ }
173
+
174
+ // 일반 객체
175
+ const result: Record<string, unknown> = {};
176
+ for (const [k, v] of Object.entries(value as object)) {
177
+ const serialized = serialize(v, ctx);
178
+ if (serialized !== undefined) {
179
+ result[k] = serialized;
180
+ }
181
+ }
182
+ return result;
183
+ }
184
+
185
+ // ============================================
186
+ // 역직렬화
187
+ // ============================================
188
+
189
+ /**
190
+ * 역직렬화 컨텍스트 (순환 참조 복원)
191
+ */
192
+ interface DeserializeContext {
193
+ refs: unknown[];
194
+ }
195
+
196
+ /**
197
+ * Props 역직렬화
198
+ *
199
+ * @example
200
+ * ```typescript
201
+ * // 서버에서 받은 JSON
202
+ * const json = '{"date":"\x00D2025-01-28T00:00:00.000Z"}';
203
+ *
204
+ * const props = deserializeProps(json);
205
+ * console.log(props.date instanceof Date); // true
206
+ * ```
207
+ */
208
+ export function deserializeProps(json: string): Record<string, unknown> {
209
+ const ctx: DeserializeContext = { refs: [] };
210
+ const parsed = JSON.parse(json);
211
+ return deserialize(parsed, ctx) as Record<string, unknown>;
212
+ }
213
+
214
+ /**
215
+ * 값 역직렬화 (재귀)
216
+ */
217
+ function deserialize(value: unknown, ctx: DeserializeContext): unknown {
218
+ // null
219
+ if (value === null) return null;
220
+
221
+ // 문자열 → 타입 마커 체크
222
+ if (typeof value === "string") {
223
+ // undefined
224
+ if (value === TYPE_MARKERS.UNDEFINED) return undefined;
225
+
226
+ // 이스케이프된 문자열 (\x00\x00 → \x00)
227
+ if (value.startsWith("\x00\x00")) {
228
+ return value.slice(2);
229
+ }
230
+
231
+ // Date
232
+ if (value.startsWith(TYPE_MARKERS.DATE)) {
233
+ return new Date(value.slice(2));
234
+ }
235
+
236
+ // URL
237
+ if (value.startsWith(TYPE_MARKERS.URL)) {
238
+ return new URL(value.slice(2));
239
+ }
240
+
241
+ // RegExp
242
+ if (value.startsWith(TYPE_MARKERS.REGEXP)) {
243
+ const str = value.slice(2);
244
+ const match = str.match(/^\/(.*)\/([gimsuy]*)$/);
245
+ if (match) {
246
+ return new RegExp(match[1], match[2]);
247
+ }
248
+ return str; // 파싱 실패 시 문자열 반환
249
+ }
250
+
251
+ // BigInt
252
+ if (value.startsWith(TYPE_MARKERS.BIGINT)) {
253
+ return BigInt(value.slice(2));
254
+ }
255
+
256
+ // Symbol
257
+ if (value.startsWith(TYPE_MARKERS.SYMBOL)) {
258
+ return Symbol(value.slice(2));
259
+ }
260
+
261
+ // 순환 참조
262
+ if (value.startsWith(TYPE_MARKERS.REF)) {
263
+ const idx = parseInt(value.slice(2), 10);
264
+ return ctx.refs[idx];
265
+ }
266
+
267
+ return value;
268
+ }
269
+
270
+ // 원시형
271
+ if (typeof value === "boolean" || typeof value === "number") {
272
+ return value;
273
+ }
274
+
275
+ // 배열 → 특수 타입 체크
276
+ if (Array.isArray(value)) {
277
+ const marker = value[0];
278
+
279
+ // Error
280
+ if (marker === TYPE_MARKERS.ERROR) {
281
+ const [, name, message, stack] = value as [string, string, string, string];
282
+ const error = new Error(message);
283
+ error.name = name;
284
+ if (stack) error.stack = stack;
285
+ ctx.refs.push(error);
286
+ return error;
287
+ }
288
+
289
+ // Map
290
+ if (marker === TYPE_MARKERS.MAP) {
291
+ const map = new Map();
292
+ ctx.refs.push(map);
293
+ for (let i = 1; i < value.length; i++) {
294
+ const [k, v] = value[i] as [unknown, unknown];
295
+ map.set(deserialize(k, ctx), deserialize(v, ctx));
296
+ }
297
+ return map;
298
+ }
299
+
300
+ // Set
301
+ if (marker === TYPE_MARKERS.SET) {
302
+ const set = new Set();
303
+ ctx.refs.push(set);
304
+ for (let i = 1; i < value.length; i++) {
305
+ set.add(deserialize(value[i], ctx));
306
+ }
307
+ return set;
308
+ }
309
+
310
+ // 일반 배열
311
+ const arr: unknown[] = [];
312
+ ctx.refs.push(arr);
313
+ for (const item of value) {
314
+ arr.push(deserialize(item, ctx));
315
+ }
316
+ return arr;
317
+ }
318
+
319
+ // 일반 객체
320
+ if (typeof value === "object") {
321
+ const obj: Record<string, unknown> = {};
322
+ ctx.refs.push(obj);
323
+ for (const [k, v] of Object.entries(value)) {
324
+ obj[k] = deserialize(v, ctx);
325
+ }
326
+ return obj;
327
+ }
328
+
329
+ return value;
330
+ }
331
+
332
+ // ============================================
333
+ // 유틸리티
334
+ // ============================================
335
+
336
+ /**
337
+ * 직렬화 가능 여부 체크
338
+ */
339
+ export function isSerializable(value: unknown): boolean {
340
+ if (value === null || value === undefined) return true;
341
+
342
+ const type = typeof value;
343
+ if (type === "boolean" || type === "number" || type === "string" || type === "bigint") {
344
+ return true;
345
+ }
346
+
347
+ if (type === "function" || type === "symbol") {
348
+ return false;
349
+ }
350
+
351
+ if (value instanceof Date || value instanceof URL || value instanceof RegExp) {
352
+ return true;
353
+ }
354
+
355
+ if (value instanceof Map || value instanceof Set) {
356
+ return true;
357
+ }
358
+
359
+ if (Array.isArray(value)) {
360
+ return value.every(isSerializable);
361
+ }
362
+
363
+ if (type === "object") {
364
+ return Object.values(value as object).every(isSerializable);
365
+ }
366
+
367
+ return false;
368
+ }
369
+
370
+ /**
371
+ * SSR에서 클라이언트로 props 전달용 스크립트 생성
372
+ */
373
+ export function generatePropsScript(
374
+ islandId: string,
375
+ props: Record<string, unknown>
376
+ ): string {
377
+ const json = serializeProps(props);
378
+ const escaped = json
379
+ .replace(/</g, "\\u003c")
380
+ .replace(/>/g, "\\u003e")
381
+ .replace(/&/g, "\\u0026");
382
+
383
+ return `<script type="application/json" data-mandu-props="${islandId}">${escaped}</script>`;
384
+ }
385
+
386
+ /**
387
+ * 클라이언트에서 props 스크립트 파싱
388
+ */
389
+ export function parsePropsScript(islandId: string): Record<string, unknown> | null {
390
+ if (typeof document === "undefined") return null;
391
+
392
+ const script = document.querySelector(
393
+ `script[data-mandu-props="${islandId}"]`
394
+ ) as HTMLScriptElement | null;
395
+
396
+ if (!script?.textContent) return null;
397
+
398
+ try {
399
+ return deserializeProps(script.textContent);
400
+ } catch (err) {
401
+ console.error(`[Mandu] Failed to parse props for island ${islandId}:`, err);
402
+ return null;
403
+ }
404
+ }
@@ -7,6 +7,16 @@ import { ManduContext, NEXT_SYMBOL, ValidationError } from "./context";
7
7
  import { AuthenticationError, AuthorizationError } from "./auth";
8
8
  import { ErrorClassifier, formatErrorResponse, ErrorCode } from "../error";
9
9
  import { createContract, type ContractDefinition, type ContractInstance } from "../contract";
10
+ import {
11
+ type LifecycleStore,
12
+ type OnRequestHandler,
13
+ type BeforeHandleHandler,
14
+ type AfterHandleHandler,
15
+ type OnErrorHandler,
16
+ type AfterResponseHandler,
17
+ createLifecycleStore,
18
+ executeLifecycle,
19
+ } from "../runtime/lifecycle";
10
20
 
11
21
  /** Handler function type */
12
22
  export type Handler = (ctx: ManduContext) => Response | Promise<Response>;
@@ -41,6 +51,7 @@ interface FillingConfig<TLoaderData = unknown> {
41
51
  guards: Guard[];
42
52
  methodGuards: Map<HttpMethod, Guard[]>;
43
53
  loader?: Loader<TLoaderData>;
54
+ lifecycle: LifecycleStore;
44
55
  }
45
56
 
46
57
  /**
@@ -68,6 +79,7 @@ export class ManduFilling<TLoaderData = unknown> {
68
79
  handlers: new Map(),
69
80
  guards: [],
70
81
  methodGuards: new Map(),
82
+ lifecycle: createLifecycleStore(),
71
83
  };
72
84
 
73
85
  // ============================================
@@ -218,6 +230,90 @@ export class ManduFilling<TLoaderData = unknown> {
218
230
  return this.guard(guardFn, ...methods);
219
231
  }
220
232
 
233
+ // ============================================
234
+ // 🥟 Lifecycle Hooks (Elysia 스타일)
235
+ // ============================================
236
+
237
+ /**
238
+ * 요청 시작 시 실행
239
+ * @example
240
+ * ```typescript
241
+ * .onRequest((ctx) => {
242
+ * console.log('Request:', ctx.req.method, ctx.req.url);
243
+ * })
244
+ * ```
245
+ */
246
+ onRequest(fn: OnRequestHandler): this {
247
+ this.config.lifecycle.onRequest.push({ fn, scope: "local" });
248
+ return this;
249
+ }
250
+
251
+ /**
252
+ * 핸들러 전 실행 (Guard 역할)
253
+ * Response 반환 시 체인 중단
254
+ * @example
255
+ * ```typescript
256
+ * .beforeHandle((ctx) => {
257
+ * if (!ctx.get('user')) {
258
+ * return ctx.unauthorized();
259
+ * }
260
+ * // void 반환 시 계속 진행
261
+ * })
262
+ * ```
263
+ */
264
+ beforeHandle(fn: BeforeHandleHandler): this {
265
+ this.config.lifecycle.beforeHandle.push({ fn, scope: "local" });
266
+ return this;
267
+ }
268
+
269
+ /**
270
+ * 핸들러 후 실행 (응답 변환)
271
+ * @example
272
+ * ```typescript
273
+ * .afterHandle((ctx, response) => {
274
+ * // 응답 헤더 추가
275
+ * response.headers.set('X-Request-Id', crypto.randomUUID());
276
+ * return response;
277
+ * })
278
+ * ```
279
+ */
280
+ afterHandle(fn: AfterHandleHandler): this {
281
+ this.config.lifecycle.afterHandle.push({ fn, scope: "local" });
282
+ return this;
283
+ }
284
+
285
+ /**
286
+ * 에러 발생 시 실행
287
+ * Response 반환 시 에러 응답으로 사용
288
+ * @example
289
+ * ```typescript
290
+ * .onError((ctx, error) => {
291
+ * console.error('Error:', error);
292
+ * return ctx.json({ error: error.message }, 500);
293
+ * })
294
+ * ```
295
+ */
296
+ onError(fn: OnErrorHandler): this {
297
+ this.config.lifecycle.onError.push({ fn, scope: "local" });
298
+ return this;
299
+ }
300
+
301
+ /**
302
+ * 응답 후 실행 (비동기, 응답에 영향 없음)
303
+ * 로깅, 메트릭 수집 등에 사용
304
+ * @example
305
+ * ```typescript
306
+ * .afterResponse((ctx) => {
307
+ * console.log('Response sent:', ctx.req.url);
308
+ * metrics.increment('requests');
309
+ * })
310
+ * ```
311
+ */
312
+ afterResponse(fn: AfterResponseHandler): this {
313
+ this.config.lifecycle.afterResponse.push({ fn, scope: "local" });
314
+ return this;
315
+ }
316
+
221
317
  // ============================================
222
318
  // 🥟 Execution
223
319
  // ============================================
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Mandu Middleware Compose 🔗
3
+ * Hono 스타일 미들웨어 조합 패턴
4
+ *
5
+ * @see https://github.com/honojs/hono/blob/main/src/compose.ts
6
+ */
7
+
8
+ import type { ManduContext } from "../filling/context";
9
+
10
+ /**
11
+ * Next 함수 타입
12
+ */
13
+ export type Next = () => Promise<void>;
14
+
15
+ /**
16
+ * 미들웨어 함수 타입
17
+ * - Response 반환: 체인 중단 (Guard 역할)
18
+ * - void 반환: 다음 미들웨어 실행
19
+ */
20
+ export type Middleware = (
21
+ ctx: ManduContext,
22
+ next: Next
23
+ ) => Response | void | Promise<Response | void>;
24
+
25
+ /**
26
+ * 에러 핸들러 타입
27
+ */
28
+ export type ErrorHandler = (
29
+ error: Error,
30
+ ctx: ManduContext
31
+ ) => Response | Promise<Response>;
32
+
33
+ /**
34
+ * NotFound 핸들러 타입
35
+ */
36
+ export type NotFoundHandler = (ctx: ManduContext) => Response | Promise<Response>;
37
+
38
+ /**
39
+ * 미들웨어 엔트리 (메타데이터 포함)
40
+ */
41
+ export interface MiddlewareEntry {
42
+ fn: Middleware;
43
+ name?: string;
44
+ isAsync?: boolean;
45
+ }
46
+
47
+ /**
48
+ * Compose 옵션
49
+ */
50
+ export interface ComposeOptions {
51
+ onError?: ErrorHandler;
52
+ onNotFound?: NotFoundHandler;
53
+ }
54
+
55
+ /**
56
+ * 미들웨어 함수들을 하나의 실행 함수로 조합
57
+ *
58
+ * @example
59
+ * ```typescript
60
+ * const middleware = [
61
+ * { fn: async (ctx, next) => { console.log('before'); await next(); console.log('after'); } },
62
+ * { fn: async (ctx, next) => { return ctx.ok({ data: 'hello' }); } },
63
+ * ];
64
+ *
65
+ * const handler = compose(middleware, {
66
+ * onError: (err, ctx) => ctx.json({ error: err.message }, 500),
67
+ * onNotFound: (ctx) => ctx.notFound(),
68
+ * });
69
+ *
70
+ * const response = await handler(context);
71
+ * ```
72
+ */
73
+ export function compose(
74
+ middleware: MiddlewareEntry[],
75
+ options: ComposeOptions = {}
76
+ ): (ctx: ManduContext) => Promise<Response> {
77
+ const { onError, onNotFound } = options;
78
+
79
+ return async (ctx: ManduContext): Promise<Response> => {
80
+ let index = -1;
81
+ let finalResponse: Response | undefined;
82
+
83
+ /**
84
+ * 미들웨어 순차 실행
85
+ * @param i 현재 인덱스
86
+ */
87
+ async function dispatch(i: number): Promise<void> {
88
+ // next() 이중 호출 방지
89
+ if (i <= index) {
90
+ throw new Error("next() called multiple times");
91
+ }
92
+ index = i;
93
+
94
+ const entry = middleware[i];
95
+
96
+ if (!entry) {
97
+ // 모든 미들웨어 통과 후 핸들러 없음
98
+ if (!finalResponse && onNotFound) {
99
+ finalResponse = await onNotFound(ctx);
100
+ }
101
+ return;
102
+ }
103
+
104
+ try {
105
+ const result = await entry.fn(ctx, () => dispatch(i + 1));
106
+
107
+ // Response 반환 시 체인 중단
108
+ if (result instanceof Response) {
109
+ finalResponse = result;
110
+ return;
111
+ }
112
+ } catch (err) {
113
+ if (err instanceof Error && onError) {
114
+ finalResponse = await onError(err, ctx);
115
+ return;
116
+ }
117
+ throw err;
118
+ }
119
+ }
120
+
121
+ await dispatch(0);
122
+
123
+ // 응답이 없으면 404
124
+ if (!finalResponse) {
125
+ if (onNotFound) {
126
+ finalResponse = await onNotFound(ctx);
127
+ } else {
128
+ finalResponse = new Response("Not Found", { status: 404 });
129
+ }
130
+ }
131
+
132
+ return finalResponse;
133
+ };
134
+ }
135
+
136
+ /**
137
+ * 미들웨어 배열 생성 헬퍼
138
+ *
139
+ * @example
140
+ * ```typescript
141
+ * const mw = createMiddleware([
142
+ * authGuard,
143
+ * rateLimitGuard,
144
+ * mainHandler,
145
+ * ]);
146
+ * ```
147
+ */
148
+ export function createMiddleware(
149
+ fns: Middleware[]
150
+ ): MiddlewareEntry[] {
151
+ return fns.map((fn, i) => ({
152
+ fn,
153
+ name: fn.name || `middleware_${i}`,
154
+ isAsync: fn.constructor.name === "AsyncFunction",
155
+ }));
156
+ }
157
+
158
+ /**
159
+ * 미들웨어 체인 빌더
160
+ *
161
+ * @example
162
+ * ```typescript
163
+ * const chain = new MiddlewareChain()
164
+ * .use(authGuard)
165
+ * .use(rateLimitGuard)
166
+ * .use(mainHandler)
167
+ * .onError((err, ctx) => ctx.json({ error: err.message }, 500))
168
+ * .build();
169
+ *
170
+ * const response = await chain(ctx);
171
+ * ```
172
+ */
173
+ export class MiddlewareChain {
174
+ private middleware: MiddlewareEntry[] = [];
175
+ private errorHandler?: ErrorHandler;
176
+ private notFoundHandler?: NotFoundHandler;
177
+
178
+ /**
179
+ * 미들웨어 추가
180
+ */
181
+ use(fn: Middleware, name?: string): this {
182
+ this.middleware.push({
183
+ fn,
184
+ name: name || fn.name || `middleware_${this.middleware.length}`,
185
+ isAsync: fn.constructor.name === "AsyncFunction",
186
+ });
187
+ return this;
188
+ }
189
+
190
+ /**
191
+ * 에러 핸들러 설정
192
+ */
193
+ onError(handler: ErrorHandler): this {
194
+ this.errorHandler = handler;
195
+ return this;
196
+ }
197
+
198
+ /**
199
+ * NotFound 핸들러 설정
200
+ */
201
+ onNotFound(handler: NotFoundHandler): this {
202
+ this.notFoundHandler = handler;
203
+ return this;
204
+ }
205
+
206
+ /**
207
+ * 미들웨어 체인 빌드
208
+ */
209
+ build(): (ctx: ManduContext) => Promise<Response> {
210
+ return compose(this.middleware, {
211
+ onError: this.errorHandler,
212
+ onNotFound: this.notFoundHandler,
213
+ });
214
+ }
215
+
216
+ /**
217
+ * 미들웨어 목록 조회
218
+ */
219
+ getMiddleware(): MiddlewareEntry[] {
220
+ return [...this.middleware];
221
+ }
222
+ }
@@ -3,3 +3,5 @@ export * from "./router";
3
3
  export * from "./server";
4
4
  export * from "./cors";
5
5
  export * from "./env";
6
+ export * from "./compose";
7
+ export * from "./lifecycle";
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Mandu Lifecycle Hooks 🔄
3
+ * Elysia 스타일 라이프사이클 훅 체계
4
+ *
5
+ * @see https://elysiajs.com/life-cycle/overview.html
6
+ *
7
+ * 요청 흐름:
8
+ * 1. onRequest - 요청 시작
9
+ * 2. onParse - 바디 파싱 (POST, PUT, PATCH)
10
+ * 3. beforeHandle - 핸들러 전 (Guard 역할)
11
+ * 4. [Handler] - 메인 핸들러 실행
12
+ * 5. afterHandle - 핸들러 후 (응답 변환)
13
+ * 6. mapResponse - 응답 매핑
14
+ * 7. afterResponse - 응답 후 (로깅, 정리)
15
+ *
16
+ * 에러 발생 시:
17
+ * - onError - 에러 핸들링
18
+ */
19
+
20
+ import type { ManduContext } from "../filling/context";
21
+
22
+ /**
23
+ * 훅 스코프
24
+ * - global: 모든 라우트에 적용
25
+ * - scoped: 현재 플러그인/라우트 그룹에 적용
26
+ * - local: 현재 라우트에만 적용
27
+ */
28
+ export type HookScope = "global" | "scoped" | "local";
29
+
30
+ /**
31
+ * 훅 컨테이너
32
+ */
33
+ export interface HookContainer<T extends Function = Function> {
34
+ fn: T;
35
+ scope: HookScope;
36
+ name?: string;
37
+ checksum?: number; // 중복 제거용
38
+ }
39
+
40
+ // ============================================
41
+ // 훅 타입 정의
42
+ // ============================================
43
+
44
+ /** 요청 시작 훅 */
45
+ export type OnRequestHandler = (ctx: ManduContext) => void | Promise<void>;
46
+
47
+ /** 바디 파싱 훅 */
48
+ export type OnParseHandler = (ctx: ManduContext) => void | Promise<void>;
49
+
50
+ /** 핸들러 전 훅 (Guard 역할) - Response 반환 시 체인 중단 */
51
+ export type BeforeHandleHandler = (
52
+ ctx: ManduContext
53
+ ) => Response | void | Promise<Response | void>;
54
+
55
+ /** 핸들러 후 훅 - 응답 변환 가능 */
56
+ export type AfterHandleHandler = (
57
+ ctx: ManduContext,
58
+ response: Response
59
+ ) => Response | Promise<Response>;
60
+
61
+ /** 응답 매핑 훅 */
62
+ export type MapResponseHandler = (
63
+ ctx: ManduContext,
64
+ response: Response
65
+ ) => Response | Promise<Response>;
66
+
67
+ /** 응답 후 훅 (비동기, 응답에 영향 없음) */
68
+ export type AfterResponseHandler = (ctx: ManduContext) => void | Promise<void>;
69
+
70
+ /** 에러 핸들링 훅 - Response 반환 시 에러 응답으로 사용 */
71
+ export type OnErrorHandler = (
72
+ ctx: ManduContext,
73
+ error: Error
74
+ ) => Response | void | Promise<Response | void>;
75
+
76
+ // ============================================
77
+ // 라이프사이클 스토어
78
+ // ============================================
79
+
80
+ /**
81
+ * 라이프사이클 훅 스토어
82
+ */
83
+ export interface LifecycleStore {
84
+ onRequest: HookContainer<OnRequestHandler>[];
85
+ onParse: HookContainer<OnParseHandler>[];
86
+ beforeHandle: HookContainer<BeforeHandleHandler>[];
87
+ afterHandle: HookContainer<AfterHandleHandler>[];
88
+ mapResponse: HookContainer<MapResponseHandler>[];
89
+ afterResponse: HookContainer<AfterResponseHandler>[];
90
+ onError: HookContainer<OnErrorHandler>[];
91
+ }
92
+
93
+ /**
94
+ * 빈 라이프사이클 스토어 생성
95
+ */
96
+ export function createLifecycleStore(): LifecycleStore {
97
+ return {
98
+ onRequest: [],
99
+ onParse: [],
100
+ beforeHandle: [],
101
+ afterHandle: [],
102
+ mapResponse: [],
103
+ afterResponse: [],
104
+ onError: [],
105
+ };
106
+ }
107
+
108
+ // ============================================
109
+ // 라이프사이클 실행
110
+ // ============================================
111
+
112
+ /**
113
+ * 라이프사이클 실행 옵션
114
+ */
115
+ export interface ExecuteOptions {
116
+ /** 바디 파싱이 필요한 메서드 */
117
+ parseBodyMethods?: string[];
118
+ }
119
+
120
+ const DEFAULT_PARSE_BODY_METHODS = ["POST", "PUT", "PATCH"];
121
+
122
+ /**
123
+ * 라이프사이클 실행
124
+ *
125
+ * @param lifecycle 라이프사이클 스토어
126
+ * @param ctx ManduContext
127
+ * @param handler 메인 핸들러
128
+ * @param options 옵션
129
+ *
130
+ * @example
131
+ * ```typescript
132
+ * const lifecycle = createLifecycleStore();
133
+ * lifecycle.onRequest.push({ fn: (ctx) => console.log('Request started'), scope: 'local' });
134
+ * lifecycle.beforeHandle.push({ fn: authGuard, scope: 'local' });
135
+ *
136
+ * const response = await executeLifecycle(
137
+ * lifecycle,
138
+ * ctx,
139
+ * async () => ctx.ok({ data: 'hello' })
140
+ * );
141
+ * ```
142
+ */
143
+ export async function executeLifecycle(
144
+ lifecycle: LifecycleStore,
145
+ ctx: ManduContext,
146
+ handler: () => Promise<Response>,
147
+ options: ExecuteOptions = {}
148
+ ): Promise<Response> {
149
+ const { parseBodyMethods = DEFAULT_PARSE_BODY_METHODS } = options;
150
+ let response: Response;
151
+
152
+ try {
153
+ // 1. onRequest
154
+ for (const hook of lifecycle.onRequest) {
155
+ await hook.fn(ctx);
156
+ }
157
+
158
+ // 2. onParse (바디가 있는 메서드만)
159
+ if (parseBodyMethods.includes(ctx.req.method)) {
160
+ for (const hook of lifecycle.onParse) {
161
+ await hook.fn(ctx);
162
+ }
163
+ }
164
+
165
+ // 3. beforeHandle (Guard 역할)
166
+ for (const hook of lifecycle.beforeHandle) {
167
+ const result = await hook.fn(ctx);
168
+ if (result instanceof Response) {
169
+ // Response 반환 시 체인 중단, afterHandle/mapResponse 건너뜀
170
+ response = result;
171
+ // afterResponse는 실행
172
+ scheduleAfterResponse(lifecycle.afterResponse, ctx);
173
+ return response;
174
+ }
175
+ }
176
+
177
+ // 4. 메인 핸들러 실행
178
+ response = await handler();
179
+
180
+ // 5. afterHandle
181
+ for (const hook of lifecycle.afterHandle) {
182
+ response = await hook.fn(ctx, response);
183
+ }
184
+
185
+ // 6. mapResponse
186
+ for (const hook of lifecycle.mapResponse) {
187
+ response = await hook.fn(ctx, response);
188
+ }
189
+
190
+ // 7. afterResponse (비동기)
191
+ scheduleAfterResponse(lifecycle.afterResponse, ctx);
192
+
193
+ return response;
194
+ } catch (err) {
195
+ // onError 처리
196
+ const error = err instanceof Error ? err : new Error(String(err));
197
+
198
+ for (const hook of lifecycle.onError) {
199
+ const result = await hook.fn(ctx, error);
200
+ if (result instanceof Response) {
201
+ // afterResponse는 에러 시에도 실행
202
+ scheduleAfterResponse(lifecycle.afterResponse, ctx);
203
+ return result;
204
+ }
205
+ }
206
+
207
+ // 에러 핸들러가 Response를 반환하지 않으면 재throw
208
+ throw error;
209
+ }
210
+ }
211
+
212
+ /**
213
+ * afterResponse 훅 비동기 실행 (응답 후)
214
+ */
215
+ function scheduleAfterResponse(
216
+ hooks: HookContainer<AfterResponseHandler>[],
217
+ ctx: ManduContext
218
+ ): void {
219
+ if (hooks.length === 0) return;
220
+
221
+ // queueMicrotask로 응답 후 실행
222
+ queueMicrotask(async () => {
223
+ for (const hook of hooks) {
224
+ try {
225
+ await hook.fn(ctx);
226
+ } catch (err) {
227
+ console.error("[Mandu] afterResponse hook error:", err);
228
+ }
229
+ }
230
+ });
231
+ }
232
+
233
+ // ============================================
234
+ // 라이프사이클 빌더
235
+ // ============================================
236
+
237
+ /**
238
+ * 라이프사이클 빌더
239
+ *
240
+ * @example
241
+ * ```typescript
242
+ * const lifecycle = new LifecycleBuilder()
243
+ * .onRequest((ctx) => console.log('Request:', ctx.req.url))
244
+ * .beforeHandle(authGuard)
245
+ * .afterHandle((ctx, res) => {
246
+ * // 응답 헤더 추가
247
+ * res.headers.set('X-Custom', 'value');
248
+ * return res;
249
+ * })
250
+ * .onError((ctx, err) => ctx.json({ error: err.message }, 500))
251
+ * .build();
252
+ * ```
253
+ */
254
+ export class LifecycleBuilder {
255
+ private store: LifecycleStore = createLifecycleStore();
256
+
257
+ /**
258
+ * 요청 시작 훅 추가
259
+ */
260
+ onRequest(fn: OnRequestHandler, scope: HookScope = "local"): this {
261
+ this.store.onRequest.push({ fn, scope });
262
+ return this;
263
+ }
264
+
265
+ /**
266
+ * 바디 파싱 훅 추가
267
+ */
268
+ onParse(fn: OnParseHandler, scope: HookScope = "local"): this {
269
+ this.store.onParse.push({ fn, scope });
270
+ return this;
271
+ }
272
+
273
+ /**
274
+ * 핸들러 전 훅 추가 (Guard 역할)
275
+ */
276
+ beforeHandle(fn: BeforeHandleHandler, scope: HookScope = "local"): this {
277
+ this.store.beforeHandle.push({ fn, scope });
278
+ return this;
279
+ }
280
+
281
+ /**
282
+ * 핸들러 후 훅 추가
283
+ */
284
+ afterHandle(fn: AfterHandleHandler, scope: HookScope = "local"): this {
285
+ this.store.afterHandle.push({ fn, scope });
286
+ return this;
287
+ }
288
+
289
+ /**
290
+ * 응답 매핑 훅 추가
291
+ */
292
+ mapResponse(fn: MapResponseHandler, scope: HookScope = "local"): this {
293
+ this.store.mapResponse.push({ fn, scope });
294
+ return this;
295
+ }
296
+
297
+ /**
298
+ * 응답 후 훅 추가
299
+ */
300
+ afterResponse(fn: AfterResponseHandler, scope: HookScope = "local"): this {
301
+ this.store.afterResponse.push({ fn, scope });
302
+ return this;
303
+ }
304
+
305
+ /**
306
+ * 에러 핸들링 훅 추가
307
+ */
308
+ onError(fn: OnErrorHandler, scope: HookScope = "local"): this {
309
+ this.store.onError.push({ fn, scope });
310
+ return this;
311
+ }
312
+
313
+ /**
314
+ * 라이프사이클 스토어 빌드
315
+ */
316
+ build(): LifecycleStore {
317
+ return { ...this.store };
318
+ }
319
+
320
+ /**
321
+ * 다른 라이프사이클과 병합
322
+ */
323
+ merge(other: LifecycleStore): this {
324
+ this.store.onRequest.push(...other.onRequest);
325
+ this.store.onParse.push(...other.onParse);
326
+ this.store.beforeHandle.push(...other.beforeHandle);
327
+ this.store.afterHandle.push(...other.afterHandle);
328
+ this.store.mapResponse.push(...other.mapResponse);
329
+ this.store.afterResponse.push(...other.afterResponse);
330
+ this.store.onError.push(...other.onError);
331
+ return this;
332
+ }
333
+ }
334
+
335
+ // ============================================
336
+ // 유틸리티
337
+ // ============================================
338
+
339
+ /**
340
+ * 훅 중복 제거 (checksum 기반)
341
+ */
342
+ export function deduplicateHooks<T extends HookContainer>(hooks: T[]): T[] {
343
+ const seen = new Set<number>();
344
+ return hooks.filter((hook) => {
345
+ if (hook.checksum === undefined) return true;
346
+ if (seen.has(hook.checksum)) return false;
347
+ seen.add(hook.checksum);
348
+ return true;
349
+ });
350
+ }
351
+
352
+ /**
353
+ * 스코프별 훅 필터링
354
+ */
355
+ export function filterHooksByScope<T extends HookContainer>(
356
+ hooks: T[],
357
+ scopes: HookScope[]
358
+ ): T[] {
359
+ return hooks.filter((hook) => scopes.includes(hook.scope));
360
+ }