@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.5.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",
@@ -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
+ }
@@ -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(
@@ -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"
@@ -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> }) => React.ReactElement;
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, { params: context.params });
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
- const loader = pageLoaders.get(route.id);
285
- if (loader) {
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 module = await loader();
288
- registerRouteComponent(route.id, module.default);
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 };