@mandujs/core 0.1.0

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 ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@mandujs/core",
3
+ "version": "0.1.0",
4
+ "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./*": "./src/*"
11
+ },
12
+ "files": [
13
+ "src/**/*"
14
+ ],
15
+ "keywords": [
16
+ "mandu",
17
+ "framework",
18
+ "agent",
19
+ "ai",
20
+ "code-generation"
21
+ ],
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/konamgil/mandu.git",
25
+ "directory": "packages/core"
26
+ },
27
+ "author": "konamgil",
28
+ "license": "MIT",
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "peerDependencies": {
33
+ "bun": ">=1.0.0",
34
+ "react": ">=18.0.0",
35
+ "react-dom": ">=18.0.0",
36
+ "zod": ">=3.0.0"
37
+ }
38
+ }
@@ -0,0 +1,127 @@
1
+ import type { RoutesManifest, RouteSpec } from "../spec/schema";
2
+ import { generateApiHandler, generatePageComponent } from "./templates";
3
+ import path from "path";
4
+ import fs from "fs/promises";
5
+
6
+ export interface GenerateResult {
7
+ success: boolean;
8
+ created: string[];
9
+ deleted: string[];
10
+ errors: string[];
11
+ }
12
+
13
+ export interface GeneratedMap {
14
+ version: number;
15
+ generatedAt: string;
16
+ files: Record<string, { routeId: string; kind: string }>;
17
+ }
18
+
19
+ async function ensureDir(dirPath: string): Promise<void> {
20
+ try {
21
+ await fs.mkdir(dirPath, { recursive: true });
22
+ } catch (error) {
23
+ // ignore if exists
24
+ }
25
+ }
26
+
27
+ async function getExistingFiles(dir: string): Promise<string[]> {
28
+ try {
29
+ const files = await fs.readdir(dir);
30
+ return files.filter((f) => f.endsWith(".route.ts") || f.endsWith(".route.tsx"));
31
+ } catch {
32
+ return [];
33
+ }
34
+ }
35
+
36
+ export async function generateRoutes(
37
+ manifest: RoutesManifest,
38
+ rootDir: string
39
+ ): Promise<GenerateResult> {
40
+ const result: GenerateResult = {
41
+ success: true,
42
+ created: [],
43
+ deleted: [],
44
+ errors: [],
45
+ };
46
+
47
+ const serverRoutesDir = path.join(rootDir, "apps/server/generated/routes");
48
+ const webRoutesDir = path.join(rootDir, "apps/web/generated/routes");
49
+ const mapDir = path.join(rootDir, "packages/core/map");
50
+
51
+ await ensureDir(serverRoutesDir);
52
+ await ensureDir(webRoutesDir);
53
+ await ensureDir(mapDir);
54
+
55
+ const generatedMap: GeneratedMap = {
56
+ version: manifest.version,
57
+ generatedAt: new Date().toISOString(),
58
+ files: {},
59
+ };
60
+
61
+ const expectedServerFiles = new Set<string>();
62
+ const expectedWebFiles = new Set<string>();
63
+
64
+ for (const route of manifest.routes) {
65
+ try {
66
+ // Server handler
67
+ const serverFileName = `${route.id}.route.ts`;
68
+ const serverFilePath = path.join(serverRoutesDir, serverFileName);
69
+ expectedServerFiles.add(serverFileName);
70
+
71
+ const handlerContent = generateApiHandler(route);
72
+ await Bun.write(serverFilePath, handlerContent);
73
+ result.created.push(serverFilePath);
74
+
75
+ generatedMap.files[`apps/server/generated/routes/${serverFileName}`] = {
76
+ routeId: route.id,
77
+ kind: route.kind,
78
+ };
79
+
80
+ // Page component (only for page kind)
81
+ if (route.kind === "page") {
82
+ const webFileName = `${route.id}.route.tsx`;
83
+ const webFilePath = path.join(webRoutesDir, webFileName);
84
+ expectedWebFiles.add(webFileName);
85
+
86
+ const componentContent = generatePageComponent(route);
87
+ await Bun.write(webFilePath, componentContent);
88
+ result.created.push(webFilePath);
89
+
90
+ generatedMap.files[`apps/web/generated/routes/${webFileName}`] = {
91
+ routeId: route.id,
92
+ kind: route.kind,
93
+ };
94
+ }
95
+ } catch (error) {
96
+ result.success = false;
97
+ result.errors.push(
98
+ `Failed to generate ${route.id}: ${error instanceof Error ? error.message : String(error)}`
99
+ );
100
+ }
101
+ }
102
+
103
+ // Clean up stale files
104
+ const existingServerFiles = await getExistingFiles(serverRoutesDir);
105
+ for (const file of existingServerFiles) {
106
+ if (!expectedServerFiles.has(file)) {
107
+ const filePath = path.join(serverRoutesDir, file);
108
+ await fs.unlink(filePath);
109
+ result.deleted.push(filePath);
110
+ }
111
+ }
112
+
113
+ const existingWebFiles = await getExistingFiles(webRoutesDir);
114
+ for (const file of existingWebFiles) {
115
+ if (!expectedWebFiles.has(file)) {
116
+ const filePath = path.join(webRoutesDir, file);
117
+ await fs.unlink(filePath);
118
+ result.deleted.push(filePath);
119
+ }
120
+ }
121
+
122
+ // Write generated map
123
+ const mapPath = path.join(mapDir, "generated.map.json");
124
+ await Bun.write(mapPath, JSON.stringify(generatedMap, null, 2));
125
+
126
+ return result;
127
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./generate";
2
+ export * from "./templates";
@@ -0,0 +1,45 @@
1
+ import type { RouteSpec } from "../spec/schema";
2
+
3
+ export function generateApiHandler(route: RouteSpec): string {
4
+ return `// Generated by Mandu - DO NOT EDIT DIRECTLY
5
+ // Route ID: ${route.id}
6
+ // Pattern: ${route.pattern}
7
+
8
+ import type { Request } from "bun";
9
+
10
+ export default function handler(req: Request, params: Record<string, string>): Response {
11
+ return Response.json({
12
+ status: "ok",
13
+ routeId: "${route.id}",
14
+ pattern: "${route.pattern}",
15
+ timestamp: new Date().toISOString(),
16
+ });
17
+ }
18
+ `;
19
+ }
20
+
21
+ export function generatePageComponent(route: RouteSpec): string {
22
+ const pageName = capitalize(route.id);
23
+ return `// Generated by Mandu - DO NOT EDIT DIRECTLY
24
+ // Route ID: ${route.id}
25
+ // Pattern: ${route.pattern}
26
+
27
+ import React from "react";
28
+
29
+ interface Props {
30
+ params: Record<string, string>;
31
+ }
32
+
33
+ export default function ${pageName}Page({ params }: Props): React.ReactElement {
34
+ return React.createElement("div", null,
35
+ React.createElement("h1", null, "${pageName} Page"),
36
+ React.createElement("p", null, "Route ID: ${route.id}"),
37
+ React.createElement("p", null, "Pattern: ${route.pattern}")
38
+ );
39
+ }
40
+ `;
41
+ }
42
+
43
+ function capitalize(str: string): string {
44
+ return str.charAt(0).toUpperCase() + str.slice(1);
45
+ }
@@ -0,0 +1,225 @@
1
+ import { GUARD_RULES, FORBIDDEN_IMPORTS, type GuardViolation } from "./rules";
2
+ import { verifyLock, computeHash } from "../spec/lock";
3
+ import type { RoutesManifest } from "../spec/schema";
4
+ import type { GeneratedMap } from "../generator/generate";
5
+ import path from "path";
6
+ import fs from "fs/promises";
7
+
8
+ export interface GuardCheckResult {
9
+ passed: boolean;
10
+ violations: GuardViolation[];
11
+ }
12
+
13
+ async function fileExists(filePath: string): Promise<boolean> {
14
+ try {
15
+ await fs.access(filePath);
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ async function readFileContent(filePath: string): Promise<string | null> {
23
+ try {
24
+ return await Bun.file(filePath).text();
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ // Rule 1: Spec hash mismatch
31
+ export async function checkSpecHashMismatch(
32
+ manifest: RoutesManifest,
33
+ lockPath: string
34
+ ): Promise<GuardViolation | null> {
35
+ const result = await verifyLock(lockPath, manifest);
36
+
37
+ if (!result.valid) {
38
+ return {
39
+ ruleId: GUARD_RULES.SPEC_HASH_MISMATCH.id,
40
+ file: lockPath,
41
+ message: result.lockHash
42
+ ? `Spec 해시 불일치: lock(${result.lockHash.slice(0, 8)}...) != current(${result.currentHash.slice(0, 8)}...)`
43
+ : "spec.lock.json 파일이 없습니다",
44
+ suggestion: "bunx mandu spec-upsert를 실행하여 변경사항을 반영하세요",
45
+ };
46
+ }
47
+
48
+ return null;
49
+ }
50
+
51
+ // Rule 2: Generated file manual edit detection
52
+ export async function checkGeneratedManualEdit(
53
+ rootDir: string,
54
+ generatedMap: GeneratedMap
55
+ ): Promise<GuardViolation[]> {
56
+ const violations: GuardViolation[] = [];
57
+
58
+ for (const [filePath, meta] of Object.entries(generatedMap.files)) {
59
+ const fullPath = path.join(rootDir, filePath);
60
+ const content = await readFileContent(fullPath);
61
+
62
+ if (!content) continue;
63
+
64
+ // Check if the "DO NOT EDIT" comment was removed or modified
65
+ if (!content.includes("Generated by Mandu - DO NOT EDIT DIRECTLY")) {
66
+ violations.push({
67
+ ruleId: GUARD_RULES.GENERATED_MANUAL_EDIT.id,
68
+ file: filePath,
69
+ message: `generated 파일이 수동으로 변경된 것으로 보입니다 (routeId: ${meta.routeId})`,
70
+ suggestion: "bunx mandu generate를 실행하여 파일을 재생성하세요",
71
+ });
72
+ }
73
+ }
74
+
75
+ return violations;
76
+ }
77
+
78
+ // Rule 3: Non-generated importing generated
79
+ export async function checkInvalidGeneratedImport(
80
+ rootDir: string
81
+ ): Promise<GuardViolation[]> {
82
+ const violations: GuardViolation[] = [];
83
+
84
+ // Scan non-generated source files
85
+ const sourceDirs = [
86
+ path.join(rootDir, "packages"),
87
+ path.join(rootDir, "apps/server"),
88
+ path.join(rootDir, "apps/web"),
89
+ ];
90
+
91
+ for (const sourceDir of sourceDirs) {
92
+ const files = await scanTsFiles(sourceDir);
93
+
94
+ for (const file of files) {
95
+ // Skip generated directories
96
+ if (file.includes("/generated/") || file.includes("\\generated\\")) {
97
+ continue;
98
+ }
99
+
100
+ const content = await readFileContent(file);
101
+ if (!content) continue;
102
+
103
+ // Check for imports from generated directories
104
+ const importRegex = /import\s+.*from\s+['"](.*generated.*)['"]/g;
105
+ let match;
106
+
107
+ while ((match = importRegex.exec(content)) !== null) {
108
+ const relativePath = path.relative(rootDir, file);
109
+ violations.push({
110
+ ruleId: GUARD_RULES.INVALID_GENERATED_IMPORT.id,
111
+ file: relativePath,
112
+ message: `generated 파일 직접 import 금지: ${match[1]}`,
113
+ suggestion:
114
+ "generated 파일을 직접 import하지 말고, 런타임 레지스트리를 통해 접근하세요",
115
+ });
116
+ }
117
+ }
118
+ }
119
+
120
+ return violations;
121
+ }
122
+
123
+ // Rule 4: Forbidden imports in generated files
124
+ export async function checkForbiddenImportsInGenerated(
125
+ rootDir: string,
126
+ generatedMap: GeneratedMap
127
+ ): Promise<GuardViolation[]> {
128
+ const violations: GuardViolation[] = [];
129
+
130
+ for (const [filePath] of Object.entries(generatedMap.files)) {
131
+ const fullPath = path.join(rootDir, filePath);
132
+ const content = await readFileContent(fullPath);
133
+
134
+ if (!content) continue;
135
+
136
+ for (const forbidden of FORBIDDEN_IMPORTS) {
137
+ const importRegex = new RegExp(
138
+ `import\\s+.*from\\s+['"]${forbidden}['"]|require\\s*\\(\\s*['"]${forbidden}['"]\\s*\\)`,
139
+ "g"
140
+ );
141
+
142
+ if (importRegex.test(content)) {
143
+ violations.push({
144
+ ruleId: GUARD_RULES.FORBIDDEN_IMPORT_IN_GENERATED.id,
145
+ file: filePath,
146
+ message: `generated 파일에서 금지된 모듈 '${forbidden}' import 감지`,
147
+ suggestion: `'${forbidden}' 모듈 사용이 필요하면 slot 로직에서 처리하세요`,
148
+ });
149
+ }
150
+ }
151
+ }
152
+
153
+ return violations;
154
+ }
155
+
156
+ async function scanTsFiles(dir: string): Promise<string[]> {
157
+ const files: string[] = [];
158
+
159
+ try {
160
+ const entries = await fs.readdir(dir, { withFileTypes: true });
161
+
162
+ for (const entry of entries) {
163
+ const fullPath = path.join(dir, entry.name);
164
+
165
+ if (entry.isDirectory()) {
166
+ if (entry.name !== "node_modules" && entry.name !== "dist") {
167
+ const subFiles = await scanTsFiles(fullPath);
168
+ files.push(...subFiles);
169
+ }
170
+ } else if (entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx"))) {
171
+ files.push(fullPath);
172
+ }
173
+ }
174
+ } catch {
175
+ // Directory doesn't exist or can't be read
176
+ }
177
+
178
+ return files;
179
+ }
180
+
181
+ export async function runGuardCheck(
182
+ manifest: RoutesManifest,
183
+ rootDir: string
184
+ ): Promise<GuardCheckResult> {
185
+ const violations: GuardViolation[] = [];
186
+
187
+ const lockPath = path.join(rootDir, "spec/spec.lock.json");
188
+ const mapPath = path.join(rootDir, "packages/core/map/generated.map.json");
189
+
190
+ // Rule 1
191
+ const hashViolation = await checkSpecHashMismatch(manifest, lockPath);
192
+ if (hashViolation) {
193
+ violations.push(hashViolation);
194
+ }
195
+
196
+ // Load generated map for other checks
197
+ let generatedMap: GeneratedMap | null = null;
198
+ if (await fileExists(mapPath)) {
199
+ try {
200
+ const mapContent = await Bun.file(mapPath).text();
201
+ generatedMap = JSON.parse(mapContent);
202
+ } catch {
203
+ // Map file corrupted or missing
204
+ }
205
+ }
206
+
207
+ if (generatedMap) {
208
+ // Rule 2
209
+ const editViolations = await checkGeneratedManualEdit(rootDir, generatedMap);
210
+ violations.push(...editViolations);
211
+
212
+ // Rule 4
213
+ const forbiddenViolations = await checkForbiddenImportsInGenerated(rootDir, generatedMap);
214
+ violations.push(...forbiddenViolations);
215
+ }
216
+
217
+ // Rule 3
218
+ const importViolations = await checkInvalidGeneratedImport(rootDir);
219
+ violations.push(...importViolations);
220
+
221
+ return {
222
+ passed: violations.length === 0,
223
+ violations,
224
+ };
225
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./rules";
2
+ export * from "./check";
@@ -0,0 +1,37 @@
1
+ export interface GuardViolation {
2
+ ruleId: string;
3
+ file: string;
4
+ message: string;
5
+ suggestion: string;
6
+ }
7
+
8
+ export interface GuardRule {
9
+ id: string;
10
+ name: string;
11
+ description: string;
12
+ }
13
+
14
+ export const GUARD_RULES: Record<string, GuardRule> = {
15
+ SPEC_HASH_MISMATCH: {
16
+ id: "SPEC_HASH_MISMATCH",
17
+ name: "Spec Hash Mismatch",
18
+ description: "spec.lock.json의 해시와 현재 spec이 일치하지 않습니다",
19
+ },
20
+ GENERATED_MANUAL_EDIT: {
21
+ id: "GENERATED_MANUAL_EDIT",
22
+ name: "Generated File Manual Edit",
23
+ description: "generated 파일이 수동으로 변경되었습니다",
24
+ },
25
+ INVALID_GENERATED_IMPORT: {
26
+ id: "INVALID_GENERATED_IMPORT",
27
+ name: "Invalid Generated Import",
28
+ description: "non-generated 파일에서 generated 파일을 직접 import 했습니다",
29
+ },
30
+ FORBIDDEN_IMPORT_IN_GENERATED: {
31
+ id: "FORBIDDEN_IMPORT_IN_GENERATED",
32
+ name: "Forbidden Import in Generated",
33
+ description: "generated 파일에서 금지된 모듈을 import 했습니다",
34
+ },
35
+ };
36
+
37
+ export const FORBIDDEN_IMPORTS = ["fs", "child_process", "cluster", "worker_threads"];
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from "./spec";
2
+ export * from "./runtime";
3
+ export * from "./generator";
4
+ export * from "./guard";
5
+ export * from "./report";
@@ -0,0 +1,127 @@
1
+ import type { GuardViolation } from "../guard/rules";
2
+ import type { GuardCheckResult } from "../guard/check";
3
+ import type { GenerateResult } from "../generator/generate";
4
+
5
+ export interface ManuduReport {
6
+ status: "pass" | "fail";
7
+ timestamp: string;
8
+ guardViolations: GuardViolation[];
9
+ generateResult?: {
10
+ created: string[];
11
+ deleted: string[];
12
+ errors: string[];
13
+ };
14
+ nextActions: string[];
15
+ }
16
+
17
+ export function buildGuardReport(checkResult: GuardCheckResult): ManuduReport {
18
+ const nextActions: string[] = [];
19
+
20
+ if (!checkResult.passed) {
21
+ const hasHashMismatch = checkResult.violations.some(
22
+ (v) => v.ruleId === "SPEC_HASH_MISMATCH"
23
+ );
24
+ const hasManualEdit = checkResult.violations.some(
25
+ (v) => v.ruleId === "GENERATED_MANUAL_EDIT"
26
+ );
27
+ const hasInvalidImport = checkResult.violations.some(
28
+ (v) => v.ruleId === "INVALID_GENERATED_IMPORT"
29
+ );
30
+ const hasForbiddenImport = checkResult.violations.some(
31
+ (v) => v.ruleId === "FORBIDDEN_IMPORT_IN_GENERATED"
32
+ );
33
+
34
+ if (hasHashMismatch) {
35
+ nextActions.push("bunx mandu spec-upsert --file spec/routes.manifest.json");
36
+ }
37
+ if (hasManualEdit) {
38
+ nextActions.push("bunx mandu generate");
39
+ }
40
+ if (hasInvalidImport) {
41
+ nextActions.push("generated 파일 직접 import를 제거하고 런타임 레지스트리 사용");
42
+ }
43
+ if (hasForbiddenImport) {
44
+ nextActions.push("generated 파일에서 금지된 import를 제거하고 slot에서 처리");
45
+ }
46
+ }
47
+
48
+ return {
49
+ status: checkResult.passed ? "pass" : "fail",
50
+ timestamp: new Date().toISOString(),
51
+ guardViolations: checkResult.violations,
52
+ nextActions,
53
+ };
54
+ }
55
+
56
+ export function buildGenerateReport(generateResult: GenerateResult): ManuduReport {
57
+ const nextActions: string[] = [];
58
+
59
+ if (!generateResult.success) {
60
+ nextActions.push("generate 오류를 수정하고 다시 실행하세요");
61
+ } else {
62
+ if (generateResult.created.length > 0) {
63
+ nextActions.push("bunx mandu guard로 검증 실행");
64
+ }
65
+ }
66
+
67
+ return {
68
+ status: generateResult.success ? "pass" : "fail",
69
+ timestamp: new Date().toISOString(),
70
+ guardViolations: [],
71
+ generateResult: {
72
+ created: generateResult.created,
73
+ deleted: generateResult.deleted,
74
+ errors: generateResult.errors,
75
+ },
76
+ nextActions,
77
+ };
78
+ }
79
+
80
+ export async function writeReport(report: ManuduReport, outputPath: string): Promise<void> {
81
+ await Bun.write(outputPath, JSON.stringify(report, null, 2));
82
+ }
83
+
84
+ export function printReportSummary(report: ManuduReport): void {
85
+ const statusIcon = report.status === "pass" ? "✅" : "❌";
86
+ console.log(`\n${statusIcon} Guard Status: ${report.status.toUpperCase()}`);
87
+ console.log(`📅 Timestamp: ${report.timestamp}`);
88
+
89
+ if (report.guardViolations.length > 0) {
90
+ console.log(`\n⚠️ Violations (${report.guardViolations.length}):`);
91
+ for (const violation of report.guardViolations) {
92
+ console.log(` [${violation.ruleId}] ${violation.file}`);
93
+ console.log(` └─ ${violation.message}`);
94
+ console.log(` 💡 ${violation.suggestion}`);
95
+ }
96
+ }
97
+
98
+ if (report.generateResult) {
99
+ if (report.generateResult.created.length > 0) {
100
+ console.log(`\n📁 Created (${report.generateResult.created.length}):`);
101
+ for (const file of report.generateResult.created) {
102
+ console.log(` + ${file}`);
103
+ }
104
+ }
105
+ if (report.generateResult.deleted.length > 0) {
106
+ console.log(`\n🗑️ Deleted (${report.generateResult.deleted.length}):`);
107
+ for (const file of report.generateResult.deleted) {
108
+ console.log(` - ${file}`);
109
+ }
110
+ }
111
+ if (report.generateResult.errors.length > 0) {
112
+ console.log(`\n❌ Errors (${report.generateResult.errors.length}):`);
113
+ for (const error of report.generateResult.errors) {
114
+ console.log(` ! ${error}`);
115
+ }
116
+ }
117
+ }
118
+
119
+ if (report.nextActions.length > 0) {
120
+ console.log(`\n🎯 Next Actions:`);
121
+ for (const action of report.nextActions) {
122
+ console.log(` → ${action}`);
123
+ }
124
+ }
125
+
126
+ console.log("");
127
+ }
@@ -0,0 +1 @@
1
+ export * from "./build";
@@ -0,0 +1,3 @@
1
+ export * from "./ssr";
2
+ export * from "./router";
3
+ export * from "./server";
@@ -0,0 +1,65 @@
1
+ import type { RouteSpec } from "../spec/schema";
2
+
3
+ export interface MatchResult {
4
+ route: RouteSpec;
5
+ params: Record<string, string>;
6
+ }
7
+
8
+ export class Router {
9
+ private routes: RouteSpec[] = [];
10
+ private compiledPatterns: Map<string, { regex: RegExp; paramNames: string[] }> = new Map();
11
+
12
+ constructor(routes: RouteSpec[] = []) {
13
+ this.setRoutes(routes);
14
+ }
15
+
16
+ setRoutes(routes: RouteSpec[]): void {
17
+ this.routes = routes;
18
+ this.compiledPatterns.clear();
19
+
20
+ for (const route of routes) {
21
+ this.compiledPatterns.set(route.id, this.compilePattern(route.pattern));
22
+ }
23
+ }
24
+
25
+ private compilePattern(pattern: string): { regex: RegExp; paramNames: string[] } {
26
+ const paramNames: string[] = [];
27
+
28
+ const regexStr = pattern
29
+ .replace(/\//g, "\\/")
30
+ .replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, paramName) => {
31
+ paramNames.push(paramName);
32
+ return "([^/]+)";
33
+ });
34
+
35
+ const regex = new RegExp(`^${regexStr}$`);
36
+ return { regex, paramNames };
37
+ }
38
+
39
+ match(pathname: string): MatchResult | null {
40
+ for (const route of this.routes) {
41
+ const compiled = this.compiledPatterns.get(route.id);
42
+ if (!compiled) continue;
43
+
44
+ const match = pathname.match(compiled.regex);
45
+ if (match) {
46
+ const params: Record<string, string> = {};
47
+ compiled.paramNames.forEach((name, index) => {
48
+ params[name] = match[index + 1];
49
+ });
50
+
51
+ return { route, params };
52
+ }
53
+ }
54
+
55
+ return null;
56
+ }
57
+
58
+ getRoutes(): RouteSpec[] {
59
+ return [...this.routes];
60
+ }
61
+ }
62
+
63
+ export function createRouter(routes: RouteSpec[] = []): Router {
64
+ return new Router(routes);
65
+ }
@@ -0,0 +1,139 @@
1
+ import type { Server } from "bun";
2
+ import type { RoutesManifest } from "../spec/schema";
3
+ import { Router } from "./router";
4
+ import { renderSSR } from "./ssr";
5
+ import React from "react";
6
+
7
+ export interface ServerOptions {
8
+ port?: number;
9
+ hostname?: string;
10
+ }
11
+
12
+ export interface ManduServer {
13
+ server: Server;
14
+ router: Router;
15
+ stop: () => void;
16
+ }
17
+
18
+ export type ApiHandler = (req: Request, params: Record<string, string>) => Response | Promise<Response>;
19
+ export type PageLoader = () => Promise<{ default: React.ComponentType<{ params: Record<string, string> }> }>;
20
+
21
+ export interface AppContext {
22
+ routeId: string;
23
+ url: string;
24
+ params: Record<string, string>;
25
+ }
26
+
27
+ type RouteComponent = (props: { params: Record<string, string> }) => React.ReactElement;
28
+ type CreateAppFn = (context: AppContext) => React.ReactElement;
29
+
30
+ // Registry
31
+ const apiHandlers: Map<string, ApiHandler> = new Map();
32
+ const pageLoaders: Map<string, PageLoader> = new Map();
33
+ const routeComponents: Map<string, RouteComponent> = new Map();
34
+ let createAppFn: CreateAppFn | null = null;
35
+
36
+ export function registerApiHandler(routeId: string, handler: ApiHandler): void {
37
+ apiHandlers.set(routeId, handler);
38
+ }
39
+
40
+ export function registerPageLoader(routeId: string, loader: PageLoader): void {
41
+ pageLoaders.set(routeId, loader);
42
+ }
43
+
44
+ export function registerRouteComponent(routeId: string, component: RouteComponent): void {
45
+ routeComponents.set(routeId, component);
46
+ }
47
+
48
+ export function setCreateApp(fn: CreateAppFn): void {
49
+ createAppFn = fn;
50
+ }
51
+
52
+ // Default createApp implementation
53
+ function defaultCreateApp(context: AppContext): React.ReactElement {
54
+ const Component = routeComponents.get(context.routeId);
55
+
56
+ if (!Component) {
57
+ return React.createElement("div", null,
58
+ React.createElement("h1", null, "404 - Route Not Found"),
59
+ React.createElement("p", null, `Route ID: ${context.routeId}`)
60
+ );
61
+ }
62
+
63
+ return React.createElement(Component, { params: context.params });
64
+ }
65
+
66
+ async function handleRequest(req: Request, router: Router): Promise<Response> {
67
+ const url = new URL(req.url);
68
+ const pathname = url.pathname;
69
+
70
+ const match = router.match(pathname);
71
+
72
+ if (!match) {
73
+ return new Response("Not Found", { status: 404 });
74
+ }
75
+
76
+ const { route, params } = match;
77
+
78
+ if (route.kind === "api") {
79
+ const handler = apiHandlers.get(route.id);
80
+ if (!handler) {
81
+ return Response.json({ error: "Handler not found" }, { status: 500 });
82
+ }
83
+ return handler(req, params);
84
+ }
85
+
86
+ if (route.kind === "page") {
87
+ const loader = pageLoaders.get(route.id);
88
+ if (loader) {
89
+ try {
90
+ const module = await loader();
91
+ registerRouteComponent(route.id, module.default);
92
+ } catch (error) {
93
+ console.error(`Failed to load page module for ${route.id}:`, error);
94
+ return new Response("Internal Server Error", { status: 500 });
95
+ }
96
+ }
97
+
98
+ const appCreator = createAppFn || defaultCreateApp;
99
+ const app = appCreator({
100
+ routeId: route.id,
101
+ url: req.url,
102
+ params,
103
+ });
104
+
105
+ return renderSSR(app, { title: `${route.id} - Mandu` });
106
+ }
107
+
108
+ return new Response("Unknown route kind", { status: 500 });
109
+ }
110
+
111
+ export function startServer(manifest: RoutesManifest, options: ServerOptions = {}): ManduServer {
112
+ const { port = 3000, hostname = "localhost" } = options;
113
+
114
+ const router = new Router(manifest.routes);
115
+
116
+ const server = Bun.serve({
117
+ port,
118
+ hostname,
119
+ fetch: (req) => handleRequest(req, router),
120
+ });
121
+
122
+ console.log(`🥟 Mandu server running at http://${hostname}:${port}`);
123
+
124
+ return {
125
+ server,
126
+ router,
127
+ stop: () => server.stop(),
128
+ };
129
+ }
130
+
131
+ // Clear registries (useful for testing)
132
+ export function clearRegistry(): void {
133
+ apiHandlers.clear();
134
+ pageLoaders.clear();
135
+ routeComponents.clear();
136
+ createAppFn = null;
137
+ }
138
+
139
+ export { apiHandlers, pageLoaders, routeComponents };
@@ -0,0 +1,38 @@
1
+ import { renderToString } from "react-dom/server";
2
+ import type { ReactElement } from "react";
3
+
4
+ export interface SSROptions {
5
+ title?: string;
6
+ lang?: string;
7
+ }
8
+
9
+ export function renderToHTML(element: ReactElement, options: SSROptions = {}): string {
10
+ const { title = "Mandu App", lang = "ko" } = options;
11
+ const content = renderToString(element);
12
+
13
+ return `<!doctype html>
14
+ <html lang="${lang}">
15
+ <head>
16
+ <meta charset="UTF-8">
17
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
18
+ <title>${title}</title>
19
+ </head>
20
+ <body>
21
+ <div id="root">${content}</div>
22
+ </body>
23
+ </html>`;
24
+ }
25
+
26
+ export function createHTMLResponse(html: string, status: number = 200): Response {
27
+ return new Response(html, {
28
+ status,
29
+ headers: {
30
+ "Content-Type": "text/html; charset=utf-8",
31
+ },
32
+ });
33
+ }
34
+
35
+ export function renderSSR(element: ReactElement, options: SSROptions = {}): Response {
36
+ const html = renderToHTML(element, options);
37
+ return createHTMLResponse(html);
38
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./schema";
2
+ export * from "./load";
3
+ export * from "./lock";
@@ -0,0 +1,76 @@
1
+ import { RoutesManifest, type RoutesManifest as RoutesManifestType } from "./schema";
2
+ import { ZodError } from "zod";
3
+
4
+ export interface LoadResult {
5
+ success: boolean;
6
+ data?: RoutesManifestType;
7
+ errors?: string[];
8
+ }
9
+
10
+ export function formatZodError(error: ZodError): string[] {
11
+ return error.errors.map((e) => {
12
+ const path = e.path.length > 0 ? `[${e.path.join(".")}] ` : "";
13
+ return `${path}${e.message}`;
14
+ });
15
+ }
16
+
17
+ export async function loadManifest(filePath: string): Promise<LoadResult> {
18
+ try {
19
+ const file = Bun.file(filePath);
20
+ const exists = await file.exists();
21
+
22
+ if (!exists) {
23
+ return {
24
+ success: false,
25
+ errors: [`파일을 찾을 수 없습니다: ${filePath}`],
26
+ };
27
+ }
28
+
29
+ const content = await file.text();
30
+ let json: unknown;
31
+
32
+ try {
33
+ json = JSON.parse(content);
34
+ } catch {
35
+ return {
36
+ success: false,
37
+ errors: ["JSON 파싱 실패: 올바른 JSON 형식이 아닙니다"],
38
+ };
39
+ }
40
+
41
+ const result = RoutesManifest.safeParse(json);
42
+
43
+ if (!result.success) {
44
+ return {
45
+ success: false,
46
+ errors: formatZodError(result.error),
47
+ };
48
+ }
49
+
50
+ return {
51
+ success: true,
52
+ data: result.data,
53
+ };
54
+ } catch (error) {
55
+ return {
56
+ success: false,
57
+ errors: [`예상치 못한 오류: ${error instanceof Error ? error.message : String(error)}`],
58
+ };
59
+ }
60
+ }
61
+
62
+ export function validateManifest(data: unknown): LoadResult {
63
+ const result = RoutesManifest.safeParse(data);
64
+
65
+ if (!result.success) {
66
+ return {
67
+ success: false,
68
+ errors: formatZodError(result.error),
69
+ };
70
+ }
71
+
72
+ return {
73
+ success: true,
74
+ data: result.data,
75
+ };
76
+ }
@@ -0,0 +1,56 @@
1
+ import { createHash } from "crypto";
2
+ import type { RoutesManifest } from "./schema";
3
+
4
+ export interface SpecLock {
5
+ routesHash: string;
6
+ updatedAt: string;
7
+ }
8
+
9
+ export function computeHash(manifest: RoutesManifest): string {
10
+ const content = JSON.stringify(manifest, null, 2);
11
+ return createHash("sha256").update(content).digest("hex");
12
+ }
13
+
14
+ export async function readLock(lockPath: string): Promise<SpecLock | null> {
15
+ try {
16
+ const file = Bun.file(lockPath);
17
+ const exists = await file.exists();
18
+
19
+ if (!exists) {
20
+ return null;
21
+ }
22
+
23
+ const content = await file.text();
24
+ return JSON.parse(content) as SpecLock;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ export async function writeLock(lockPath: string, manifest: RoutesManifest): Promise<SpecLock> {
31
+ const lock: SpecLock = {
32
+ routesHash: computeHash(manifest),
33
+ updatedAt: new Date().toISOString(),
34
+ };
35
+
36
+ await Bun.write(lockPath, JSON.stringify(lock, null, 2));
37
+ return lock;
38
+ }
39
+
40
+ export async function verifyLock(
41
+ lockPath: string,
42
+ manifest: RoutesManifest
43
+ ): Promise<{ valid: boolean; currentHash: string; lockHash: string | null }> {
44
+ const currentHash = computeHash(manifest);
45
+ const lock = await readLock(lockPath);
46
+
47
+ if (!lock) {
48
+ return { valid: false, currentHash, lockHash: null };
49
+ }
50
+
51
+ return {
52
+ valid: lock.routesHash === currentHash,
53
+ currentHash,
54
+ lockHash: lock.routesHash,
55
+ };
56
+ }
@@ -0,0 +1,57 @@
1
+ import { z } from "zod";
2
+
3
+ export const RouteKind = z.enum(["page", "api"]);
4
+ export type RouteKind = z.infer<typeof RouteKind>;
5
+
6
+ export const RouteSpec = z
7
+ .object({
8
+ id: z.string().min(1, "id는 필수입니다"),
9
+ pattern: z.string().startsWith("/", "pattern은 /로 시작해야 합니다"),
10
+ kind: RouteKind,
11
+ module: z.string().min(1, "module 경로는 필수입니다"),
12
+ componentModule: z.string().optional(),
13
+ })
14
+ .refine(
15
+ (route) => {
16
+ if (route.kind === "page" && !route.componentModule) {
17
+ return false;
18
+ }
19
+ return true;
20
+ },
21
+ {
22
+ message: "kind가 'page'인 경우 componentModule은 필수입니다",
23
+ path: ["componentModule"],
24
+ }
25
+ );
26
+
27
+ export type RouteSpec = z.infer<typeof RouteSpec>;
28
+
29
+ export const RoutesManifest = z
30
+ .object({
31
+ version: z.number().int().positive(),
32
+ routes: z.array(RouteSpec),
33
+ })
34
+ .refine(
35
+ (manifest) => {
36
+ const ids = manifest.routes.map((r) => r.id);
37
+ const uniqueIds = new Set(ids);
38
+ return ids.length === uniqueIds.size;
39
+ },
40
+ {
41
+ message: "route id는 중복될 수 없습니다",
42
+ path: ["routes"],
43
+ }
44
+ )
45
+ .refine(
46
+ (manifest) => {
47
+ const patterns = manifest.routes.map((r) => r.pattern);
48
+ const uniquePatterns = new Set(patterns);
49
+ return patterns.length === uniquePatterns.size;
50
+ },
51
+ {
52
+ message: "route pattern은 중복될 수 없습니다",
53
+ path: ["routes"],
54
+ }
55
+ );
56
+
57
+ export type RoutesManifest = z.infer<typeof RoutesManifest>;