@mandujs/core 0.9.9 → 0.9.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +200 -200
- package/README.md +200 -200
- package/package.json +52 -52
- package/src/bundler/build.ts +3 -4
- package/src/client/Link.tsx +227 -209
- package/src/client/hooks.ts +267 -267
- package/src/client/router.ts +443 -443
- package/src/client/serialize.ts +404 -404
- package/src/filling/auth.ts +308 -308
- package/src/filling/context.ts +438 -438
- package/src/generator/index.ts +3 -3
- package/src/generator/templates.ts +73 -1
- package/src/guard/check.ts +44 -0
- package/src/guard/rules.ts +13 -0
- package/src/report/index.ts +1 -1
- package/src/runtime/compose.ts +222 -222
- package/src/runtime/index.ts +3 -3
- package/src/runtime/lifecycle.ts +381 -381
- package/src/runtime/ssr.ts +321 -321
- package/src/runtime/trace.ts +144 -144
- package/src/spec/index.ts +3 -3
- package/src/spec/load.ts +76 -76
- package/src/spec/lock.ts +56 -56
- package/src/watcher/rules.ts +8 -0
package/src/generator/index.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export * from "./generate";
|
|
2
|
-
export * from "./templates";
|
|
3
|
-
export * from "./contract-glue";
|
|
1
|
+
export * from "./generate";
|
|
2
|
+
export * from "./templates";
|
|
3
|
+
export * from "./contract-glue";
|
|
@@ -189,13 +189,18 @@ function computeSlotImportPath(slotModule: string, fromDir: string): string {
|
|
|
189
189
|
}
|
|
190
190
|
|
|
191
191
|
export function generatePageComponent(route: RouteSpec): string {
|
|
192
|
-
|
|
192
|
+
// Island-First: clientModule이 있으면 Island render를 SSR에서 직접 사용
|
|
193
|
+
if (route.clientModule) {
|
|
194
|
+
return generatePageComponentWithIsland(route);
|
|
195
|
+
}
|
|
193
196
|
|
|
194
197
|
// slotModule이 있으면 PageHandler 형식으로 생성 (filling 포함)
|
|
195
198
|
if (route.slotModule) {
|
|
196
199
|
return generatePageHandlerWithSlot(route);
|
|
197
200
|
}
|
|
198
201
|
|
|
202
|
+
const pageName = toPascalCase(route.id);
|
|
203
|
+
|
|
199
204
|
// slotModule이 없으면 기존 방식
|
|
200
205
|
return `// Generated by Mandu - DO NOT EDIT DIRECTLY
|
|
201
206
|
// Route ID: ${route.id}
|
|
@@ -218,6 +223,73 @@ export default function ${pageName}Page({ params }: Props): React.ReactElement {
|
|
|
218
223
|
`;
|
|
219
224
|
}
|
|
220
225
|
|
|
226
|
+
/**
|
|
227
|
+
* Island-First Rendering: SSR이 island의 render 함수를 직접 사용
|
|
228
|
+
* - clientModule의 definition.setup() + definition.render() 호출
|
|
229
|
+
* - SSR과 클라이언트가 동일한 렌더링 로직 사용 → 불일치 구조적 방지
|
|
230
|
+
* - slotModule 유무에 따라 두 가지 변형 생성
|
|
231
|
+
*/
|
|
232
|
+
export function generatePageComponentWithIsland(route: RouteSpec): string {
|
|
233
|
+
const pageName = toPascalCase(route.id);
|
|
234
|
+
const clientImportPath = computeSlotImportPath(route.clientModule!, "apps/web/generated/routes");
|
|
235
|
+
|
|
236
|
+
// clientModule + slotModule → PageRegistration 형식
|
|
237
|
+
if (route.slotModule) {
|
|
238
|
+
const slotImportPath = computeSlotImportPath(route.slotModule!, "apps/web/generated/routes");
|
|
239
|
+
|
|
240
|
+
return `// Generated by Mandu - DO NOT EDIT DIRECTLY
|
|
241
|
+
// Island-First Rendering + Slot Module
|
|
242
|
+
// Route ID: ${route.id}
|
|
243
|
+
// Pattern: ${route.pattern}
|
|
244
|
+
// Client Module: ${route.clientModule}
|
|
245
|
+
// Slot Module: ${route.slotModule}
|
|
246
|
+
|
|
247
|
+
import React from "react";
|
|
248
|
+
import filling from "${slotImportPath}";
|
|
249
|
+
import islandModule from "${clientImportPath}";
|
|
250
|
+
|
|
251
|
+
interface Props {
|
|
252
|
+
params: Record<string, string>;
|
|
253
|
+
loaderData?: unknown;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function ${pageName}Page({ params, loaderData }: Props): React.ReactElement {
|
|
257
|
+
const serverData = (loaderData || {}) as any;
|
|
258
|
+
const setupResult = islandModule.definition.setup(serverData);
|
|
259
|
+
return islandModule.definition.render(setupResult) as React.ReactElement;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// PageRegistration 형식으로 export (server.ts의 registerPageHandler용)
|
|
263
|
+
export default {
|
|
264
|
+
component: ${pageName}Page,
|
|
265
|
+
filling: filling,
|
|
266
|
+
};
|
|
267
|
+
`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// clientModule만 (slotModule 없음) → default export 컴포넌트
|
|
271
|
+
return `// Generated by Mandu - DO NOT EDIT DIRECTLY
|
|
272
|
+
// Island-First Rendering: SSR이 island render 직접 사용
|
|
273
|
+
// Route ID: ${route.id}
|
|
274
|
+
// Pattern: ${route.pattern}
|
|
275
|
+
// Client Module: ${route.clientModule}
|
|
276
|
+
|
|
277
|
+
import React from "react";
|
|
278
|
+
import islandModule from "${clientImportPath}";
|
|
279
|
+
|
|
280
|
+
interface Props {
|
|
281
|
+
params: Record<string, string>;
|
|
282
|
+
loaderData?: unknown;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export default function ${pageName}Page({ params, loaderData }: Props): React.ReactElement {
|
|
286
|
+
const serverData = (loaderData || {}) as any;
|
|
287
|
+
const setupResult = islandModule.definition.setup(serverData);
|
|
288
|
+
return islandModule.definition.render(setupResult) as React.ReactElement;
|
|
289
|
+
}
|
|
290
|
+
`;
|
|
291
|
+
}
|
|
292
|
+
|
|
221
293
|
/**
|
|
222
294
|
* slotModule이 있는 Page Route용 Handler 생성
|
|
223
295
|
* - component와 filling을 함께 export
|
package/src/guard/check.ts
CHANGED
|
@@ -193,6 +193,46 @@ export async function checkSlotContentValidation(
|
|
|
193
193
|
return violations;
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
// Rule: Island-First Integrity
|
|
197
|
+
export async function checkIslandFirstIntegrity(
|
|
198
|
+
manifest: RoutesManifest,
|
|
199
|
+
rootDir: string
|
|
200
|
+
): Promise<GuardViolation[]> {
|
|
201
|
+
const violations: GuardViolation[] = [];
|
|
202
|
+
|
|
203
|
+
for (const route of manifest.routes) {
|
|
204
|
+
if (route.kind !== "page" || !route.clientModule) continue;
|
|
205
|
+
|
|
206
|
+
// 1. clientModule 파일 존재 여부
|
|
207
|
+
const clientPath = path.join(rootDir, route.clientModule);
|
|
208
|
+
if (!(await fileExists(clientPath))) {
|
|
209
|
+
violations.push({
|
|
210
|
+
ruleId: "CLIENT_MODULE_NOT_FOUND",
|
|
211
|
+
file: route.clientModule,
|
|
212
|
+
message: `clientModule 파일을 찾을 수 없습니다 (routeId: ${route.id})`,
|
|
213
|
+
suggestion: "clientModule 경로를 확인하거나 파일을 생성하세요",
|
|
214
|
+
});
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 2. componentModule이 island을 import하는지 확인
|
|
219
|
+
if (route.componentModule) {
|
|
220
|
+
const componentPath = path.join(rootDir, route.componentModule);
|
|
221
|
+
const content = await readFileContent(componentPath);
|
|
222
|
+
if (content && !content.includes("islandModule") && !content.includes("Island-First")) {
|
|
223
|
+
violations.push({
|
|
224
|
+
ruleId: "ISLAND_FIRST_INTEGRITY",
|
|
225
|
+
file: route.componentModule,
|
|
226
|
+
message: `componentModule이 island을 import하지 않습니다 (routeId: ${route.id})`,
|
|
227
|
+
suggestion: "mandu generate를 실행하여 Island-First 템플릿으로 재생성하세요",
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return violations;
|
|
234
|
+
}
|
|
235
|
+
|
|
196
236
|
// Rule 4: Forbidden imports in generated files
|
|
197
237
|
export async function checkForbiddenImportsInGenerated(
|
|
198
238
|
rootDir: string,
|
|
@@ -303,6 +343,10 @@ export async function runGuardCheck(
|
|
|
303
343
|
const contractViolations = await runContractGuardCheck(manifest, rootDir);
|
|
304
344
|
violations.push(...contractViolations);
|
|
305
345
|
|
|
346
|
+
// Rule: Island-First Integrity
|
|
347
|
+
const islandViolations = await checkIslandFirstIntegrity(manifest, rootDir);
|
|
348
|
+
violations.push(...islandViolations);
|
|
349
|
+
|
|
306
350
|
return {
|
|
307
351
|
passed: violations.length === 0,
|
|
308
352
|
violations,
|
package/src/guard/rules.ts
CHANGED
|
@@ -95,6 +95,19 @@ export const GUARD_RULES: Record<string, GuardRule> = {
|
|
|
95
95
|
description: "Slot에 구현된 메서드가 Contract에 문서화되지 않았습니다",
|
|
96
96
|
severity: "warning",
|
|
97
97
|
},
|
|
98
|
+
// Island-First Rendering rules
|
|
99
|
+
ISLAND_FIRST_INTEGRITY: {
|
|
100
|
+
id: "ISLAND_FIRST_INTEGRITY",
|
|
101
|
+
name: "Island-First Integrity",
|
|
102
|
+
description: "clientModule이 있는 page route의 componentModule이 island을 import하지 않습니다",
|
|
103
|
+
severity: "error",
|
|
104
|
+
},
|
|
105
|
+
CLIENT_MODULE_NOT_FOUND: {
|
|
106
|
+
id: "CLIENT_MODULE_NOT_FOUND",
|
|
107
|
+
name: "Client Module Not Found",
|
|
108
|
+
description: "spec에 명시된 clientModule 파일을 찾을 수 없습니다",
|
|
109
|
+
severity: "error",
|
|
110
|
+
},
|
|
98
111
|
};
|
|
99
112
|
|
|
100
113
|
export const FORBIDDEN_IMPORTS = ["fs", "child_process", "cluster", "worker_threads"];
|
package/src/report/index.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export * from "./build";
|
|
1
|
+
export * from "./build";
|
package/src/runtime/compose.ts
CHANGED
|
@@ -1,222 +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
|
-
}
|
|
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
|
@@ -3,6 +3,6 @@ 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";
|
|
8
|
-
export * from "./trace";
|
|
6
|
+
export * from "./compose";
|
|
7
|
+
export * from "./lifecycle";
|
|
8
|
+
export * from "./trace";
|