@mandujs/core 0.7.0 → 0.7.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/package.json +1 -1
- package/src/bundler/build.ts +131 -18
- package/src/filling/context.ts +438 -464
- package/src/filling/filling.ts +220 -552
- package/src/filling/tmpclaude-2f8d-cwd +1 -0
- package/src/filling/tmpclaude-fb5a-cwd +1 -0
package/src/filling/filling.ts
CHANGED
|
@@ -1,552 +1,220 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Mandu Filling - 만두소 🥟
|
|
3
|
-
* 체이닝 API로 비즈니스 로직 정의
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { ManduContext,
|
|
7
|
-
import { AuthenticationError, AuthorizationError } from "./auth";
|
|
8
|
-
import { ErrorClassifier, formatErrorResponse, ErrorCode } from "../error";
|
|
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";
|
|
20
|
-
|
|
21
|
-
/** Handler function type */
|
|
22
|
-
export type Handler = (ctx: ManduContext) => Response | Promise<Response>;
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
export type
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
export type
|
|
29
|
-
|
|
30
|
-
/** Loader
|
|
31
|
-
export
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
guards.push(guardFn);
|
|
222
|
-
this.config.methodGuards.set(method, guards);
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
return this;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/** Alias for guard - more semantic for middleware */
|
|
229
|
-
use(guardFn: Guard, ...methods: HttpMethod[]): this {
|
|
230
|
-
return this.guard(guardFn, ...methods);
|
|
231
|
-
}
|
|
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
|
-
|
|
317
|
-
// ============================================
|
|
318
|
-
// 🥟 Execution
|
|
319
|
-
// ============================================
|
|
320
|
-
|
|
321
|
-
/**
|
|
322
|
-
* Handle incoming request
|
|
323
|
-
* Called by generated route handler
|
|
324
|
-
* @param request The incoming request
|
|
325
|
-
* @param params URL path parameters
|
|
326
|
-
* @param routeContext Route context for error reporting
|
|
327
|
-
*/
|
|
328
|
-
async handle(
|
|
329
|
-
request: Request,
|
|
330
|
-
params: Record<string, string> = {},
|
|
331
|
-
routeContext?: { routeId: string; pattern: string }
|
|
332
|
-
): Promise<Response> {
|
|
333
|
-
const ctx = new ManduContext(request, params);
|
|
334
|
-
const method = request.method.toUpperCase() as HttpMethod;
|
|
335
|
-
|
|
336
|
-
try {
|
|
337
|
-
// Run global guards
|
|
338
|
-
for (const guard of this.config.guards) {
|
|
339
|
-
const result = await guard(ctx);
|
|
340
|
-
if (result !== NEXT_SYMBOL) {
|
|
341
|
-
return result as Response;
|
|
342
|
-
}
|
|
343
|
-
if (!ctx.shouldContinue) {
|
|
344
|
-
const response = ctx.getResponse();
|
|
345
|
-
if (!response) {
|
|
346
|
-
throw new Error("Guard set shouldContinue=false but no response was provided");
|
|
347
|
-
}
|
|
348
|
-
return response;
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
// Run method-specific guards
|
|
353
|
-
const methodGuards = this.config.methodGuards.get(method) || [];
|
|
354
|
-
for (const guard of methodGuards) {
|
|
355
|
-
const result = await guard(ctx);
|
|
356
|
-
if (result !== NEXT_SYMBOL) {
|
|
357
|
-
return result as Response;
|
|
358
|
-
}
|
|
359
|
-
if (!ctx.shouldContinue) {
|
|
360
|
-
const response = ctx.getResponse();
|
|
361
|
-
if (!response) {
|
|
362
|
-
throw new Error("Guard set shouldContinue=false but no response was provided");
|
|
363
|
-
}
|
|
364
|
-
return response;
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// Get handler for method
|
|
369
|
-
const handler = this.config.handlers.get(method);
|
|
370
|
-
if (!handler) {
|
|
371
|
-
return ctx.json(
|
|
372
|
-
{
|
|
373
|
-
status: "error",
|
|
374
|
-
message: `Method ${method} not allowed`,
|
|
375
|
-
allowed: Array.from(this.config.handlers.keys()),
|
|
376
|
-
},
|
|
377
|
-
405
|
|
378
|
-
);
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// Execute handler
|
|
382
|
-
return await handler(ctx);
|
|
383
|
-
} catch (error) {
|
|
384
|
-
// Handle authentication errors
|
|
385
|
-
if (error instanceof AuthenticationError) {
|
|
386
|
-
return ctx.json(
|
|
387
|
-
{
|
|
388
|
-
errorType: "AUTH_ERROR",
|
|
389
|
-
code: "AUTHENTICATION_REQUIRED",
|
|
390
|
-
message: error.message,
|
|
391
|
-
summary: "인증 필요 - 로그인 후 다시 시도하세요",
|
|
392
|
-
timestamp: new Date().toISOString(),
|
|
393
|
-
},
|
|
394
|
-
401
|
|
395
|
-
);
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
// Handle authorization errors
|
|
399
|
-
if (error instanceof AuthorizationError) {
|
|
400
|
-
return ctx.json(
|
|
401
|
-
{
|
|
402
|
-
errorType: "AUTH_ERROR",
|
|
403
|
-
code: "ACCESS_DENIED",
|
|
404
|
-
message: error.message,
|
|
405
|
-
summary: "권한 없음 - 접근 권한이 부족합니다",
|
|
406
|
-
requiredRoles: error.requiredRoles,
|
|
407
|
-
timestamp: new Date().toISOString(),
|
|
408
|
-
},
|
|
409
|
-
403
|
|
410
|
-
);
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
// Handle validation errors with enhanced error format
|
|
414
|
-
if (error instanceof ValidationError) {
|
|
415
|
-
return ctx.json(
|
|
416
|
-
{
|
|
417
|
-
errorType: "LOGIC_ERROR",
|
|
418
|
-
code: ErrorCode.SLOT_VALIDATION_ERROR,
|
|
419
|
-
message: "Validation failed",
|
|
420
|
-
summary: "입력 검증 실패 - 요청 데이터 확인 필요",
|
|
421
|
-
fix: {
|
|
422
|
-
file: routeContext ? `spec/slots/${routeContext.routeId}.slot.ts` : "spec/slots/",
|
|
423
|
-
suggestion: "요청 데이터가 스키마와 일치하는지 확인하세요",
|
|
424
|
-
},
|
|
425
|
-
route: routeContext,
|
|
426
|
-
errors: error.errors,
|
|
427
|
-
timestamp: new Date().toISOString(),
|
|
428
|
-
},
|
|
429
|
-
400
|
|
430
|
-
);
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// Handle other errors with error classification
|
|
434
|
-
const classifier = new ErrorClassifier(null, routeContext);
|
|
435
|
-
const manduError = classifier.classify(error);
|
|
436
|
-
|
|
437
|
-
console.error(`[Mandu] ${manduError.errorType}:`, manduError.message);
|
|
438
|
-
|
|
439
|
-
const response = formatErrorResponse(manduError, {
|
|
440
|
-
isDev: process.env.NODE_ENV !== "production",
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
return ctx.json(response, 500);
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
/**
|
|
448
|
-
* Get list of registered methods
|
|
449
|
-
*/
|
|
450
|
-
getMethods(): HttpMethod[] {
|
|
451
|
-
return Array.from(this.config.handlers.keys());
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
/**
|
|
455
|
-
* Check if method is registered
|
|
456
|
-
*/
|
|
457
|
-
hasMethod(method: HttpMethod): boolean {
|
|
458
|
-
return this.config.handlers.has(method);
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
/**
|
|
463
|
-
* Mandu namespace with factory methods
|
|
464
|
-
*/
|
|
465
|
-
export const Mandu = {
|
|
466
|
-
/**
|
|
467
|
-
* Create a new filling (slot logic builder)
|
|
468
|
-
* @example
|
|
469
|
-
* ```typescript
|
|
470
|
-
* import { Mandu } from '@mandujs/core'
|
|
471
|
-
*
|
|
472
|
-
* export default Mandu.filling()
|
|
473
|
-
* .get(ctx => ctx.ok({ message: 'Hello!' }))
|
|
474
|
-
* ```
|
|
475
|
-
*
|
|
476
|
-
* @example with loader data type
|
|
477
|
-
* ```typescript
|
|
478
|
-
* import { Mandu } from '@mandujs/core'
|
|
479
|
-
*
|
|
480
|
-
* interface LoaderData {
|
|
481
|
-
* todos: Todo[];
|
|
482
|
-
* user: User | null;
|
|
483
|
-
* }
|
|
484
|
-
*
|
|
485
|
-
* export default Mandu.filling<LoaderData>()
|
|
486
|
-
* .loader(async (ctx) => {
|
|
487
|
-
* const todos = await db.todos.findMany();
|
|
488
|
-
* return { todos, user: null };
|
|
489
|
-
* })
|
|
490
|
-
* .get(ctx => ctx.ok(ctx.get('loaderData')))
|
|
491
|
-
* ```
|
|
492
|
-
*/
|
|
493
|
-
filling<TLoaderData = unknown>(): ManduFilling<TLoaderData> {
|
|
494
|
-
return new ManduFilling<TLoaderData>();
|
|
495
|
-
},
|
|
496
|
-
|
|
497
|
-
/**
|
|
498
|
-
* Create an API contract (schema-first definition)
|
|
499
|
-
*
|
|
500
|
-
* Contract-first 방식으로 API 스키마를 정의합니다.
|
|
501
|
-
* 정의된 스키마는 다음에 활용됩니다:
|
|
502
|
-
* - TypeScript 타입 추론 (Slot에서 자동 완성)
|
|
503
|
-
* - 런타임 요청/응답 검증
|
|
504
|
-
* - OpenAPI 문서 자동 생성
|
|
505
|
-
* - Guard의 Contract-Slot 일관성 검사
|
|
506
|
-
*
|
|
507
|
-
* @example
|
|
508
|
-
* ```typescript
|
|
509
|
-
* import { z } from "zod";
|
|
510
|
-
* import { Mandu } from "@mandujs/core";
|
|
511
|
-
*
|
|
512
|
-
* const UserSchema = z.object({
|
|
513
|
-
* id: z.string().uuid(),
|
|
514
|
-
* email: z.string().email(),
|
|
515
|
-
* name: z.string().min(2),
|
|
516
|
-
* });
|
|
517
|
-
*
|
|
518
|
-
* export default Mandu.contract({
|
|
519
|
-
* description: "사용자 관리 API",
|
|
520
|
-
* tags: ["users"],
|
|
521
|
-
*
|
|
522
|
-
* request: {
|
|
523
|
-
* GET: {
|
|
524
|
-
* query: z.object({
|
|
525
|
-
* page: z.coerce.number().default(1),
|
|
526
|
-
* limit: z.coerce.number().default(10),
|
|
527
|
-
* }),
|
|
528
|
-
* },
|
|
529
|
-
* POST: {
|
|
530
|
-
* body: UserSchema.omit({ id: true }),
|
|
531
|
-
* },
|
|
532
|
-
* },
|
|
533
|
-
*
|
|
534
|
-
* response: {
|
|
535
|
-
* 200: z.object({ data: z.array(UserSchema) }),
|
|
536
|
-
* 201: z.object({ data: UserSchema }),
|
|
537
|
-
* 400: z.object({ error: z.string() }),
|
|
538
|
-
* },
|
|
539
|
-
* });
|
|
540
|
-
* ```
|
|
541
|
-
*/
|
|
542
|
-
contract<T extends ContractDefinition>(definition: T): T & ContractInstance {
|
|
543
|
-
return createContract(definition);
|
|
544
|
-
},
|
|
545
|
-
|
|
546
|
-
/**
|
|
547
|
-
* Create context manually (for testing)
|
|
548
|
-
*/
|
|
549
|
-
context(request: Request, params?: Record<string, string>): ManduContext {
|
|
550
|
-
return new ManduContext(request, params);
|
|
551
|
-
},
|
|
552
|
-
};
|
|
1
|
+
/**
|
|
2
|
+
* Mandu Filling - 만두소 🥟
|
|
3
|
+
* 체이닝 API로 비즈니스 로직 정의
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { ManduContext, ValidationError } from "./context";
|
|
7
|
+
import { AuthenticationError, AuthorizationError } from "./auth";
|
|
8
|
+
import { ErrorClassifier, formatErrorResponse, ErrorCode } from "../error";
|
|
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";
|
|
20
|
+
|
|
21
|
+
/** Handler function type */
|
|
22
|
+
export type Handler = (ctx: ManduContext) => Response | Promise<Response>;
|
|
23
|
+
|
|
24
|
+
/** HTTP methods */
|
|
25
|
+
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
|
|
26
|
+
|
|
27
|
+
/** Loader function type - SSR 데이터 로딩 */
|
|
28
|
+
export type Loader<T = unknown> = (ctx: ManduContext) => T | Promise<T>;
|
|
29
|
+
|
|
30
|
+
/** Loader 실행 옵션 */
|
|
31
|
+
export interface LoaderOptions<T = unknown> {
|
|
32
|
+
/** 타임아웃 (ms), 기본값 5000 */
|
|
33
|
+
timeout?: number;
|
|
34
|
+
/** 타임아웃 또는 에러 시 반환할 fallback 데이터 */
|
|
35
|
+
fallback?: T;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Loader 타임아웃 에러 */
|
|
39
|
+
export class LoaderTimeoutError extends Error {
|
|
40
|
+
constructor(timeout: number) {
|
|
41
|
+
super(`Loader timed out after ${timeout}ms`);
|
|
42
|
+
this.name = "LoaderTimeoutError";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface FillingConfig<TLoaderData = unknown> {
|
|
47
|
+
handlers: Map<HttpMethod, Handler>;
|
|
48
|
+
loader?: Loader<TLoaderData>;
|
|
49
|
+
lifecycle: LifecycleStore;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class ManduFilling<TLoaderData = unknown> {
|
|
53
|
+
private config: FillingConfig<TLoaderData> = {
|
|
54
|
+
handlers: new Map(),
|
|
55
|
+
lifecycle: createLifecycleStore(),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
loader(loaderFn: Loader<TLoaderData>): this {
|
|
59
|
+
this.config.loader = loaderFn;
|
|
60
|
+
return this;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async executeLoader(
|
|
64
|
+
ctx: ManduContext,
|
|
65
|
+
options: LoaderOptions<TLoaderData> = {}
|
|
66
|
+
): Promise<TLoaderData | undefined> {
|
|
67
|
+
if (!this.config.loader) {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
const { timeout = 5000, fallback } = options;
|
|
71
|
+
try {
|
|
72
|
+
const loaderPromise = Promise.resolve(this.config.loader(ctx));
|
|
73
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
74
|
+
setTimeout(() => reject(new LoaderTimeoutError(timeout)), timeout);
|
|
75
|
+
});
|
|
76
|
+
return await Promise.race([loaderPromise, timeoutPromise]);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (fallback !== undefined) {
|
|
79
|
+
console.warn(`[Mandu] Loader failed, using fallback:`, error instanceof Error ? error.message : String(error));
|
|
80
|
+
return fallback;
|
|
81
|
+
}
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
hasLoader(): boolean {
|
|
87
|
+
return !!this.config.loader;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
get(handler: Handler): this {
|
|
91
|
+
this.config.handlers.set("GET", handler);
|
|
92
|
+
return this;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
post(handler: Handler): this {
|
|
96
|
+
this.config.handlers.set("POST", handler);
|
|
97
|
+
return this;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
put(handler: Handler): this {
|
|
101
|
+
this.config.handlers.set("PUT", handler);
|
|
102
|
+
return this;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
patch(handler: Handler): this {
|
|
106
|
+
this.config.handlers.set("PATCH", handler);
|
|
107
|
+
return this;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
delete(handler: Handler): this {
|
|
111
|
+
this.config.handlers.set("DELETE", handler);
|
|
112
|
+
return this;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
head(handler: Handler): this {
|
|
116
|
+
this.config.handlers.set("HEAD", handler);
|
|
117
|
+
return this;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
options(handler: Handler): this {
|
|
121
|
+
this.config.handlers.set("OPTIONS", handler);
|
|
122
|
+
return this;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
all(handler: Handler): this {
|
|
126
|
+
const methods: HttpMethod[] = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
|
|
127
|
+
methods.forEach((method) => this.config.handlers.set(method, handler));
|
|
128
|
+
return this;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
onRequest(fn: OnRequestHandler): this {
|
|
132
|
+
this.config.lifecycle.onRequest.push({ fn, scope: "local" });
|
|
133
|
+
return this;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
beforeHandle(fn: BeforeHandleHandler): this {
|
|
137
|
+
this.config.lifecycle.beforeHandle.push({ fn, scope: "local" });
|
|
138
|
+
return this;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
afterHandle(fn: AfterHandleHandler): this {
|
|
142
|
+
this.config.lifecycle.afterHandle.push({ fn, scope: "local" });
|
|
143
|
+
return this;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
onError(fn: OnErrorHandler): this {
|
|
147
|
+
this.config.lifecycle.onError.push({ fn, scope: "local" });
|
|
148
|
+
return this;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
afterResponse(fn: AfterResponseHandler): this {
|
|
152
|
+
this.config.lifecycle.afterResponse.push({ fn, scope: "local" });
|
|
153
|
+
return this;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async handle(
|
|
157
|
+
request: Request,
|
|
158
|
+
params: Record<string, string> = {},
|
|
159
|
+
routeContext?: { routeId: string; pattern: string }
|
|
160
|
+
): Promise<Response> {
|
|
161
|
+
const ctx = new ManduContext(request, params);
|
|
162
|
+
const method = request.method.toUpperCase() as HttpMethod;
|
|
163
|
+
const handler = this.config.handlers.get(method);
|
|
164
|
+
if (!handler) {
|
|
165
|
+
return ctx.json({ status: "error", message: `Method ${method} not allowed`, allowed: Array.from(this.config.handlers.keys()) }, 405);
|
|
166
|
+
}
|
|
167
|
+
const lifecycleWithDefaults = this.createLifecycleWithDefaults(routeContext);
|
|
168
|
+
return executeLifecycle(lifecycleWithDefaults, ctx, async () => handler(ctx));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private createLifecycleWithDefaults(routeContext?: { routeId: string; pattern: string }): LifecycleStore {
|
|
172
|
+
const lifecycle: LifecycleStore = {
|
|
173
|
+
onRequest: [...this.config.lifecycle.onRequest],
|
|
174
|
+
onParse: [...this.config.lifecycle.onParse],
|
|
175
|
+
beforeHandle: [...this.config.lifecycle.beforeHandle],
|
|
176
|
+
afterHandle: [...this.config.lifecycle.afterHandle],
|
|
177
|
+
mapResponse: [...this.config.lifecycle.mapResponse],
|
|
178
|
+
afterResponse: [...this.config.lifecycle.afterResponse],
|
|
179
|
+
onError: [...this.config.lifecycle.onError],
|
|
180
|
+
};
|
|
181
|
+
const defaultErrorHandler: OnErrorHandler = (ctx, error) => {
|
|
182
|
+
if (error instanceof AuthenticationError) {
|
|
183
|
+
return ctx.json({ errorType: "AUTH_ERROR", code: "AUTHENTICATION_REQUIRED", message: error.message, summary: "인증 필요 - 로그인 후 다시 시도하세요", timestamp: new Date().toISOString() }, 401);
|
|
184
|
+
}
|
|
185
|
+
if (error instanceof AuthorizationError) {
|
|
186
|
+
return ctx.json({ errorType: "AUTH_ERROR", code: "ACCESS_DENIED", message: error.message, summary: "권한 없음 - 접근 권한이 부족합니다", requiredRoles: error.requiredRoles, timestamp: new Date().toISOString() }, 403);
|
|
187
|
+
}
|
|
188
|
+
if (error instanceof ValidationError) {
|
|
189
|
+
return ctx.json({ errorType: "LOGIC_ERROR", code: ErrorCode.SLOT_VALIDATION_ERROR, message: "Validation failed", summary: "입력 검증 실패 - 요청 데이터 확인 필요", fix: { file: routeContext ? `spec/slots/${routeContext.routeId}.slot.ts` : "spec/slots/", suggestion: "요청 데이터가 스키마와 일치하는지 확인하세요" }, route: routeContext, errors: error.errors, timestamp: new Date().toISOString() }, 400);
|
|
190
|
+
}
|
|
191
|
+
const classifier = new ErrorClassifier(null, routeContext);
|
|
192
|
+
const manduError = classifier.classify(error);
|
|
193
|
+
console.error(`[Mandu] ${manduError.errorType}:`, manduError.message);
|
|
194
|
+
const response = formatErrorResponse(manduError, { isDev: process.env.NODE_ENV !== "production" });
|
|
195
|
+
return ctx.json(response, 500);
|
|
196
|
+
};
|
|
197
|
+
lifecycle.onError.push({ fn: defaultErrorHandler, scope: "local" });
|
|
198
|
+
return lifecycle;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
getMethods(): HttpMethod[] {
|
|
202
|
+
return Array.from(this.config.handlers.keys());
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
hasMethod(method: HttpMethod): boolean {
|
|
206
|
+
return this.config.handlers.has(method);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export const Mandu = {
|
|
211
|
+
filling<TLoaderData = unknown>(): ManduFilling<TLoaderData> {
|
|
212
|
+
return new ManduFilling<TLoaderData>();
|
|
213
|
+
},
|
|
214
|
+
contract<T extends ContractDefinition>(definition: T): T & ContractInstance {
|
|
215
|
+
return createContract(definition);
|
|
216
|
+
},
|
|
217
|
+
context(request: Request, params?: Record<string, string>): ManduContext {
|
|
218
|
+
return new ManduContext(request, params);
|
|
219
|
+
},
|
|
220
|
+
};
|