@mandujs/core 0.5.5 → 0.5.7
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 +2 -2
- package/src/bundler/build.ts +40 -0
- package/src/filling/auth.ts +308 -0
- package/src/filling/filling.ts +30 -0
- package/src/filling/index.ts +13 -0
- package/src/generator/templates.ts +47 -0
- package/src/runtime/server.ts +78 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mandujs/core",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.7",
|
|
4
4
|
"description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
],
|
|
23
23
|
"repository": {
|
|
24
24
|
"type": "git",
|
|
25
|
-
"url": "https://github.com/konamgil/mandu.git",
|
|
25
|
+
"url": "git+https://github.com/konamgil/mandu.git",
|
|
26
26
|
"directory": "packages/core"
|
|
27
27
|
},
|
|
28
28
|
"author": "konamgil",
|
package/src/bundler/build.ts
CHANGED
|
@@ -249,6 +249,36 @@ export default ReactDOMClient;
|
|
|
249
249
|
`;
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
/**
|
|
253
|
+
* JSX Runtime shim 소스 생성
|
|
254
|
+
*/
|
|
255
|
+
function generateJsxRuntimeShimSource(): string {
|
|
256
|
+
return `
|
|
257
|
+
/**
|
|
258
|
+
* Mandu JSX Runtime Shim (Generated)
|
|
259
|
+
* Production JSX 변환용
|
|
260
|
+
*/
|
|
261
|
+
import * as jsxRuntime from 'react/jsx-runtime';
|
|
262
|
+
export * from 'react/jsx-runtime';
|
|
263
|
+
export default jsxRuntime;
|
|
264
|
+
`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* JSX Dev Runtime shim 소스 생성
|
|
269
|
+
*/
|
|
270
|
+
function generateJsxDevRuntimeShimSource(): string {
|
|
271
|
+
return `
|
|
272
|
+
/**
|
|
273
|
+
* Mandu JSX Dev Runtime Shim (Generated)
|
|
274
|
+
* Development JSX 변환용
|
|
275
|
+
*/
|
|
276
|
+
import * as jsxDevRuntime from 'react/jsx-dev-runtime';
|
|
277
|
+
export * from 'react/jsx-dev-runtime';
|
|
278
|
+
export default jsxDevRuntime;
|
|
279
|
+
`;
|
|
280
|
+
}
|
|
281
|
+
|
|
252
282
|
/**
|
|
253
283
|
* Island 엔트리 래퍼 생성
|
|
254
284
|
*/
|
|
@@ -332,6 +362,8 @@ interface VendorBuildResult {
|
|
|
332
362
|
react: string;
|
|
333
363
|
reactDom: string;
|
|
334
364
|
reactDomClient: string;
|
|
365
|
+
jsxRuntime: string;
|
|
366
|
+
jsxDevRuntime: string;
|
|
335
367
|
errors: string[];
|
|
336
368
|
}
|
|
337
369
|
|
|
@@ -348,12 +380,16 @@ async function buildVendorShims(
|
|
|
348
380
|
react: "",
|
|
349
381
|
reactDom: "",
|
|
350
382
|
reactDomClient: "",
|
|
383
|
+
jsxRuntime: "",
|
|
384
|
+
jsxDevRuntime: "",
|
|
351
385
|
};
|
|
352
386
|
|
|
353
387
|
const shims = [
|
|
354
388
|
{ name: "_react", source: generateReactShimSource(), key: "react" },
|
|
355
389
|
{ name: "_react-dom", source: generateReactDOMShimSource(), key: "reactDom" },
|
|
356
390
|
{ name: "_react-dom-client", source: generateReactDOMClientShimSource(), key: "reactDomClient" },
|
|
391
|
+
{ name: "_jsx-runtime", source: generateJsxRuntimeShimSource(), key: "jsxRuntime" },
|
|
392
|
+
{ name: "_jsx-dev-runtime", source: generateJsxDevRuntimeShimSource(), key: "jsxDevRuntime" },
|
|
357
393
|
];
|
|
358
394
|
|
|
359
395
|
for (const shim of shims) {
|
|
@@ -394,6 +430,8 @@ async function buildVendorShims(
|
|
|
394
430
|
react: results.react,
|
|
395
431
|
reactDom: results.reactDom,
|
|
396
432
|
reactDomClient: results.reactDomClient,
|
|
433
|
+
jsxRuntime: results.jsxRuntime,
|
|
434
|
+
jsxDevRuntime: results.jsxDevRuntime,
|
|
397
435
|
errors,
|
|
398
436
|
};
|
|
399
437
|
}
|
|
@@ -494,6 +532,8 @@ function createBundleManifest(
|
|
|
494
532
|
"react": vendorResult.react,
|
|
495
533
|
"react-dom": vendorResult.reactDom,
|
|
496
534
|
"react-dom/client": vendorResult.reactDomClient,
|
|
535
|
+
"react/jsx-runtime": vendorResult.jsxRuntime,
|
|
536
|
+
"react/jsx-dev-runtime": vendorResult.jsxDevRuntime,
|
|
497
537
|
},
|
|
498
538
|
},
|
|
499
539
|
};
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu Auth Guards - 인증/인가 헬퍼 🔐
|
|
3
|
+
*
|
|
4
|
+
* Guard에서 사용할 수 있는 타입-안전 인증 헬퍼
|
|
5
|
+
* 인증 실패 시 적절한 에러를 throw하여 Guard 체인 중단
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ManduContext } from "./context";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 인증 실패 에러 (401 Unauthorized)
|
|
12
|
+
*/
|
|
13
|
+
export class AuthenticationError extends Error {
|
|
14
|
+
readonly statusCode = 401;
|
|
15
|
+
|
|
16
|
+
constructor(message: string = "Authentication required") {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "AuthenticationError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 인가 실패 에러 (403 Forbidden)
|
|
24
|
+
*/
|
|
25
|
+
export class AuthorizationError extends Error {
|
|
26
|
+
readonly statusCode = 403;
|
|
27
|
+
readonly requiredRoles?: string[];
|
|
28
|
+
|
|
29
|
+
constructor(message: string = "Access denied", requiredRoles?: string[]) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "AuthorizationError";
|
|
32
|
+
this.requiredRoles = requiredRoles;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 기본 User 인터페이스
|
|
38
|
+
* 프로젝트에서 확장하여 사용
|
|
39
|
+
*/
|
|
40
|
+
export interface BaseUser {
|
|
41
|
+
id: string;
|
|
42
|
+
[key: string]: unknown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Role을 가진 User 인터페이스
|
|
47
|
+
*/
|
|
48
|
+
export interface UserWithRole extends BaseUser {
|
|
49
|
+
role: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Roles 배열을 가진 User 인터페이스
|
|
54
|
+
*/
|
|
55
|
+
export interface UserWithRoles extends BaseUser {
|
|
56
|
+
roles: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ============================================
|
|
60
|
+
// 🔐 Auth Guard Helpers
|
|
61
|
+
// ============================================
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 인증된 사용자 필수
|
|
65
|
+
* Guard에서 user가 없으면 AuthenticationError throw
|
|
66
|
+
*
|
|
67
|
+
* @param ctx ManduContext
|
|
68
|
+
* @param key store에서 user를 찾을 키 (기본: 'user')
|
|
69
|
+
* @returns 인증된 User (타입 확정)
|
|
70
|
+
* @throws AuthenticationError
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* ```typescript
|
|
74
|
+
* import { requireUser } from '@mandujs/core'
|
|
75
|
+
*
|
|
76
|
+
* export default Mandu.filling()
|
|
77
|
+
* .guard(async (ctx) => {
|
|
78
|
+
* // JWT 토큰 검증 후 user 저장
|
|
79
|
+
* const user = await verifyToken(ctx.headers.get('Authorization'));
|
|
80
|
+
* ctx.set('user', user);
|
|
81
|
+
* return ctx.next();
|
|
82
|
+
* })
|
|
83
|
+
* .get((ctx) => {
|
|
84
|
+
* const user = requireUser(ctx); // User 타입 확정, 없으면 401
|
|
85
|
+
* return ctx.ok({ message: `Hello, ${user.id}!` });
|
|
86
|
+
* })
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
export function requireUser<T extends BaseUser = BaseUser>(
|
|
90
|
+
ctx: ManduContext,
|
|
91
|
+
key: string = "user"
|
|
92
|
+
): T {
|
|
93
|
+
const user = ctx.get<T>(key);
|
|
94
|
+
|
|
95
|
+
if (!user) {
|
|
96
|
+
throw new AuthenticationError("User context is required");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (typeof user !== "object" || !("id" in user)) {
|
|
100
|
+
throw new AuthenticationError("Invalid user context");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return user;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 특정 역할 필수 (단일 role 필드)
|
|
108
|
+
*
|
|
109
|
+
* @param ctx ManduContext
|
|
110
|
+
* @param roles 허용된 역할 목록
|
|
111
|
+
* @param key store에서 user를 찾을 키 (기본: 'user')
|
|
112
|
+
* @returns 인증된 User (타입 확정)
|
|
113
|
+
* @throws AuthenticationError (user 없음)
|
|
114
|
+
* @throws AuthorizationError (역할 불일치)
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* ```typescript
|
|
118
|
+
* .guard((ctx) => {
|
|
119
|
+
* requireRole(ctx, 'admin', 'moderator'); // admin 또는 moderator만 허용
|
|
120
|
+
* return ctx.next();
|
|
121
|
+
* })
|
|
122
|
+
* ```
|
|
123
|
+
*/
|
|
124
|
+
export function requireRole<T extends UserWithRole = UserWithRole>(
|
|
125
|
+
ctx: ManduContext,
|
|
126
|
+
...roles: string[]
|
|
127
|
+
): T {
|
|
128
|
+
const user = requireUser<T>(ctx);
|
|
129
|
+
|
|
130
|
+
if (!("role" in user) || typeof user.role !== "string") {
|
|
131
|
+
throw new AuthorizationError("User has no role defined");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!roles.includes(user.role)) {
|
|
135
|
+
throw new AuthorizationError(
|
|
136
|
+
`Required role: ${roles.join(" or ")}`,
|
|
137
|
+
roles
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return user;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 특정 역할 중 하나 필수 (roles 배열 필드)
|
|
146
|
+
*
|
|
147
|
+
* @param ctx ManduContext
|
|
148
|
+
* @param roles 허용된 역할 목록 (하나라도 있으면 통과)
|
|
149
|
+
* @param key store에서 user를 찾을 키 (기본: 'user')
|
|
150
|
+
* @returns 인증된 User (타입 확정)
|
|
151
|
+
* @throws AuthenticationError (user 없음)
|
|
152
|
+
* @throws AuthorizationError (역할 불일치)
|
|
153
|
+
*
|
|
154
|
+
* @example
|
|
155
|
+
* ```typescript
|
|
156
|
+
* .guard((ctx) => {
|
|
157
|
+
* requireAnyRole(ctx, 'editor', 'admin'); // editor 또는 admin 역할 필요
|
|
158
|
+
* return ctx.next();
|
|
159
|
+
* })
|
|
160
|
+
* ```
|
|
161
|
+
*/
|
|
162
|
+
export function requireAnyRole<T extends UserWithRoles = UserWithRoles>(
|
|
163
|
+
ctx: ManduContext,
|
|
164
|
+
...roles: string[]
|
|
165
|
+
): T {
|
|
166
|
+
const user = requireUser<T>(ctx);
|
|
167
|
+
|
|
168
|
+
if (!("roles" in user) || !Array.isArray(user.roles)) {
|
|
169
|
+
throw new AuthorizationError("User has no roles defined");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const hasRole = roles.some((role) => user.roles.includes(role));
|
|
173
|
+
|
|
174
|
+
if (!hasRole) {
|
|
175
|
+
throw new AuthorizationError(
|
|
176
|
+
`Required one of roles: ${roles.join(", ")}`,
|
|
177
|
+
roles
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return user;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 모든 역할 필수 (roles 배열 필드)
|
|
186
|
+
*
|
|
187
|
+
* @param ctx ManduContext
|
|
188
|
+
* @param roles 필요한 역할 목록 (모두 있어야 통과)
|
|
189
|
+
* @returns 인증된 User (타입 확정)
|
|
190
|
+
* @throws AuthenticationError (user 없음)
|
|
191
|
+
* @throws AuthorizationError (역할 불일치)
|
|
192
|
+
*
|
|
193
|
+
* @example
|
|
194
|
+
* ```typescript
|
|
195
|
+
* .guard((ctx) => {
|
|
196
|
+
* requireAllRoles(ctx, 'verified', 'premium'); // verified AND premium 필요
|
|
197
|
+
* return ctx.next();
|
|
198
|
+
* })
|
|
199
|
+
* ```
|
|
200
|
+
*/
|
|
201
|
+
export function requireAllRoles<T extends UserWithRoles = UserWithRoles>(
|
|
202
|
+
ctx: ManduContext,
|
|
203
|
+
...roles: string[]
|
|
204
|
+
): T {
|
|
205
|
+
const user = requireUser<T>(ctx);
|
|
206
|
+
|
|
207
|
+
if (!("roles" in user) || !Array.isArray(user.roles)) {
|
|
208
|
+
throw new AuthorizationError("User has no roles defined");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const missingRoles = roles.filter((role) => !user.roles.includes(role));
|
|
212
|
+
|
|
213
|
+
if (missingRoles.length > 0) {
|
|
214
|
+
throw new AuthorizationError(
|
|
215
|
+
`Missing required roles: ${missingRoles.join(", ")}`,
|
|
216
|
+
roles
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return user;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ============================================
|
|
224
|
+
// 🔐 Auth Guard Factory
|
|
225
|
+
// ============================================
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 인증 Guard 생성 팩토리
|
|
229
|
+
* 반복되는 인증 로직을 Guard로 변환
|
|
230
|
+
*
|
|
231
|
+
* @example
|
|
232
|
+
* ```typescript
|
|
233
|
+
* const authGuard = createAuthGuard(async (ctx) => {
|
|
234
|
+
* const token = ctx.headers.get('Authorization')?.replace('Bearer ', '');
|
|
235
|
+
* if (!token) return null;
|
|
236
|
+
* return await verifyJwt(token);
|
|
237
|
+
* });
|
|
238
|
+
*
|
|
239
|
+
* export default Mandu.filling()
|
|
240
|
+
* .guard(authGuard)
|
|
241
|
+
* .get((ctx) => {
|
|
242
|
+
* const user = requireUser(ctx);
|
|
243
|
+
* return ctx.ok({ user });
|
|
244
|
+
* })
|
|
245
|
+
* ```
|
|
246
|
+
*/
|
|
247
|
+
export function createAuthGuard<T extends BaseUser>(
|
|
248
|
+
authenticator: (ctx: ManduContext) => T | null | Promise<T | null>,
|
|
249
|
+
options: {
|
|
250
|
+
key?: string;
|
|
251
|
+
onUnauthenticated?: (ctx: ManduContext) => Response;
|
|
252
|
+
} = {}
|
|
253
|
+
) {
|
|
254
|
+
const { key = "user", onUnauthenticated } = options;
|
|
255
|
+
|
|
256
|
+
return async (ctx: ManduContext): Promise<symbol | Response> => {
|
|
257
|
+
try {
|
|
258
|
+
const user = await authenticator(ctx);
|
|
259
|
+
|
|
260
|
+
if (user) {
|
|
261
|
+
ctx.set(key, user);
|
|
262
|
+
return ctx.next();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (onUnauthenticated) {
|
|
266
|
+
return onUnauthenticated(ctx);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return ctx.unauthorized("Authentication required");
|
|
270
|
+
} catch (error) {
|
|
271
|
+
if (error instanceof AuthenticationError) {
|
|
272
|
+
return ctx.unauthorized(error.message);
|
|
273
|
+
}
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* 역할 기반 Guard 생성 팩토리
|
|
281
|
+
*
|
|
282
|
+
* @example
|
|
283
|
+
* ```typescript
|
|
284
|
+
* const adminOnly = createRoleGuard('admin');
|
|
285
|
+
* const editorOrAdmin = createRoleGuard('editor', 'admin');
|
|
286
|
+
*
|
|
287
|
+
* export default Mandu.filling()
|
|
288
|
+
* .guard(authGuard)
|
|
289
|
+
* .guard(adminOnly) // admin만 접근 가능
|
|
290
|
+
* .delete((ctx) => ctx.noContent())
|
|
291
|
+
* ```
|
|
292
|
+
*/
|
|
293
|
+
export function createRoleGuard(...allowedRoles: string[]) {
|
|
294
|
+
return (ctx: ManduContext): symbol | Response => {
|
|
295
|
+
try {
|
|
296
|
+
requireRole(ctx, ...allowedRoles);
|
|
297
|
+
return ctx.next();
|
|
298
|
+
} catch (error) {
|
|
299
|
+
if (error instanceof AuthenticationError) {
|
|
300
|
+
return ctx.unauthorized(error.message);
|
|
301
|
+
}
|
|
302
|
+
if (error instanceof AuthorizationError) {
|
|
303
|
+
return ctx.forbidden(error.message);
|
|
304
|
+
}
|
|
305
|
+
throw error;
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
}
|
package/src/filling/filling.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { ManduContext, NEXT_SYMBOL, ValidationError } from "./context";
|
|
7
|
+
import { AuthenticationError, AuthorizationError } from "./auth";
|
|
7
8
|
import { ErrorClassifier, formatErrorResponse, ErrorCode } from "../error";
|
|
8
9
|
import { createContract, type ContractDefinition, type ContractInstance } from "../contract";
|
|
9
10
|
|
|
@@ -284,6 +285,35 @@ export class ManduFilling<TLoaderData = unknown> {
|
|
|
284
285
|
// Execute handler
|
|
285
286
|
return await handler(ctx);
|
|
286
287
|
} catch (error) {
|
|
288
|
+
// Handle authentication errors
|
|
289
|
+
if (error instanceof AuthenticationError) {
|
|
290
|
+
return ctx.json(
|
|
291
|
+
{
|
|
292
|
+
errorType: "AUTH_ERROR",
|
|
293
|
+
code: "AUTHENTICATION_REQUIRED",
|
|
294
|
+
message: error.message,
|
|
295
|
+
summary: "인증 필요 - 로그인 후 다시 시도하세요",
|
|
296
|
+
timestamp: new Date().toISOString(),
|
|
297
|
+
},
|
|
298
|
+
401
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Handle authorization errors
|
|
303
|
+
if (error instanceof AuthorizationError) {
|
|
304
|
+
return ctx.json(
|
|
305
|
+
{
|
|
306
|
+
errorType: "AUTH_ERROR",
|
|
307
|
+
code: "ACCESS_DENIED",
|
|
308
|
+
message: error.message,
|
|
309
|
+
summary: "권한 없음 - 접근 권한이 부족합니다",
|
|
310
|
+
requiredRoles: error.requiredRoles,
|
|
311
|
+
timestamp: new Date().toISOString(),
|
|
312
|
+
},
|
|
313
|
+
403
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
287
317
|
// Handle validation errors with enhanced error format
|
|
288
318
|
if (error instanceof ValidationError) {
|
|
289
319
|
return ctx.json(
|
package/src/filling/index.ts
CHANGED
|
@@ -6,3 +6,16 @@ export { ManduContext, NEXT_SYMBOL, ValidationError, CookieManager } from "./con
|
|
|
6
6
|
export type { CookieOptions } from "./context";
|
|
7
7
|
export { ManduFilling, Mandu, LoaderTimeoutError } from "./filling";
|
|
8
8
|
export type { Handler, Guard, HttpMethod, Loader, LoaderOptions } from "./filling";
|
|
9
|
+
|
|
10
|
+
// Auth Guards
|
|
11
|
+
export {
|
|
12
|
+
AuthenticationError,
|
|
13
|
+
AuthorizationError,
|
|
14
|
+
requireUser,
|
|
15
|
+
requireRole,
|
|
16
|
+
requireAnyRole,
|
|
17
|
+
requireAllRoles,
|
|
18
|
+
createAuthGuard,
|
|
19
|
+
createRoleGuard,
|
|
20
|
+
} from "./auth";
|
|
21
|
+
export type { BaseUser, UserWithRole, UserWithRoles } from "./auth";
|
|
@@ -190,6 +190,13 @@ function computeSlotImportPath(slotModule: string, fromDir: string): string {
|
|
|
190
190
|
|
|
191
191
|
export function generatePageComponent(route: RouteSpec): string {
|
|
192
192
|
const pageName = toPascalCase(route.id);
|
|
193
|
+
|
|
194
|
+
// slotModule이 있으면 PageHandler 형식으로 생성 (filling 포함)
|
|
195
|
+
if (route.slotModule) {
|
|
196
|
+
return generatePageHandlerWithSlot(route);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// slotModule이 없으면 기존 방식
|
|
193
200
|
return `// Generated by Mandu - DO NOT EDIT DIRECTLY
|
|
194
201
|
// Route ID: ${route.id}
|
|
195
202
|
// Pattern: ${route.pattern}
|
|
@@ -198,6 +205,7 @@ import React from "react";
|
|
|
198
205
|
|
|
199
206
|
interface Props {
|
|
200
207
|
params: Record<string, string>;
|
|
208
|
+
loaderData?: unknown;
|
|
201
209
|
}
|
|
202
210
|
|
|
203
211
|
export default function ${pageName}Page({ params }: Props): React.ReactElement {
|
|
@@ -210,6 +218,45 @@ export default function ${pageName}Page({ params }: Props): React.ReactElement {
|
|
|
210
218
|
`;
|
|
211
219
|
}
|
|
212
220
|
|
|
221
|
+
/**
|
|
222
|
+
* slotModule이 있는 Page Route용 Handler 생성
|
|
223
|
+
* - component와 filling을 함께 export
|
|
224
|
+
* - server.ts에서 filling.executeLoader() 호출 가능
|
|
225
|
+
*/
|
|
226
|
+
export function generatePageHandlerWithSlot(route: RouteSpec): string {
|
|
227
|
+
const pageName = toPascalCase(route.id);
|
|
228
|
+
const slotImportPath = computeSlotImportPath(route.slotModule!, "apps/server/generated/routes");
|
|
229
|
+
|
|
230
|
+
return `// Generated by Mandu - DO NOT EDIT DIRECTLY
|
|
231
|
+
// Route ID: ${route.id}
|
|
232
|
+
// Pattern: ${route.pattern}
|
|
233
|
+
// Slot Module: ${route.slotModule}
|
|
234
|
+
|
|
235
|
+
import React from "react";
|
|
236
|
+
import filling from "${slotImportPath}";
|
|
237
|
+
|
|
238
|
+
interface Props {
|
|
239
|
+
params: Record<string, string>;
|
|
240
|
+
loaderData?: unknown;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function ${pageName}Page({ params, loaderData }: Props): React.ReactElement {
|
|
244
|
+
return React.createElement("div", null,
|
|
245
|
+
React.createElement("h1", null, "${pageName} Page"),
|
|
246
|
+
React.createElement("p", null, "Route ID: ${route.id}"),
|
|
247
|
+
React.createElement("p", null, "Pattern: ${route.pattern}"),
|
|
248
|
+
loaderData ? React.createElement("pre", null, JSON.stringify(loaderData, null, 2)) : null
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// PageRegistration 형식으로 export (server.ts의 registerPageHandler용)
|
|
253
|
+
export default {
|
|
254
|
+
component: ${pageName}Page,
|
|
255
|
+
filling: filling,
|
|
256
|
+
};
|
|
257
|
+
`;
|
|
258
|
+
}
|
|
259
|
+
|
|
213
260
|
/**
|
|
214
261
|
* Convert string to PascalCase (handles kebab-case, snake_case)
|
|
215
262
|
* "todo-page" → "TodoPage"
|
package/src/runtime/server.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { Server } from "bun";
|
|
2
2
|
import type { RoutesManifest } from "../spec/schema";
|
|
3
3
|
import type { BundleManifest } from "../bundler/types";
|
|
4
|
+
import type { ManduFilling } from "../filling/filling";
|
|
5
|
+
import { ManduContext } from "../filling/context";
|
|
4
6
|
import { Router } from "./router";
|
|
5
7
|
import { renderSSR } from "./ssr";
|
|
6
8
|
import React from "react";
|
|
@@ -103,18 +105,36 @@ export interface ManduServer {
|
|
|
103
105
|
export type ApiHandler = (req: Request, params: Record<string, string>) => Response | Promise<Response>;
|
|
104
106
|
export type PageLoader = () => Promise<{ default: React.ComponentType<{ params: Record<string, string> }> }>;
|
|
105
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Page 등록 정보
|
|
110
|
+
* - component: React 컴포넌트
|
|
111
|
+
* - filling: Slot의 ManduFilling 인스턴스 (loader 포함)
|
|
112
|
+
*/
|
|
113
|
+
export interface PageRegistration {
|
|
114
|
+
component: React.ComponentType<{ params: Record<string, string>; loaderData?: unknown }>;
|
|
115
|
+
filling?: ManduFilling<unknown>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Page Handler - 컴포넌트와 filling을 함께 반환
|
|
120
|
+
*/
|
|
121
|
+
export type PageHandler = () => Promise<PageRegistration>;
|
|
122
|
+
|
|
106
123
|
export interface AppContext {
|
|
107
124
|
routeId: string;
|
|
108
125
|
url: string;
|
|
109
126
|
params: Record<string, string>;
|
|
127
|
+
/** SSR loader에서 로드한 데이터 */
|
|
128
|
+
loaderData?: unknown;
|
|
110
129
|
}
|
|
111
130
|
|
|
112
|
-
type RouteComponent = (props: { params: Record<string, string
|
|
131
|
+
type RouteComponent = (props: { params: Record<string, string>; loaderData?: unknown }) => React.ReactElement;
|
|
113
132
|
type CreateAppFn = (context: AppContext) => React.ReactElement;
|
|
114
133
|
|
|
115
134
|
// Registry
|
|
116
135
|
const apiHandlers: Map<string, ApiHandler> = new Map();
|
|
117
136
|
const pageLoaders: Map<string, PageLoader> = new Map();
|
|
137
|
+
const pageHandlers: Map<string, PageHandler> = new Map();
|
|
118
138
|
const routeComponents: Map<string, RouteComponent> = new Map();
|
|
119
139
|
let createAppFn: CreateAppFn | null = null;
|
|
120
140
|
|
|
@@ -141,6 +161,14 @@ export function registerPageLoader(routeId: string, loader: PageLoader): void {
|
|
|
141
161
|
pageLoaders.set(routeId, loader);
|
|
142
162
|
}
|
|
143
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Page Handler 등록 (컴포넌트 + filling)
|
|
166
|
+
* filling이 있으면 loader를 실행하여 serverData 전달
|
|
167
|
+
*/
|
|
168
|
+
export function registerPageHandler(routeId: string, handler: PageHandler): void {
|
|
169
|
+
pageHandlers.set(routeId, handler);
|
|
170
|
+
}
|
|
171
|
+
|
|
144
172
|
export function registerRouteComponent(routeId: string, component: RouteComponent): void {
|
|
145
173
|
routeComponents.set(routeId, component);
|
|
146
174
|
}
|
|
@@ -160,7 +188,10 @@ function defaultCreateApp(context: AppContext): React.ReactElement {
|
|
|
160
188
|
);
|
|
161
189
|
}
|
|
162
190
|
|
|
163
|
-
return React.createElement(Component, {
|
|
191
|
+
return React.createElement(Component, {
|
|
192
|
+
params: context.params,
|
|
193
|
+
loaderData: context.loaderData,
|
|
194
|
+
});
|
|
164
195
|
}
|
|
165
196
|
|
|
166
197
|
// ========== Static File Serving ==========
|
|
@@ -281,11 +312,22 @@ async function handleRequest(req: Request, router: Router): Promise<Response> {
|
|
|
281
312
|
}
|
|
282
313
|
|
|
283
314
|
if (route.kind === "page") {
|
|
284
|
-
|
|
285
|
-
|
|
315
|
+
let loaderData: unknown;
|
|
316
|
+
let component: RouteComponent | undefined;
|
|
317
|
+
|
|
318
|
+
// 1. PageHandler 방식 (신규 - filling 포함)
|
|
319
|
+
const pageHandler = pageHandlers.get(route.id);
|
|
320
|
+
if (pageHandler) {
|
|
286
321
|
try {
|
|
287
|
-
const
|
|
288
|
-
|
|
322
|
+
const registration = await pageHandler();
|
|
323
|
+
component = registration.component as RouteComponent;
|
|
324
|
+
registerRouteComponent(route.id, component);
|
|
325
|
+
|
|
326
|
+
// Filling의 loader 실행
|
|
327
|
+
if (registration.filling?.hasLoader()) {
|
|
328
|
+
const ctx = new ManduContext(req, params);
|
|
329
|
+
loaderData = await registration.filling.executeLoader(ctx);
|
|
330
|
+
}
|
|
289
331
|
} catch (err) {
|
|
290
332
|
const pageError = createPageLoadErrorResponse(
|
|
291
333
|
route.id,
|
|
@@ -299,6 +341,27 @@ async function handleRequest(req: Request, router: Router): Promise<Response> {
|
|
|
299
341
|
return Response.json(response, { status: 500 });
|
|
300
342
|
}
|
|
301
343
|
}
|
|
344
|
+
// 2. PageLoader 방식 (레거시 호환)
|
|
345
|
+
else {
|
|
346
|
+
const loader = pageLoaders.get(route.id);
|
|
347
|
+
if (loader) {
|
|
348
|
+
try {
|
|
349
|
+
const module = await loader();
|
|
350
|
+
registerRouteComponent(route.id, module.default);
|
|
351
|
+
} catch (err) {
|
|
352
|
+
const pageError = createPageLoadErrorResponse(
|
|
353
|
+
route.id,
|
|
354
|
+
route.pattern,
|
|
355
|
+
err instanceof Error ? err : new Error(String(err))
|
|
356
|
+
);
|
|
357
|
+
console.error(`[Mandu] ${pageError.errorType}:`, pageError.message);
|
|
358
|
+
const response = formatErrorResponse(pageError, {
|
|
359
|
+
isDev: process.env.NODE_ENV !== "production",
|
|
360
|
+
});
|
|
361
|
+
return Response.json(response, { status: 500 });
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
302
365
|
|
|
303
366
|
const appCreator = createAppFn || defaultCreateApp;
|
|
304
367
|
try {
|
|
@@ -306,8 +369,14 @@ async function handleRequest(req: Request, router: Router): Promise<Response> {
|
|
|
306
369
|
routeId: route.id,
|
|
307
370
|
url: req.url,
|
|
308
371
|
params,
|
|
372
|
+
loaderData,
|
|
309
373
|
});
|
|
310
374
|
|
|
375
|
+
// serverData 구조: { [routeId]: { serverData: loaderData } }
|
|
376
|
+
const serverData = loaderData
|
|
377
|
+
? { [route.id]: { serverData: loaderData } }
|
|
378
|
+
: undefined;
|
|
379
|
+
|
|
311
380
|
return renderSSR(app, {
|
|
312
381
|
title: `${route.id} - Mandu`,
|
|
313
382
|
isDev: serverSettings.isDev,
|
|
@@ -315,6 +384,7 @@ async function handleRequest(req: Request, router: Router): Promise<Response> {
|
|
|
315
384
|
routeId: route.id,
|
|
316
385
|
hydration: route.hydration,
|
|
317
386
|
bundleManifest: serverSettings.bundleManifest,
|
|
387
|
+
serverData,
|
|
318
388
|
});
|
|
319
389
|
} catch (err) {
|
|
320
390
|
const ssrError = createSSRErrorResponse(
|
|
@@ -418,8 +488,9 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
418
488
|
export function clearRegistry(): void {
|
|
419
489
|
apiHandlers.clear();
|
|
420
490
|
pageLoaders.clear();
|
|
491
|
+
pageHandlers.clear();
|
|
421
492
|
routeComponents.clear();
|
|
422
493
|
createAppFn = null;
|
|
423
494
|
}
|
|
424
495
|
|
|
425
|
-
export { apiHandlers, pageLoaders, routeComponents };
|
|
496
|
+
export { apiHandlers, pageLoaders, pageHandlers, routeComponents };
|