@mandujs/core 0.20.6 → 0.20.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 +1 -1
- package/src/bundler/build.ts +17 -9
- package/src/bundler/dev.ts +20 -0
- package/src/config/validate.ts +23 -9
- package/src/contract/validator.ts +46 -36
- package/src/runtime/server.ts +5 -4
package/package.json
CHANGED
package/src/bundler/build.ts
CHANGED
|
@@ -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)
|
|
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:
|
|
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:
|
|
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:
|
|
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)
|
|
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: `
|
|
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
|
-
|
|
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
|
// 출력 파일 정보
|
package/src/bundler/dev.ts
CHANGED
|
@@ -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
|
}
|
package/src/config/validate.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
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:
|
|
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
|
{
|
package/src/runtime/server.ts
CHANGED
|
@@ -984,8 +984,9 @@ async function handleInternalCacheControlRequest(
|
|
|
984
984
|
if (req.method === "POST") {
|
|
985
985
|
try {
|
|
986
986
|
payload = await req.json() as Record<string, unknown>;
|
|
987
|
-
} catch {
|
|
988
|
-
|
|
987
|
+
} catch (parseErr) {
|
|
988
|
+
const detail = parseErr instanceof Error ? parseErr.message : "Invalid JSON";
|
|
989
|
+
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
990
|
}
|
|
990
991
|
} else {
|
|
991
992
|
payload = { all: true };
|
|
@@ -1015,7 +1016,7 @@ async function handleInternalCacheControlRequest(
|
|
|
1015
1016
|
});
|
|
1016
1017
|
}
|
|
1017
1018
|
|
|
1018
|
-
return Response.
|
|
1019
|
+
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
1020
|
}
|
|
1020
1021
|
|
|
1021
1022
|
async function handleRequest(req: Request, router: Router, registry: ServerRegistry): Promise<Response> {
|
|
@@ -1609,7 +1610,7 @@ async function ensurePageRouteMetadata(
|
|
|
1609
1610
|
): Promise<PageRegistration> {
|
|
1610
1611
|
const handler = pageHandler ?? registry.pageHandlers.get(routeId);
|
|
1611
1612
|
if (!handler) {
|
|
1612
|
-
throw new Error(`Page handler not found for route: ${routeId}
|
|
1613
|
+
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
1614
|
}
|
|
1614
1615
|
|
|
1615
1616
|
const existingComponent = registry.routeComponents.get(routeId);
|