@mandujs/core 0.20.6 → 0.20.8

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.20.6",
3
+ "version": "0.20.8",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -12,6 +12,7 @@
12
12
  "./testing": "./src/testing/index.ts",
13
13
  "./plugins": "./src/plugins/index.ts",
14
14
  "./error": "./src/error/index.ts",
15
+ "./observability": "./src/observability/index.ts",
15
16
  "./bundler/prerender": "./src/bundler/prerender.ts",
16
17
  "./*": "./src/*"
17
18
  },
@@ -64,7 +64,10 @@ async function buildPerIslandBundle(
64
64
  define: { "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"), ...options.define },
65
65
  });
66
66
  await fs.unlink(entryPath).catch(() => {});
67
- if (!result.success) throw new Error(result.logs.map((l) => l.message).join("\n"));
67
+ if (!result.success) {
68
+ const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
69
+ throw new Error(`Island build failed for '${entry.name}' (source: ${entry.filePath}):\n${grouped}\n Hint: Check the import paths and TypeScript types in this island file.`);
70
+ }
68
71
  return { name: entry.name, js: `/.mandu/client/${outputName}`, route: entry.routeId, priority: entry.priority };
69
72
  } catch (error) {
70
73
  await fs.unlink(entryPath).catch(() => {});
@@ -986,10 +989,11 @@ if (typeof window !== 'undefined') {
986
989
  await fs.unlink(srcPath).catch(() => {});
987
990
 
988
991
  if (!result.success) {
992
+ const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
989
993
  return {
990
994
  success: false,
991
995
  outputPath: "",
992
- errors: result.logs.map((l) => l.message),
996
+ errors: [`DevTools client build failed (source: ${srcPath}):\n${grouped}\n Hint: Check the import paths and TypeScript types.`],
993
997
  };
994
998
  }
995
999
 
@@ -1003,7 +1007,7 @@ if (typeof window !== 'undefined') {
1003
1007
  return {
1004
1008
  success: false,
1005
1009
  outputPath: "",
1006
- errors: [String(error)],
1010
+ errors: [`DevTools client build threw an exception (source: ${srcPath}): ${String(error)}`],
1007
1011
  };
1008
1012
  }
1009
1013
  }
@@ -1037,10 +1041,11 @@ async function buildRouterRuntime(
1037
1041
  await fs.unlink(routerPath).catch(() => {});
1038
1042
 
1039
1043
  if (!result.success) {
1044
+ const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
1040
1045
  return {
1041
1046
  success: false,
1042
1047
  outputPath: "",
1043
- errors: result.logs.map((l) => l.message),
1048
+ errors: [`Router runtime build failed (source: ${routerPath}):\n${grouped}\n Hint: Check the import paths and TypeScript types.`],
1044
1049
  };
1045
1050
  }
1046
1051
 
@@ -1054,7 +1059,7 @@ async function buildRouterRuntime(
1054
1059
  return {
1055
1060
  success: false,
1056
1061
  outputPath: "",
1057
- errors: [String(error)],
1062
+ errors: [`Router runtime build threw an exception (source: ${routerPath}): ${String(error)}`],
1058
1063
  };
1059
1064
  }
1060
1065
  }
@@ -1111,10 +1116,11 @@ async function buildRuntime(
1111
1116
 
1112
1117
  if (!result.success) {
1113
1118
  // 실패 시 디버깅을 위해 소스 파일을 남겨둠 (_runtime.src.js)
1119
+ const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
1114
1120
  return {
1115
1121
  success: false,
1116
1122
  outputPath: "",
1117
- errors: result.logs.map((l) => l.message),
1123
+ errors: [`Runtime bundle build failed (source: ${runtimePath}):\n${grouped}\n Hint: Check the import paths and TypeScript types. The source file has been kept for debugging.`],
1118
1124
  };
1119
1125
  }
1120
1126
 
@@ -1140,7 +1146,7 @@ async function buildRuntime(
1140
1146
  return {
1141
1147
  success: false,
1142
1148
  outputPath: "",
1143
- errors: [String(error), ...extra].filter(Boolean),
1149
+ errors: [`Runtime bundle build threw an exception (source: ${runtimePath}): ${String(error)}`, ...extra].filter(Boolean),
1144
1150
  };
1145
1151
  }
1146
1152
  }
@@ -1219,9 +1225,10 @@ async function buildVendorShims(
1219
1225
  await fs.unlink(srcPath).catch(() => {});
1220
1226
 
1221
1227
  if (!result.success) {
1228
+ const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
1222
1229
  return {
1223
1230
  key: shim.key,
1224
- error: `[${shim.name}] ${result.logs.map((l) => l.message).join(", ")}`,
1231
+ error: `Vendor shim '${shim.name}' build failed (source: ${srcPath}):\n${grouped}\n Hint: Check the import paths and ensure the vendor package is installed.`,
1225
1232
  };
1226
1233
  }
1227
1234
 
@@ -1296,7 +1303,8 @@ async function buildIsland(
1296
1303
  await fs.unlink(entryPath).catch(() => {});
1297
1304
 
1298
1305
  if (!result.success) {
1299
- throw new Error(result.logs.map((l) => l.message).join("\n"));
1306
+ const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
1307
+ throw new Error(`Island build failed for route '${route.id}' (source: ${clientModulePath}):\n${grouped}\n Hint: Check the import paths and TypeScript types in this island file.`);
1300
1308
  }
1301
1309
 
1302
1310
  // 출력 파일 정보
@@ -24,6 +24,11 @@ export interface DevBundlerOptions {
24
24
  * 클라이언트 번들 리빌드 없이 서버 핸들러 재등록이 필요한 경우 호출
25
25
  */
26
26
  onSSRChange?: (filePath: string) => void;
27
+ /**
28
+ * API route 파일 변경 콜백 (route.ts 등)
29
+ * API 핸들러 재등록이 필요한 경우 호출
30
+ */
31
+ onAPIChange?: (filePath: string) => void;
27
32
  /**
28
33
  * 추가 watch 디렉토리 (공통 컴포넌트 등)
29
34
  * 상대 경로 또는 절대 경로 모두 지원
@@ -81,6 +86,7 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
81
86
  onRebuild,
82
87
  onError,
83
88
  onSSRChange,
89
+ onAPIChange,
84
90
  watchDirs: customWatchDirs = [],
85
91
  disableDefaultWatchDirs = false,
86
92
  } = options;
@@ -101,6 +107,7 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
101
107
  // clientModule 경로에서 routeId 매핑 생성
102
108
  const clientModuleToRoute = new Map<string, string>();
103
109
  const serverModuleSet = new Set<string>(); // SSR 모듈 (page.tsx, layout.tsx)
110
+ const apiModuleSet = new Set<string>(); // API 모듈 (route.ts)
104
111
  const watchDirs = new Set<string>();
105
112
  const commonWatchDirs = new Set<string>(); // 공통 디렉토리 (전체 재빌드 트리거)
106
113
 
@@ -138,6 +145,13 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
138
145
  watchDirs.add(path.dirname(path.resolve(rootDir, layoutPath)));
139
146
  }
140
147
  }
148
+
149
+ // Track API route modules for hot-reload
150
+ if (route.kind === "api" && route.module) {
151
+ const absPath = path.resolve(rootDir, route.module).replace(/\\/g, "/");
152
+ apiModuleSet.add(absPath);
153
+ watchDirs.add(path.dirname(path.resolve(rootDir, route.module)));
154
+ }
141
155
  }
142
156
 
143
157
  // spec/slots 디렉토리도 추가
@@ -281,6 +295,12 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
281
295
  if (onSSRChange && serverModuleSet.has(normalizedPath)) {
282
296
  console.log(`\n🔄 SSR file changed: ${path.basename(changedFile)}`);
283
297
  onSSRChange(normalizedPath);
298
+ return;
299
+ }
300
+ // API 모듈 변경 감지 (route.ts)
301
+ if (onAPIChange && apiModuleSet.has(normalizedPath)) {
302
+ console.log(`\n🔄 API route changed: ${path.basename(changedFile)}`);
303
+ onAPIChange(normalizedPath);
284
304
  }
285
305
  return;
286
306
  }
@@ -233,19 +233,33 @@ export async function validateConfig(rootDir: string): Promise<ValidationResult>
233
233
  path: e.path.join("."),
234
234
  message: e.message,
235
235
  }));
236
- return { valid: false, errors, source: fileName };
236
+ return {
237
+ valid: false,
238
+ errors: [
239
+ { path: "", message: `Config validation failed in '${filePath}'. Fix the following field errors:` },
240
+ ...errors,
241
+ ],
242
+ source: fileName,
243
+ };
244
+ }
245
+
246
+ // Differentiate file-not-found from parse/import errors
247
+ const errMsg = error instanceof Error ? error.message : String(error);
248
+ const isModuleError = errMsg.includes("Cannot find module") || errMsg.includes("MODULE_NOT_FOUND");
249
+ const isSyntaxError = error instanceof SyntaxError || errMsg.includes("SyntaxError");
250
+
251
+ let detail: string;
252
+ if (isModuleError) {
253
+ detail = `Could not resolve config file '${filePath}'. Check that the file exists and all its imports are installed.`;
254
+ } else if (isSyntaxError) {
255
+ detail = `Syntax error while parsing '${filePath}': ${errMsg}. Verify the file contains valid TypeScript/JSON.`;
256
+ } else {
257
+ detail = `Failed to load config from '${filePath}': ${errMsg}`;
237
258
  }
238
259
 
239
260
  return {
240
261
  valid: false,
241
- errors: [
242
- {
243
- path: "",
244
- message: `Failed to load config: ${
245
- error instanceof Error ? error.message : String(error)
246
- }`,
247
- },
248
- ],
262
+ errors: [{ path: "", message: detail }],
249
263
  source: fileName,
250
264
  };
251
265
  }
@@ -13,32 +13,32 @@
13
13
  */
14
14
 
15
15
  import type { z } from "zod";
16
- import type {
17
- ContractSchema,
18
- ContractValidationResult,
19
- ContractValidationError,
20
- ContractValidationIssue,
21
- MethodRequestSchema,
22
- ContractNormalizeMode,
23
- ResponseSchemaWithExamples,
24
- } from "./schema";
16
+ import type {
17
+ ContractSchema,
18
+ ContractValidationResult,
19
+ ContractValidationError,
20
+ ContractValidationIssue,
21
+ MethodRequestSchema,
22
+ ContractNormalizeMode,
23
+ ResponseSchemaWithExamples,
24
+ } from "./schema";
25
25
  import {
26
26
  type NormalizeMode,
27
27
  type NormalizeOptions,
28
28
  normalizeSchema,
29
29
  createCoerceSchema,
30
30
  } from "./normalize";
31
- import { ZodObject } from "zod";
32
-
33
- function isResponseSchemaWithExamples(
34
- schema: z.ZodTypeAny | ResponseSchemaWithExamples | undefined
35
- ): schema is ResponseSchemaWithExamples {
36
- return (
37
- schema !== undefined &&
38
- typeof schema === "object" &&
39
- "schema" in schema
40
- );
41
- }
31
+ import { ZodObject } from "zod";
32
+
33
+ function isResponseSchemaWithExamples(
34
+ schema: z.ZodTypeAny | ResponseSchemaWithExamples | undefined
35
+ ): schema is ResponseSchemaWithExamples {
36
+ return (
37
+ schema !== undefined &&
38
+ typeof schema === "object" &&
39
+ "schema" in schema
40
+ );
41
+ }
42
42
 
43
43
  /**
44
44
  * Validator 옵션
@@ -248,18 +248,23 @@ export class ContractValidator {
248
248
 
249
249
  const result = methodSchema.body.safeParse(body);
250
250
  if (!result.success) {
251
+ const enrichedIssues = zodErrorToIssues(result.error).map((issue) => ({
252
+ ...issue,
253
+ message: `${issue.path.length > 0 ? `At '${issue.path.join(".")}': ` : ""}${issue.message}`,
254
+ }));
251
255
  errors.push({
252
256
  type: "body",
253
- issues: zodErrorToIssues(result.error),
257
+ issues: enrichedIssues,
254
258
  });
255
259
  }
256
260
  } catch (error) {
261
+ const contentType = req.headers.get("content-type") || "(not set)";
257
262
  errors.push({
258
263
  type: "body",
259
264
  issues: [
260
265
  {
261
266
  path: [],
262
- message: `Failed to parse request body: ${error instanceof Error ? error.message : "Unknown error"}`,
267
+ message: `Failed to parse request body (received Content-Type: '${contentType}'): ${error instanceof Error ? error.message : "Unknown error"}. Ensure the body matches the declared Content-Type and is well-formed.`,
263
268
  code: "invalid_type",
264
269
  },
265
270
  ],
@@ -420,20 +425,25 @@ export class ContractValidator {
420
425
  const bodySchema = normalizeSchema(methodSchema.body, normalizeOpts);
421
426
  const result = bodySchema.safeParse(body);
422
427
  if (!result.success) {
428
+ const enrichedIssues = zodErrorToIssues(result.error).map((issue) => ({
429
+ ...issue,
430
+ message: `${issue.path.length > 0 ? `At '${issue.path.join(".")}': ` : ""}${issue.message}`,
431
+ }));
423
432
  errors.push({
424
433
  type: "body",
425
- issues: zodErrorToIssues(result.error),
434
+ issues: enrichedIssues,
426
435
  });
427
436
  } else {
428
437
  normalizedData.body = result.data;
429
438
  }
430
439
  } catch (error) {
440
+ const contentType = req.headers.get("content-type") || "(not set)";
431
441
  errors.push({
432
442
  type: "body",
433
443
  issues: [
434
444
  {
435
445
  path: [],
436
- message: `Failed to parse request body: ${error instanceof Error ? error.message : "Unknown error"}`,
446
+ message: `Failed to parse request body (received Content-Type: '${contentType}'): ${error instanceof Error ? error.message : "Unknown error"}. Ensure the body matches the declared Content-Type and is well-formed.`,
437
447
  code: "invalid_type",
438
448
  },
439
449
  ],
@@ -453,18 +463,18 @@ export class ContractValidator {
453
463
  * @param responseBody - The response body (already parsed)
454
464
  * @param statusCode - HTTP status code
455
465
  */
456
- validateResponse(responseBody: unknown, statusCode: number): ContractValidationResult {
457
- const responseSchemaOrWithExamples = this.contract.response[statusCode];
458
- if (!responseSchemaOrWithExamples) {
459
- // No schema defined for this status code, pass through
460
- return { success: true };
461
- }
462
-
463
- const responseSchema = isResponseSchemaWithExamples(responseSchemaOrWithExamples)
464
- ? responseSchemaOrWithExamples.schema
465
- : responseSchemaOrWithExamples;
466
-
467
- const result = responseSchema.safeParse(responseBody);
466
+ validateResponse(responseBody: unknown, statusCode: number): ContractValidationResult {
467
+ const responseSchemaOrWithExamples = this.contract.response[statusCode];
468
+ if (!responseSchemaOrWithExamples) {
469
+ // No schema defined for this status code, pass through
470
+ return { success: true };
471
+ }
472
+
473
+ const responseSchema = isResponseSchemaWithExamples(responseSchemaOrWithExamples)
474
+ ? responseSchemaOrWithExamples.schema
475
+ : responseSchemaOrWithExamples;
476
+
477
+ const result = responseSchema.safeParse(responseBody);
468
478
  if (!result.success) {
469
479
  const errors: ContractValidationError[] = [
470
480
  {
@@ -40,6 +40,7 @@ import {
40
40
  } from "./cors";
41
41
  import { validateImportPath } from "./security";
42
42
  import { KITCHEN_PREFIX, KitchenHandler, recordRequest } from "../kitchen/kitchen-handler";
43
+ import { eventBus } from "../observability/event-bus";
43
44
  import {
44
45
  type MiddlewareFn,
45
46
  type MiddlewareConfig,
@@ -984,8 +985,9 @@ async function handleInternalCacheControlRequest(
984
985
  if (req.method === "POST") {
985
986
  try {
986
987
  payload = await req.json() as Record<string, unknown>;
987
- } catch {
988
- return Response.json({ error: "Invalid JSON body" }, { status: 400 });
988
+ } catch (parseErr) {
989
+ const detail = parseErr instanceof Error ? parseErr.message : "Invalid JSON";
990
+ return Response.json({ error: "Invalid JSON body", detail, hint: "Ensure the request body is valid JSON (e.g., no trailing commas, unquoted keys, or truncated input)." }, { status: 400 });
989
991
  }
990
992
  } else {
991
993
  payload = { all: true };
@@ -1015,11 +1017,13 @@ async function handleInternalCacheControlRequest(
1015
1017
  });
1016
1018
  }
1017
1019
 
1018
- return Response.json({ error: "Method not allowed" }, { status: 405 });
1020
+ return new Response(JSON.stringify({ error: "Method not allowed", allowed: ["GET", "POST", "DELETE"], hint: `Received '${req.method}'. This endpoint accepts GET (read stats), POST (clear by path/tag), and DELETE (clear all).` }), { status: 405, headers: { "Content-Type": "application/json", "Allow": "GET, POST, DELETE" } });
1019
1021
  }
1020
1022
 
1021
1023
  async function handleRequest(req: Request, router: Router, registry: ServerRegistry): Promise<Response> {
1022
1024
  const requestStart = Date.now();
1025
+ // Phase 1-4: Correlation ID — 한 요청에서 발생하는 모든 이벤트를 추적
1026
+ const correlationId = req.headers.get("x-mandu-request-id") ?? crypto.randomUUID();
1023
1027
  const result = await handleRequestInternal(req, router, registry);
1024
1028
 
1025
1029
  if (!result.ok) {
@@ -1034,7 +1038,17 @@ async function handleRequest(req: Request, router: Router, registry: ServerRegis
1034
1038
  if (!p.startsWith("/.mandu/") && !p.startsWith("/__kitchen")) {
1035
1039
  const elapsed = Date.now() - requestStart;
1036
1040
  console.log(`[${new Date().toLocaleTimeString()}] ${req.method} ${p} ${errorResponse.status} ${elapsed}ms`);
1037
- recordRequest({ id: crypto.randomUUID(), method: req.method, path: p, status: errorResponse.status, duration: elapsed, timestamp: Date.now() });
1041
+ recordRequest({ id: correlationId, method: req.method, path: p, status: errorResponse.status, duration: elapsed, timestamp: Date.now() });
1042
+ // Phase 1-2: HTTP 요청 → EventBus
1043
+ eventBus.emit({
1044
+ type: "http",
1045
+ severity: errorResponse.status >= 500 ? "error" : errorResponse.status >= 400 ? "warn" : "info",
1046
+ source: "server",
1047
+ correlationId,
1048
+ message: `${req.method} ${p} ${errorResponse.status}`,
1049
+ duration: elapsed,
1050
+ data: { method: req.method, path: p, status: errorResponse.status, error: true },
1051
+ });
1038
1052
  }
1039
1053
  }
1040
1054
  return errorResponse;
@@ -1056,7 +1070,17 @@ async function handleRequest(req: Request, router: Router, registry: ServerRegis
1056
1070
  const cacheHdr = result.value.headers.get("X-Mandu-Cache") ?? "";
1057
1071
  const cacheTag = cacheHdr ? ` ${cacheHdr}` : "";
1058
1072
  console.log(`[${new Date().toLocaleTimeString()}] ${req.method} ${p} ${status} ${elapsed}ms${cacheTag}`);
1059
- recordRequest({ id: crypto.randomUUID(), method: req.method, path: p, status, duration: elapsed, timestamp: Date.now(), cacheStatus: cacheHdr || undefined });
1073
+ recordRequest({ id: correlationId, method: req.method, path: p, status, duration: elapsed, timestamp: Date.now(), cacheStatus: cacheHdr || undefined });
1074
+ // Phase 1-2: HTTP 요청 → EventBus
1075
+ eventBus.emit({
1076
+ type: "http",
1077
+ severity: status >= 500 ? "error" : status >= 400 ? "warn" : "info",
1078
+ source: "server",
1079
+ correlationId,
1080
+ message: `${req.method} ${p} ${status}${cacheTag}`,
1081
+ duration: elapsed,
1082
+ data: { method: req.method, path: p, status, cache: cacheHdr || undefined },
1083
+ });
1060
1084
  }
1061
1085
  }
1062
1086
 
@@ -1609,7 +1633,7 @@ async function ensurePageRouteMetadata(
1609
1633
  ): Promise<PageRegistration> {
1610
1634
  const handler = pageHandler ?? registry.pageHandlers.get(routeId);
1611
1635
  if (!handler) {
1612
- throw new Error(`Page handler not found for route: ${routeId}`);
1636
+ throw new Error(`Page handler not found for route: '${routeId}'. Ensure this route is registered in the manifest. If you are running in development, restart 'mandu dev' to pick up new routes. In production, verify that the route module exists and was included in the build.`);
1613
1637
  }
1614
1638
 
1615
1639
  const existingComponent = registry.routeComponents.get(routeId);