@mandujs/core 0.9.27 → 0.9.29

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.9.27",
3
+ "version": "0.9.29",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -1118,12 +1118,23 @@ export async function buildClientBundles(
1118
1118
  // 1. Hydration이 필요한 라우트 필터링
1119
1119
  const hydratedRoutes = getHydratedRoutes(manifest);
1120
1120
 
1121
+ // 2. 출력 디렉토리 생성 (항상 필요 - 매니페스트 저장용)
1122
+ const outDir = options.outDir || path.join(rootDir, ".mandu/client");
1123
+ await fs.mkdir(outDir, { recursive: true });
1124
+
1125
+ // Hydration 라우트가 없어도 빈 매니페스트를 저장해야 함
1126
+ // (이전 빌드의 stale 매니페스트 참조 방지)
1121
1127
  if (hydratedRoutes.length === 0) {
1128
+ const emptyManifest = createEmptyManifest(env);
1129
+ await fs.writeFile(
1130
+ path.join(rootDir, ".mandu/manifest.json"),
1131
+ JSON.stringify(emptyManifest, null, 2)
1132
+ );
1122
1133
  return {
1123
1134
  success: true,
1124
1135
  outputs: [],
1125
1136
  errors: [],
1126
- manifest: createEmptyManifest(env),
1137
+ manifest: emptyManifest,
1127
1138
  stats: {
1128
1139
  totalSize: 0,
1129
1140
  totalGzipSize: 0,
@@ -1134,10 +1145,6 @@ export async function buildClientBundles(
1134
1145
  };
1135
1146
  }
1136
1147
 
1137
- // 2. 출력 디렉토리 생성
1138
- const outDir = options.outDir || path.join(rootDir, ".mandu/client");
1139
- await fs.mkdir(outDir, { recursive: true });
1140
-
1141
1148
  // 3. Runtime 번들 빌드
1142
1149
  const runtimeResult = await buildRuntime(outDir, options);
1143
1150
  if (!runtimeResult.success) {
@@ -100,11 +100,19 @@ export interface ServerOptions {
100
100
  * - false: 기존 renderToString 사용 (기본값)
101
101
  */
102
102
  streaming?: boolean;
103
+ /**
104
+ * 커스텀 레지스트리 (핸들러/설정 분리)
105
+ * - 제공하지 않으면 기본 전역 레지스트리 사용
106
+ * - 테스트나 멀티앱 시나리오에서 createServerRegistry()로 생성한 인스턴스 전달
107
+ */
108
+ registry?: ServerRegistry;
103
109
  }
104
110
 
105
111
  export interface ManduServer {
106
112
  server: Server;
107
113
  router: Router;
114
+ /** 이 서버 인스턴스의 레지스트리 */
115
+ registry: ServerRegistry;
108
116
  stop: () => void;
109
117
  }
110
118
 
@@ -137,15 +145,13 @@ export interface AppContext {
137
145
  type RouteComponent = (props: { params: Record<string, string>; loaderData?: unknown }) => React.ReactElement;
138
146
  type CreateAppFn = (context: AppContext) => React.ReactElement;
139
147
 
140
- // Registry
141
- const apiHandlers: Map<string, ApiHandler> = new Map();
142
- const pageLoaders: Map<string, PageLoader> = new Map();
143
- const pageHandlers: Map<string, PageHandler> = new Map();
144
- const routeComponents: Map<string, RouteComponent> = new Map();
145
- let createAppFn: CreateAppFn | null = null;
148
+ // ========== Server Registry (인스턴스별 분리) ==========
146
149
 
147
- // Server settings (module-level for handleRequest access)
148
- let serverSettings: {
150
+ /**
151
+ * 서버 인스턴스별 핸들러/설정 레지스트리
152
+ * 같은 프로세스에서 여러 서버를 띄울 때 핸들러가 섞이는 문제 방지
153
+ */
154
+ export interface ServerRegistrySettings {
149
155
  isDev: boolean;
150
156
  hmrPort?: number;
151
157
  bundleManifest?: BundleManifest;
@@ -153,20 +159,82 @@ let serverSettings: {
153
159
  publicDir: string;
154
160
  cors?: CorsOptions | false;
155
161
  streaming: boolean;
156
- } = {
157
- isDev: false,
158
- rootDir: process.cwd(),
159
- publicDir: "public",
160
- cors: false,
161
- streaming: false,
162
- };
162
+ }
163
+
164
+ export class ServerRegistry {
165
+ readonly apiHandlers: Map<string, ApiHandler> = new Map();
166
+ readonly pageLoaders: Map<string, PageLoader> = new Map();
167
+ readonly pageHandlers: Map<string, PageHandler> = new Map();
168
+ readonly routeComponents: Map<string, RouteComponent> = new Map();
169
+ createAppFn: CreateAppFn | null = null;
170
+ settings: ServerRegistrySettings = {
171
+ isDev: false,
172
+ rootDir: process.cwd(),
173
+ publicDir: "public",
174
+ cors: false,
175
+ streaming: false,
176
+ };
177
+
178
+ registerApiHandler(routeId: string, handler: ApiHandler): void {
179
+ this.apiHandlers.set(routeId, handler);
180
+ }
181
+
182
+ registerPageLoader(routeId: string, loader: PageLoader): void {
183
+ this.pageLoaders.set(routeId, loader);
184
+ }
185
+
186
+ registerPageHandler(routeId: string, handler: PageHandler): void {
187
+ this.pageHandlers.set(routeId, handler);
188
+ }
189
+
190
+ registerRouteComponent(routeId: string, component: RouteComponent): void {
191
+ this.routeComponents.set(routeId, component);
192
+ }
193
+
194
+ setCreateApp(fn: CreateAppFn): void {
195
+ this.createAppFn = fn;
196
+ }
197
+
198
+ /**
199
+ * 모든 핸들러/컴포넌트 초기화 (테스트용)
200
+ */
201
+ clear(): void {
202
+ this.apiHandlers.clear();
203
+ this.pageLoaders.clear();
204
+ this.pageHandlers.clear();
205
+ this.routeComponents.clear();
206
+ this.createAppFn = null;
207
+ }
208
+ }
209
+
210
+ /**
211
+ * 기본 전역 레지스트리 (하위 호환성)
212
+ */
213
+ const defaultRegistry = new ServerRegistry();
214
+
215
+ /**
216
+ * 새 레지스트리 인스턴스 생성
217
+ * 테스트나 멀티앱 시나리오에서 사용
218
+ */
219
+ export function createServerRegistry(): ServerRegistry {
220
+ return new ServerRegistry();
221
+ }
222
+
223
+ /**
224
+ * 기본 레지스트리 초기화 (테스트용)
225
+ */
226
+ export function clearDefaultRegistry(): void {
227
+ defaultRegistry.clear();
228
+ }
229
+
230
+ // ========== 하위 호환성을 위한 전역 함수들 (defaultRegistry 사용) ==========
163
231
 
164
232
  export function registerApiHandler(routeId: string, handler: ApiHandler): void {
165
- apiHandlers.set(routeId, handler);
233
+ defaultRegistry.registerApiHandler(routeId, handler);
166
234
  }
167
235
 
168
236
  export function registerPageLoader(routeId: string, loader: PageLoader): void {
169
- pageLoaders.set(routeId, loader);
237
+ defaultRegistry.registerPageLoader(routeId, loader);
170
238
  }
171
239
 
172
240
  /**
@@ -174,32 +242,34 @@ export function registerPageLoader(routeId: string, loader: PageLoader): void {
174
242
  * filling이 있으면 loader를 실행하여 serverData 전달
175
243
  */
176
244
  export function registerPageHandler(routeId: string, handler: PageHandler): void {
177
- pageHandlers.set(routeId, handler);
245
+ defaultRegistry.registerPageHandler(routeId, handler);
178
246
  }
179
247
 
180
248
  export function registerRouteComponent(routeId: string, component: RouteComponent): void {
181
- routeComponents.set(routeId, component);
249
+ defaultRegistry.registerRouteComponent(routeId, component);
182
250
  }
183
251
 
184
252
  export function setCreateApp(fn: CreateAppFn): void {
185
- createAppFn = fn;
253
+ defaultRegistry.setCreateApp(fn);
186
254
  }
187
255
 
188
- // Default createApp implementation
189
- function defaultCreateApp(context: AppContext): React.ReactElement {
190
- const Component = routeComponents.get(context.routeId);
256
+ // Default createApp implementation (registry 기반)
257
+ function createDefaultAppFactory(registry: ServerRegistry) {
258
+ return function defaultCreateApp(context: AppContext): React.ReactElement {
259
+ const Component = registry.routeComponents.get(context.routeId);
191
260
 
192
- if (!Component) {
193
- return React.createElement("div", null,
194
- React.createElement("h1", null, "404 - Route Not Found"),
195
- React.createElement("p", null, `Route ID: ${context.routeId}`)
196
- );
197
- }
261
+ if (!Component) {
262
+ return React.createElement("div", null,
263
+ React.createElement("h1", null, "404 - Route Not Found"),
264
+ React.createElement("p", null, `Route ID: ${context.routeId}`)
265
+ );
266
+ }
198
267
 
199
- return React.createElement(Component, {
200
- params: context.params,
201
- loaderData: context.loaderData,
202
- });
268
+ return React.createElement(Component, {
269
+ params: context.params,
270
+ loaderData: context.loaderData,
271
+ });
272
+ };
203
273
  }
204
274
 
205
275
  // ========== Static File Serving ==========
@@ -224,7 +294,7 @@ function isPathSafe(filePath: string, allowedDir: string): boolean {
224
294
  *
225
295
  * 보안: Path traversal 공격 방지를 위해 모든 경로를 검증합니다.
226
296
  */
227
- async function serveStaticFile(pathname: string): Promise<Response | null> {
297
+ async function serveStaticFile(pathname: string, settings: ServerRegistrySettings): Promise<Response | null> {
228
298
  let filePath: string | null = null;
229
299
  let isBundleFile = false;
230
300
  let allowedBaseDir: string;
@@ -238,14 +308,14 @@ async function serveStaticFile(pathname: string): Promise<Response | null> {
238
308
  if (pathname.startsWith("/.mandu/client/")) {
239
309
  // pathname에서 prefix 제거 후 안전하게 조합
240
310
  const relativePath = pathname.slice("/.mandu/client/".length);
241
- allowedBaseDir = path.join(serverSettings.rootDir, ".mandu", "client");
311
+ allowedBaseDir = path.join(settings.rootDir, ".mandu", "client");
242
312
  filePath = path.join(allowedBaseDir, relativePath);
243
313
  isBundleFile = true;
244
314
  }
245
315
  // 2. Public 폴더 파일 (/public/*)
246
316
  else if (pathname.startsWith("/public/")) {
247
317
  const relativePath = pathname.slice("/public/".length);
248
- allowedBaseDir = path.join(serverSettings.rootDir, "public");
318
+ allowedBaseDir = path.join(settings.rootDir, "public");
249
319
  filePath = path.join(allowedBaseDir, relativePath);
250
320
  }
251
321
  // 3. Public 폴더의 루트 파일 (favicon.ico, robots.txt 등)
@@ -257,7 +327,7 @@ async function serveStaticFile(pathname: string): Promise<Response | null> {
257
327
  ) {
258
328
  // 고정된 파일명만 허용 (이미 위에서 정확히 매칭됨)
259
329
  const filename = path.basename(pathname);
260
- allowedBaseDir = path.join(serverSettings.rootDir, serverSettings.publicDir);
330
+ allowedBaseDir = path.join(settings.rootDir, settings.publicDir);
261
331
  filePath = path.join(allowedBaseDir, filename);
262
332
  } else {
263
333
  return null; // 정적 파일이 아님
@@ -281,7 +351,7 @@ async function serveStaticFile(pathname: string): Promise<Response | null> {
281
351
 
282
352
  // Cache-Control 헤더 설정
283
353
  let cacheControl: string;
284
- if (serverSettings.isDev) {
354
+ if (settings.isDev) {
285
355
  // 개발 모드: 캐시 없음
286
356
  cacheControl = "no-cache, no-store, must-revalidate";
287
357
  } else if (isBundleFile) {
@@ -305,22 +375,23 @@ async function serveStaticFile(pathname: string): Promise<Response | null> {
305
375
 
306
376
  // ========== Request Handler ==========
307
377
 
308
- async function handleRequest(req: Request, router: Router): Promise<Response> {
378
+ async function handleRequest(req: Request, router: Router, registry: ServerRegistry): Promise<Response> {
309
379
  const url = new URL(req.url);
310
380
  const pathname = url.pathname;
381
+ const settings = registry.settings;
311
382
 
312
383
  // 0. CORS Preflight 요청 처리
313
- if (serverSettings.cors && isPreflightRequest(req)) {
314
- const corsOptions = serverSettings.cors === true ? {} : serverSettings.cors;
384
+ if (settings.cors && isPreflightRequest(req)) {
385
+ const corsOptions = settings.cors === true ? {} : settings.cors;
315
386
  return handlePreflightRequest(req, corsOptions);
316
387
  }
317
388
 
318
389
  // 1. 정적 파일 서빙 시도 (최우선)
319
- const staticResponse = await serveStaticFile(pathname);
390
+ const staticResponse = await serveStaticFile(pathname, settings);
320
391
  if (staticResponse) {
321
392
  // 정적 파일에도 CORS 헤더 적용
322
- if (serverSettings.cors && isCorsRequest(req)) {
323
- const corsOptions = serverSettings.cors === true ? {} : serverSettings.cors;
393
+ if (settings.cors && isCorsRequest(req)) {
394
+ const corsOptions = settings.cors === true ? {} : settings.cors;
324
395
  return applyCorsToResponse(staticResponse, req, corsOptions);
325
396
  }
326
397
  return staticResponse;
@@ -340,7 +411,7 @@ async function handleRequest(req: Request, router: Router): Promise<Response> {
340
411
  const { route, params } = match;
341
412
 
342
413
  if (route.kind === "api") {
343
- const handler = apiHandlers.get(route.id);
414
+ const handler = registry.apiHandlers.get(route.id);
344
415
  if (!handler) {
345
416
  const error = createHandlerNotFoundResponse(route.id, route.pattern);
346
417
  const response = formatErrorResponse(error, {
@@ -359,12 +430,12 @@ async function handleRequest(req: Request, router: Router): Promise<Response> {
359
430
  const isDataRequest = url.searchParams.has("_data");
360
431
 
361
432
  // 1. PageHandler 방식 (신규 - filling 포함)
362
- const pageHandler = pageHandlers.get(route.id);
433
+ const pageHandler = registry.pageHandlers.get(route.id);
363
434
  if (pageHandler) {
364
435
  try {
365
436
  const registration = await pageHandler();
366
437
  component = registration.component as RouteComponent;
367
- registerRouteComponent(route.id, component);
438
+ registry.registerRouteComponent(route.id, component);
368
439
 
369
440
  // Filling의 loader 실행
370
441
  if (registration.filling?.hasLoader()) {
@@ -386,7 +457,7 @@ async function handleRequest(req: Request, router: Router): Promise<Response> {
386
457
  }
387
458
  // 2. PageLoader 방식 (레거시 호환)
388
459
  else {
389
- const loader = pageLoaders.get(route.id);
460
+ const loader = registry.pageLoaders.get(route.id);
390
461
  if (loader) {
391
462
  try {
392
463
  const module = await loader();
@@ -395,7 +466,7 @@ async function handleRequest(req: Request, router: Router): Promise<Response> {
395
466
  const component = typeof exported === 'function'
396
467
  ? exported
397
468
  : exported?.component ?? exported;
398
- registerRouteComponent(route.id, component);
469
+ registry.registerRouteComponent(route.id, component);
399
470
 
400
471
  // filling이 있으면 loader 실행
401
472
  const filling = typeof exported === 'object' ? exported?.filling : null;
@@ -430,7 +501,8 @@ async function handleRequest(req: Request, router: Router): Promise<Response> {
430
501
  }
431
502
 
432
503
  // SSR 렌더링
433
- const appCreator = createAppFn || defaultCreateApp;
504
+ const defaultAppCreator = createDefaultAppFactory(registry);
505
+ const appCreator = registry.createAppFn || defaultAppCreator;
434
506
  try {
435
507
  const app = appCreator({
436
508
  routeId: route.id,
@@ -445,29 +517,29 @@ async function handleRequest(req: Request, router: Router): Promise<Response> {
445
517
  : undefined;
446
518
 
447
519
  // Streaming SSR 모드 결정
448
- // 우선순위: route.streaming > serverSettings.streaming
520
+ // 우선순위: route.streaming > settings.streaming
449
521
  const useStreaming = route.streaming !== undefined
450
522
  ? route.streaming
451
- : serverSettings.streaming;
523
+ : settings.streaming;
452
524
 
453
525
  if (useStreaming) {
454
526
  return await renderStreamingResponse(app, {
455
527
  title: `${route.id} - Mandu`,
456
- isDev: serverSettings.isDev,
457
- hmrPort: serverSettings.hmrPort,
528
+ isDev: settings.isDev,
529
+ hmrPort: settings.hmrPort,
458
530
  routeId: route.id,
459
531
  routePattern: route.pattern,
460
532
  hydration: route.hydration,
461
- bundleManifest: serverSettings.bundleManifest,
533
+ bundleManifest: settings.bundleManifest,
462
534
  criticalData: loaderData as Record<string, unknown> | undefined,
463
535
  enableClientRouter: true,
464
536
  onShellReady: () => {
465
- if (serverSettings.isDev) {
537
+ if (settings.isDev) {
466
538
  console.log(`[Mandu Streaming] Shell ready: ${route.id}`);
467
539
  }
468
540
  },
469
541
  onMetrics: (metrics) => {
470
- if (serverSettings.isDev) {
542
+ if (settings.isDev) {
471
543
  console.log(`[Mandu Streaming] Metrics for ${route.id}:`, {
472
544
  shellReadyTime: `${metrics.shellReadyTime}ms`,
473
545
  allReadyTime: `${metrics.allReadyTime}ms`,
@@ -481,11 +553,11 @@ async function handleRequest(req: Request, router: Router): Promise<Response> {
481
553
  // 기존 renderToString 방식
482
554
  return renderSSR(app, {
483
555
  title: `${route.id} - Mandu`,
484
- isDev: serverSettings.isDev,
485
- hmrPort: serverSettings.hmrPort,
556
+ isDev: settings.isDev,
557
+ hmrPort: settings.hmrPort,
486
558
  routeId: route.id,
487
559
  hydration: route.hydration,
488
- bundleManifest: serverSettings.bundleManifest,
560
+ bundleManifest: settings.bundleManifest,
489
561
  serverData,
490
562
  // Client-side Routing 활성화 정보 전달
491
563
  enableClientRouter: true,
@@ -535,13 +607,14 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
535
607
  publicDir = "public",
536
608
  cors = false,
537
609
  streaming = false,
610
+ registry = defaultRegistry,
538
611
  } = options;
539
612
 
540
613
  // CORS 옵션 파싱
541
614
  const corsOptions: CorsOptions | false = cors === true ? {} : cors;
542
615
 
543
- // Server settings 저장
544
- serverSettings = {
616
+ // Registry settings 저장
617
+ registry.settings = {
545
618
  isDev,
546
619
  hmrPort,
547
620
  bundleManifest,
@@ -553,9 +626,9 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
553
626
 
554
627
  const router = new Router(manifest.routes);
555
628
 
556
- // Fetch handler with CORS support
629
+ // Fetch handler with CORS support (registry를 클로저로 캡처)
557
630
  const fetchHandler = async (req: Request): Promise<Response> => {
558
- const response = await handleRequest(req, router);
631
+ const response = await handleRequest(req, router, registry);
559
632
 
560
633
  // API 라우트 응답에 CORS 헤더 적용
561
634
  if (corsOptions && isCorsRequest(req)) {
@@ -593,17 +666,18 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
593
666
  return {
594
667
  server,
595
668
  router,
669
+ registry,
596
670
  stop: () => server.stop(),
597
671
  };
598
672
  }
599
673
 
600
- // Clear registries (useful for testing)
674
+ // Clear registries (useful for testing) - deprecated, use clearDefaultRegistry()
601
675
  export function clearRegistry(): void {
602
- apiHandlers.clear();
603
- pageLoaders.clear();
604
- pageHandlers.clear();
605
- routeComponents.clear();
606
- createAppFn = null;
676
+ clearDefaultRegistry();
607
677
  }
608
678
 
609
- export { apiHandlers, pageLoaders, pageHandlers, routeComponents };
679
+ // Export registry maps for backward compatibility (defaultRegistry 사용)
680
+ export const apiHandlers = defaultRegistry.apiHandlers;
681
+ export const pageLoaders = defaultRegistry.pageLoaders;
682
+ export const pageHandlers = defaultRegistry.pageHandlers;
683
+ export const routeComponents = defaultRegistry.routeComponents;