@mandujs/core 0.9.30 → 0.9.37

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.
@@ -78,13 +78,25 @@ export class RouterError extends Error {
78
78
  // TrieNode Class
79
79
  // ═══════════════════════════════════════════════════════════════════════════
80
80
 
81
+ /**
82
+ * Wildcard 설정
83
+ */
84
+ interface WildcardConfig {
85
+ /** 파라미터 이름 (예: "path" for :path*) */
86
+ name: string;
87
+ /** optional 여부 (예: :path*? 는 optional) */
88
+ optional: boolean;
89
+ /** 라우트 정보 */
90
+ route: RouteSpec;
91
+ }
92
+
81
93
  /**
82
94
  * Trie node for dynamic route matching
83
95
  *
84
96
  * Structure:
85
97
  * - children: Map for static segments
86
98
  * - paramChild: Single param child with name tracking (P0-4)
87
- * - wildcardRoute: Route for wildcard (*) matching
99
+ * - wildcardConfig: Wildcard with param name and optional flag
88
100
  * - route: Route that terminates at this node
89
101
  */
90
102
  class TrieNode {
@@ -94,11 +106,16 @@ class TrieNode {
94
106
  /** Parameter child with name for conflict detection */
95
107
  paramChild: { name: string; node: TrieNode } | null = null;
96
108
 
97
- /** Wildcard route (only valid at leaf) */
98
- wildcardRoute: RouteSpec | null = null;
109
+ /** Wildcard config with param name (only valid at leaf) */
110
+ wildcardConfig: WildcardConfig | null = null;
99
111
 
100
112
  /** Route terminating at this node */
101
113
  route: RouteSpec | null = null;
114
+
115
+ /** @deprecated Use wildcardConfig instead */
116
+ get wildcardRoute(): RouteSpec | null {
117
+ return this.wildcardConfig?.route ?? null;
118
+ }
102
119
  }
103
120
 
104
121
  // ═══════════════════════════════════════════════════════════════════════════
@@ -116,7 +133,7 @@ class TrieNode {
116
133
  *
117
134
  * @returns Decoded string or null if security violation
118
135
  */
119
- function safeDecodeURIComponent(str: string): string | null {
136
+ function safeDecodeURIComponent(str: string): string | null {
120
137
  // 1. Pre-decode %2F check
121
138
  if (ENCODED_SLASH_PATTERN.test(str)) {
122
139
  return null;
@@ -141,8 +158,26 @@ function safeDecodeURIComponent(str: string): string | null {
141
158
  return null;
142
159
  }
143
160
 
144
- return decoded;
145
- }
161
+ return decoded;
162
+ }
163
+
164
+ /**
165
+ * Decode wildcard segments safely (per-segment)
166
+ */
167
+ function decodeWildcardSegments(segments: string[]): string | null {
168
+ if (segments.length === 0) return "";
169
+
170
+ const decodedSegments: string[] = [];
171
+ for (const segment of segments) {
172
+ const decoded = safeDecodeURIComponent(segment);
173
+ if (decoded === null) {
174
+ return null;
175
+ }
176
+ decodedSegments.push(decoded);
177
+ }
178
+
179
+ return decodedSegments.join("/");
180
+ }
146
181
 
147
182
  // ═══════════════════════════════════════════════════════════════════════════
148
183
  // Router Class
@@ -305,7 +340,8 @@ export class Router {
305
340
  }
306
341
 
307
342
  // P0-2: Segment-based wildcard validation
308
- const wildcardIdx = segments.findIndex((s) => s === "*");
343
+ // Wildcard formats: * (legacy), :param*, :param*? (optional)
344
+ const wildcardIdx = segments.findIndex((s) => s === "*" || s.includes("*"));
309
345
  if (wildcardIdx !== -1 && wildcardIdx !== segments.length - 1) {
310
346
  throw new RouterError(
311
347
  `Wildcard must be the last segment in pattern "${pattern}"`,
@@ -335,17 +371,58 @@ export class Router {
335
371
  private insertTrie(pattern: string, segments: string[], route: RouteSpec): void {
336
372
  let node = this.trie;
337
373
 
338
- for (let i = 0; i < segments.length; i++) {
339
- const seg = segments[i];
340
-
341
- // Wildcard handling
342
- if (seg === "*") {
343
- node.wildcardRoute = route;
344
- return;
345
- }
346
-
347
- // Parameter handling
374
+ for (let i = 0; i < segments.length; i++) {
375
+ const seg = segments[i];
376
+
377
+ // Legacy wildcard: *
378
+ if (seg === "*") {
379
+ if (node.wildcardConfig) {
380
+ throw new RouterError(
381
+ `Wildcard conflict in pattern "${pattern}"`,
382
+ "ROUTE_CONFLICT",
383
+ route.id,
384
+ node.wildcardConfig.route.id
385
+ );
386
+ }
387
+ node.wildcardConfig = {
388
+ name: WILDCARD_PARAM_KEY,
389
+ optional: false,
390
+ route,
391
+ };
392
+ return;
393
+ }
394
+
395
+ // Parameter handling (including wildcards)
348
396
  if (seg.startsWith(":")) {
397
+ // Check for wildcard pattern: :param* or :param*?
398
+ const wildcardMatch = seg.match(/^:([^*?]+)\*(\?)?$/);
399
+ if (wildcardMatch) {
400
+ const paramName = wildcardMatch[1];
401
+ const isOptional = wildcardMatch[2] === "?";
402
+
403
+ if (node.wildcardConfig) {
404
+ throw new RouterError(
405
+ `Wildcard conflict in pattern "${pattern}"`,
406
+ "ROUTE_CONFLICT",
407
+ route.id,
408
+ node.wildcardConfig.route.id
409
+ );
410
+ }
411
+
412
+ node.wildcardConfig = {
413
+ name: paramName,
414
+ optional: isOptional,
415
+ route,
416
+ };
417
+
418
+ // Optional wildcard: 현재 노드도 매칭 가능하게 route 설정
419
+ if (isOptional && !node.route) {
420
+ node.route = route;
421
+ }
422
+ return;
423
+ }
424
+
425
+ // Regular parameter: :param
349
426
  const paramName = seg.slice(1);
350
427
 
351
428
  // P0-3: Param name conflict detection
@@ -385,14 +462,14 @@ export class Router {
385
462
  let node = this.trie;
386
463
 
387
464
  // Track wildcard candidate for backtracking
388
- let wildcardMatch: { route: RouteSpec; consumed: number } | null = null;
465
+ let wildcardMatch: { config: WildcardConfig; consumed: number } | null = null;
389
466
 
390
467
  for (let i = 0; i < segments.length; i++) {
391
468
  const seg = segments[i];
392
469
 
393
470
  // Save wildcard candidate before advancing
394
- if (node.wildcardRoute) {
395
- wildcardMatch = { route: node.wildcardRoute, consumed: i };
471
+ if (node.wildcardConfig) {
472
+ wildcardMatch = { config: node.wildcardConfig, consumed: i };
396
473
  }
397
474
 
398
475
  // 1. Try static child first (higher priority)
@@ -417,15 +494,19 @@ export class Router {
417
494
  continue;
418
495
  }
419
496
 
420
- // 3. No match - try wildcard fallback
421
- if (wildcardMatch) {
422
- const remaining = segments.slice(wildcardMatch.consumed).join("/");
423
- if (this.debug) {
424
- console.log(`[Router] Wildcard match: ${wildcardMatch.route.id} with ${remaining}`);
425
- }
426
- return {
427
- route: wildcardMatch.route,
428
- params: { [WILDCARD_PARAM_KEY]: remaining },
497
+ // 3. No match - try wildcard fallback
498
+ if (wildcardMatch) {
499
+ const remainingSegments = segments.slice(wildcardMatch.consumed);
500
+ const remaining = decodeWildcardSegments(remainingSegments);
501
+ if (remaining === null) {
502
+ return null;
503
+ }
504
+ if (this.debug) {
505
+ console.log(`[Router] Wildcard match: ${wildcardMatch.config.route.id} with ${remaining}`);
506
+ }
507
+ return {
508
+ route: wildcardMatch.config.route,
509
+ params: { ...params, [wildcardMatch.config.name]: remaining },
429
510
  };
430
511
  }
431
512
 
@@ -441,23 +522,36 @@ export class Router {
441
522
  return { route: node.route, params };
442
523
  }
443
524
 
444
- // Check for wildcard at current node (but with no remaining segments)
445
- // Policy A: /files/* does NOT match /files
446
- if (node.wildcardRoute) {
447
- // Don't match - wildcard requires at least one segment
448
- if (this.debug) {
449
- console.log(`[Router] Wildcard policy A: ${pathname} does not match wildcard`);
450
- }
525
+ // Check for wildcard at current node (but with no remaining segments)
526
+ if (node.wildcardConfig) {
527
+ // Optional wildcard: /files/:path*? matches /files (with empty path param)
528
+ if (node.wildcardConfig.optional) {
529
+ if (this.debug) {
530
+ console.log(`[Router] Optional wildcard match: ${node.wildcardConfig.route.id} with empty path`);
531
+ }
532
+ return {
533
+ route: node.wildcardConfig.route,
534
+ params,
535
+ };
536
+ }
537
+ // Non-optional wildcard: /files/:path* does NOT match /files
538
+ if (this.debug) {
539
+ console.log(`[Router] Wildcard policy: ${pathname} does not match non-optional wildcard`);
540
+ }
451
541
  }
452
542
 
453
- // Try wildcard fallback from earlier in the path
454
- if (wildcardMatch) {
455
- const remaining = segments.slice(wildcardMatch.consumed).join("/");
456
- return {
457
- route: wildcardMatch.route,
458
- params: { [WILDCARD_PARAM_KEY]: remaining },
459
- };
460
- }
543
+ // Try wildcard fallback from earlier in the path
544
+ if (wildcardMatch) {
545
+ const remainingSegments = segments.slice(wildcardMatch.consumed);
546
+ const remaining = decodeWildcardSegments(remainingSegments);
547
+ if (remaining === null) {
548
+ return null;
549
+ }
550
+ return {
551
+ route: wildcardMatch.config.route,
552
+ params: { ...params, [wildcardMatch.config.name]: remaining },
553
+ };
554
+ }
461
555
 
462
556
  return null;
463
557
  }
@@ -470,8 +564,11 @@ export class Router {
470
564
  routes.push(node.route);
471
565
  }
472
566
 
473
- if (node.wildcardRoute) {
474
- routes.push(node.wildcardRoute);
567
+ if (node.wildcardConfig) {
568
+ // Optional wildcard의 경우 route가 이미 추가되었을 수 있음
569
+ if (!node.route || node.wildcardConfig.route !== node.route) {
570
+ routes.push(node.wildcardConfig.route);
571
+ }
475
572
  }
476
573
 
477
574
  for (const child of node.children.values()) {
@@ -5,7 +5,8 @@ import type { ManduFilling } from "../filling/filling";
5
5
  import { ManduContext } from "../filling/context";
6
6
  import { Router } from "./router";
7
7
  import { renderSSR, renderStreamingResponse } from "./ssr";
8
- import React from "react";
8
+ import { PageBoundary, DefaultLoading, DefaultError, type ErrorFallbackProps } from "./boundary";
9
+ import React, { type ReactNode } from "react";
9
10
  import path from "path";
10
11
  import {
11
12
  formatErrorResponse,
@@ -119,6 +120,36 @@ export interface ManduServer {
119
120
  export type ApiHandler = (req: Request, params: Record<string, string>) => Response | Promise<Response>;
120
121
  export type PageLoader = () => Promise<{ default: React.ComponentType<{ params: Record<string, string> }> }>;
121
122
 
123
+ /**
124
+ * Layout 컴포넌트 타입
125
+ * children을 받아서 감싸는 구조
126
+ */
127
+ export type LayoutComponent = React.ComponentType<{
128
+ children: React.ReactNode;
129
+ params?: Record<string, string>;
130
+ }>;
131
+
132
+ /**
133
+ * Layout 로더 타입
134
+ */
135
+ export type LayoutLoader = () => Promise<{ default: LayoutComponent }>;
136
+
137
+ /**
138
+ * Loading 컴포넌트 타입
139
+ */
140
+ export type LoadingComponent = React.ComponentType<Record<string, never>>;
141
+
142
+ /**
143
+ * Error 컴포넌트 타입
144
+ */
145
+ export type ErrorComponent = React.ComponentType<ErrorFallbackProps>;
146
+
147
+ /**
148
+ * Loading/Error 로더 타입
149
+ */
150
+ export type LoadingLoader = () => Promise<{ default: LoadingComponent }>;
151
+ export type ErrorLoader = () => Promise<{ default: ErrorComponent }>;
152
+
122
153
  /**
123
154
  * Page 등록 정보
124
155
  * - component: React 컴포넌트
@@ -166,6 +197,18 @@ export class ServerRegistry {
166
197
  readonly pageLoaders: Map<string, PageLoader> = new Map();
167
198
  readonly pageHandlers: Map<string, PageHandler> = new Map();
168
199
  readonly routeComponents: Map<string, RouteComponent> = new Map();
200
+ /** Layout 컴포넌트 캐시 (모듈 경로 → 컴포넌트) */
201
+ readonly layoutComponents: Map<string, LayoutComponent> = new Map();
202
+ /** Layout 로더 (모듈 경로 → 로더 함수) */
203
+ readonly layoutLoaders: Map<string, LayoutLoader> = new Map();
204
+ /** Loading 컴포넌트 캐시 (모듈 경로 → 컴포넌트) */
205
+ readonly loadingComponents: Map<string, LoadingComponent> = new Map();
206
+ /** Loading 로더 (모듈 경로 → 로더 함수) */
207
+ readonly loadingLoaders: Map<string, LoadingLoader> = new Map();
208
+ /** Error 컴포넌트 캐시 (모듈 경로 → 컴포넌트) */
209
+ readonly errorComponents: Map<string, ErrorComponent> = new Map();
210
+ /** Error 로더 (모듈 경로 → 로더 함수) */
211
+ readonly errorLoaders: Map<string, ErrorLoader> = new Map();
169
212
  createAppFn: CreateAppFn | null = null;
170
213
  settings: ServerRegistrySettings = {
171
214
  isDev: false,
@@ -191,10 +234,130 @@ export class ServerRegistry {
191
234
  this.routeComponents.set(routeId, component);
192
235
  }
193
236
 
237
+ /**
238
+ * Layout 로더 등록
239
+ */
240
+ registerLayoutLoader(modulePath: string, loader: LayoutLoader): void {
241
+ this.layoutLoaders.set(modulePath, loader);
242
+ }
243
+
244
+ /**
245
+ * Layout 컴포넌트 가져오기 (캐시 또는 로드)
246
+ */
247
+ async getLayoutComponent(modulePath: string): Promise<LayoutComponent | null> {
248
+ // 캐시 확인
249
+ const cached = this.layoutComponents.get(modulePath);
250
+ if (cached) {
251
+ return cached;
252
+ }
253
+
254
+ // 로더로 로드
255
+ const loader = this.layoutLoaders.get(modulePath);
256
+ if (loader) {
257
+ try {
258
+ const module = await loader();
259
+ const component = module.default;
260
+ this.layoutComponents.set(modulePath, component);
261
+ return component;
262
+ } catch (error) {
263
+ console.error(`[Mandu] Failed to load layout: ${modulePath}`, error);
264
+ return null;
265
+ }
266
+ }
267
+
268
+ // 동적 import 시도
269
+ try {
270
+ const fullPath = path.join(this.settings.rootDir, modulePath);
271
+ const module = await import(fullPath);
272
+ const component = module.default;
273
+ this.layoutComponents.set(modulePath, component);
274
+ return component;
275
+ } catch (error) {
276
+ console.error(`[Mandu] Failed to load layout: ${modulePath}`, error);
277
+ return null;
278
+ }
279
+ }
280
+
194
281
  setCreateApp(fn: CreateAppFn): void {
195
282
  this.createAppFn = fn;
196
283
  }
197
284
 
285
+ /**
286
+ * Loading 로더 등록
287
+ */
288
+ registerLoadingLoader(modulePath: string, loader: LoadingLoader): void {
289
+ this.loadingLoaders.set(modulePath, loader);
290
+ }
291
+
292
+ /**
293
+ * Error 로더 등록
294
+ */
295
+ registerErrorLoader(modulePath: string, loader: ErrorLoader): void {
296
+ this.errorLoaders.set(modulePath, loader);
297
+ }
298
+
299
+ /**
300
+ * Loading 컴포넌트 가져오기 (캐시 또는 로드)
301
+ */
302
+ async getLoadingComponent(modulePath: string): Promise<LoadingComponent | null> {
303
+ const cached = this.loadingComponents.get(modulePath);
304
+ if (cached) return cached;
305
+
306
+ const loader = this.loadingLoaders.get(modulePath);
307
+ if (loader) {
308
+ try {
309
+ const module = await loader();
310
+ const component = module.default;
311
+ this.loadingComponents.set(modulePath, component);
312
+ return component;
313
+ } catch (error) {
314
+ console.error(`[Mandu] Failed to load loading component: ${modulePath}`, error);
315
+ return null;
316
+ }
317
+ }
318
+
319
+ try {
320
+ const fullPath = path.join(this.settings.rootDir, modulePath);
321
+ const module = await import(fullPath);
322
+ const component = module.default;
323
+ this.loadingComponents.set(modulePath, component);
324
+ return component;
325
+ } catch {
326
+ return null;
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Error 컴포넌트 가져오기 (캐시 또는 로드)
332
+ */
333
+ async getErrorComponent(modulePath: string): Promise<ErrorComponent | null> {
334
+ const cached = this.errorComponents.get(modulePath);
335
+ if (cached) return cached;
336
+
337
+ const loader = this.errorLoaders.get(modulePath);
338
+ if (loader) {
339
+ try {
340
+ const module = await loader();
341
+ const component = module.default;
342
+ this.errorComponents.set(modulePath, component);
343
+ return component;
344
+ } catch (error) {
345
+ console.error(`[Mandu] Failed to load error component: ${modulePath}`, error);
346
+ return null;
347
+ }
348
+ }
349
+
350
+ try {
351
+ const fullPath = path.join(this.settings.rootDir, modulePath);
352
+ const module = await import(fullPath);
353
+ const component = module.default;
354
+ this.errorComponents.set(modulePath, component);
355
+ return component;
356
+ } catch {
357
+ return null;
358
+ }
359
+ }
360
+
198
361
  /**
199
362
  * 모든 핸들러/컴포넌트 초기화 (테스트용)
200
363
  */
@@ -203,6 +366,12 @@ export class ServerRegistry {
203
366
  this.pageLoaders.clear();
204
367
  this.pageHandlers.clear();
205
368
  this.routeComponents.clear();
369
+ this.layoutComponents.clear();
370
+ this.layoutLoaders.clear();
371
+ this.loadingComponents.clear();
372
+ this.loadingLoaders.clear();
373
+ this.errorComponents.clear();
374
+ this.errorLoaders.clear();
206
375
  this.createAppFn = null;
207
376
  }
208
377
  }
@@ -253,6 +422,63 @@ export function setCreateApp(fn: CreateAppFn): void {
253
422
  defaultRegistry.setCreateApp(fn);
254
423
  }
255
424
 
425
+ /**
426
+ * Layout 로더 등록 (전역)
427
+ */
428
+ export function registerLayoutLoader(modulePath: string, loader: LayoutLoader): void {
429
+ defaultRegistry.registerLayoutLoader(modulePath, loader);
430
+ }
431
+
432
+ /**
433
+ * Loading 로더 등록 (전역)
434
+ */
435
+ export function registerLoadingLoader(modulePath: string, loader: LoadingLoader): void {
436
+ defaultRegistry.registerLoadingLoader(modulePath, loader);
437
+ }
438
+
439
+ /**
440
+ * Error 로더 등록 (전역)
441
+ */
442
+ export function registerErrorLoader(modulePath: string, loader: ErrorLoader): void {
443
+ defaultRegistry.registerErrorLoader(modulePath, loader);
444
+ }
445
+
446
+ /**
447
+ * 레이아웃 체인으로 컨텐츠 래핑
448
+ *
449
+ * @param content 페이지 컴포넌트로 렌더된 React Element
450
+ * @param layoutChain 레이아웃 모듈 경로 배열 (외부 → 내부)
451
+ * @param registry ServerRegistry 인스턴스
452
+ * @param params URL 파라미터
453
+ * @returns 래핑된 React Element
454
+ */
455
+ async function wrapWithLayouts(
456
+ content: React.ReactElement,
457
+ layoutChain: string[],
458
+ registry: ServerRegistry,
459
+ params: Record<string, string>
460
+ ): Promise<React.ReactElement> {
461
+ if (!layoutChain || layoutChain.length === 0) {
462
+ return content;
463
+ }
464
+
465
+ // 레이아웃 로드 (병렬)
466
+ const layouts = await Promise.all(
467
+ layoutChain.map((modulePath) => registry.getLayoutComponent(modulePath))
468
+ );
469
+
470
+ // 내부 → 외부 순서로 래핑 (역순)
471
+ let wrapped = content;
472
+ for (let i = layouts.length - 1; i >= 0; i--) {
473
+ const Layout = layouts[i];
474
+ if (Layout) {
475
+ wrapped = React.createElement(Layout, { params }, wrapped);
476
+ }
477
+ }
478
+
479
+ return wrapped;
480
+ }
481
+
256
482
  // Default createApp implementation (registry 기반)
257
483
  function createDefaultAppFactory(registry: ServerRegistry) {
258
484
  return function defaultCreateApp(context: AppContext): React.ReactElement {
@@ -504,13 +730,18 @@ async function handleRequest(req: Request, router: Router, registry: ServerRegis
504
730
  const defaultAppCreator = createDefaultAppFactory(registry);
505
731
  const appCreator = registry.createAppFn || defaultAppCreator;
506
732
  try {
507
- const app = appCreator({
733
+ let app = appCreator({
508
734
  routeId: route.id,
509
735
  url: req.url,
510
736
  params,
511
737
  loaderData,
512
738
  });
513
739
 
740
+ // 레이아웃 체인 적용 (layoutChain이 있는 경우)
741
+ if (route.layoutChain && route.layoutChain.length > 0) {
742
+ app = await wrapWithLayouts(app, route.layoutChain, registry, params);
743
+ }
744
+
514
745
  // serverData 구조: { [routeId]: { serverData: loaderData } }
515
746
  const serverData = loaderData
516
747
  ? { [route.id]: { serverData: loaderData } }
@@ -94,6 +94,17 @@ export const RouteSpec = z
94
94
  // - false: 이 라우트에 전통적 SSR 적용
95
95
  // - undefined: 서버 설정 따름
96
96
  streaming: z.boolean().optional(),
97
+
98
+ // Layout 체인 [NEW v0.9.33]
99
+ // - 페이지에 적용할 레이아웃 모듈 경로 배열
100
+ // - 배열 순서: 외부 → 내부 (Root → Parent → Child)
101
+ layoutChain: z.array(z.string()).optional(),
102
+
103
+ // Loading UI 모듈 경로 [NEW v0.9.33]
104
+ loadingModule: z.string().optional(),
105
+
106
+ // Error UI 모듈 경로 [NEW v0.9.33]
107
+ errorModule: z.string().optional(),
97
108
  })
98
109
  .refine(
99
110
  (route) => {
@@ -21,10 +21,10 @@ import fs from "fs/promises";
21
21
  */
22
22
  export const MVP_RULES: ArchRule[] = [
23
23
  {
24
- id: "GENERATED_DIRECT_EDIT",
24
+ id: "GENERATED_DIRECT_EDIT",
25
25
  name: "Generated Direct Edit",
26
26
  description: "Generated 파일은 직접 수정하면 안 됩니다",
27
- pattern: "generated/**",
27
+ pattern: "**/generated/**",
28
28
  action: "warn",
29
29
  message: "Generated 파일이 직접 수정되었습니다. 이 파일은 `mandu generate`로 재생성됩니다.",
30
30
  agentAction: "regenerate",
@@ -64,10 +64,10 @@ export const MVP_RULES: ArchRule[] = [
64
64
  agentCommand: "mandu_check_location",
65
65
  },
66
66
  {
67
- id: "FORBIDDEN_IMPORT",
67
+ id: "FORBIDDEN_IMPORT",
68
68
  name: "Forbidden Import in Generated",
69
69
  description: "Generated 파일에서 금지된 모듈 import",
70
- pattern: "generated/**",
70
+ pattern: "**/generated/**",
71
71
  action: "warn",
72
72
  message: "Generated 파일에서 금지된 모듈이 import되었습니다.",
73
73
  forbiddenImports: ["fs", "child_process", "cluster", "worker_threads"],