@mandujs/core 0.6.0 → 0.7.0
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 +1 -1
- package/src/client/index.ts +9 -0
- package/src/client/serialize.ts +404 -0
- package/src/filling/filling.ts +96 -0
- package/src/runtime/compose.ts +222 -0
- package/src/runtime/index.ts +2 -0
- package/src/runtime/lifecycle.ts +360 -0
package/package.json
CHANGED
package/src/client/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/filling/filling.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/runtime/index.ts
CHANGED
|
@@ -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
|
+
}
|