@mandujs/core 0.4.0 → 0.4.2

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,41 +1,41 @@
1
- {
2
- "name": "@mandujs/core",
3
- "version": "0.4.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
- "./client": "./src/client/index.ts",
11
- "./*": "./src/*"
12
- },
13
- "files": [
14
- "src/**/*"
15
- ],
16
- "keywords": [
17
- "mandu",
18
- "framework",
19
- "agent",
20
- "ai",
21
- "code-generation"
22
- ],
23
- "repository": {
24
- "type": "git",
25
- "url": "https://github.com/konamgil/mandu.git",
26
- "directory": "packages/core"
27
- },
28
- "author": "konamgil",
29
- "license": "MIT",
30
- "publishConfig": {
31
- "access": "public"
32
- },
33
- "engines": {
34
- "bun": ">=1.0.0"
35
- },
36
- "peerDependencies": {
37
- "react": ">=18.0.0",
38
- "react-dom": ">=18.0.0",
39
- "zod": ">=3.0.0"
40
- }
41
- }
1
+ {
2
+ "name": "@mandujs/core",
3
+ "version": "0.4.2",
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
+ "./client": "./src/client/index.ts",
11
+ "./*": "./src/*"
12
+ },
13
+ "files": [
14
+ "src/**/*"
15
+ ],
16
+ "keywords": [
17
+ "mandu",
18
+ "framework",
19
+ "agent",
20
+ "ai",
21
+ "code-generation"
22
+ ],
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/konamgil/mandu.git",
26
+ "directory": "packages/core"
27
+ },
28
+ "author": "konamgil",
29
+ "license": "MIT",
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "engines": {
34
+ "bun": ">=1.0.0"
35
+ },
36
+ "peerDependencies": {
37
+ "react": ">=18.0.0",
38
+ "react-dom": ">=18.0.0",
39
+ "zod": ">=3.0.0"
40
+ }
41
+ }
@@ -220,12 +220,14 @@ export * as ReactDOMClient from 'react-dom/client';
220
220
  * Island 엔트리 래퍼 생성
221
221
  */
222
222
  function generateIslandEntry(routeId: string, clientModulePath: string): string {
223
+ // Windows 경로의 백슬래시를 슬래시로 변환 (JS escape 문제 방지)
224
+ const normalizedPath = clientModulePath.replace(/\\/g, "/");
223
225
  return `
224
226
  /**
225
227
  * Mandu Island Entry: ${routeId} (Generated)
226
228
  */
227
229
 
228
- import island from "${clientModulePath}";
230
+ import island from "${normalizedPath}";
229
231
  import { registerIsland } from "./_runtime.js";
230
232
 
231
233
  registerIsland("${routeId}", () => island);
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Mandu Dev Bundler 🔥
3
+ * 개발 모드 번들링 + HMR (Hot Module Replacement)
4
+ */
5
+
6
+ import type { RoutesManifest, RouteSpec } from "../spec/schema";
7
+ import { buildClientBundles } from "./build";
8
+ import type { BundleResult } from "./types";
9
+ import path from "path";
10
+ import fs from "fs";
11
+
12
+ export interface DevBundlerOptions {
13
+ /** 프로젝트 루트 */
14
+ rootDir: string;
15
+ /** 라우트 매니페스트 */
16
+ manifest: RoutesManifest;
17
+ /** 재빌드 콜백 */
18
+ onRebuild?: (result: RebuildResult) => void;
19
+ /** 에러 콜백 */
20
+ onError?: (error: Error, routeId?: string) => void;
21
+ }
22
+
23
+ export interface RebuildResult {
24
+ routeId: string;
25
+ success: boolean;
26
+ buildTime: number;
27
+ error?: string;
28
+ }
29
+
30
+ export interface DevBundler {
31
+ /** 초기 빌드 결과 */
32
+ initialBuild: BundleResult;
33
+ /** 파일 감시 중지 */
34
+ close: () => void;
35
+ }
36
+
37
+ /**
38
+ * 개발 모드 번들러 시작
39
+ * 파일 변경 감시 및 자동 재빌드
40
+ */
41
+ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBundler> {
42
+ const { rootDir, manifest, onRebuild, onError } = options;
43
+ const slotsDir = path.join(rootDir, "spec", "slots");
44
+
45
+ // 초기 빌드
46
+ console.log("🔨 Initial client bundle build...");
47
+ const initialBuild = await buildClientBundles(manifest, rootDir, {
48
+ minify: false,
49
+ sourcemap: true,
50
+ });
51
+
52
+ if (initialBuild.success) {
53
+ console.log(`✅ Built ${initialBuild.stats.bundleCount} islands`);
54
+ } else {
55
+ console.error("⚠️ Initial build had errors:", initialBuild.errors);
56
+ }
57
+
58
+ // 파일 감시 설정
59
+ let watcher: fs.FSWatcher | null = null;
60
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
61
+
62
+ try {
63
+ await fs.promises.access(slotsDir);
64
+
65
+ watcher = fs.watch(slotsDir, { recursive: true }, async (event, filename) => {
66
+ if (!filename) return;
67
+
68
+ // .client.ts 파일만 감시
69
+ if (!filename.endsWith(".client.ts")) return;
70
+
71
+ // Debounce - 연속 변경 무시
72
+ if (debounceTimer) {
73
+ clearTimeout(debounceTimer);
74
+ }
75
+
76
+ debounceTimer = setTimeout(async () => {
77
+ const routeId = filename.replace(".client.ts", "").replace(/\\/g, "/").split("/").pop();
78
+ if (!routeId) return;
79
+
80
+ const route = manifest.routes.find((r) => r.id === routeId);
81
+ if (!route || !route.clientModule) return;
82
+
83
+ console.log(`\n🔄 Rebuilding: ${routeId}`);
84
+ const startTime = performance.now();
85
+
86
+ try {
87
+ const result = await buildClientBundles(manifest, rootDir, {
88
+ minify: false,
89
+ sourcemap: true,
90
+ });
91
+
92
+ const buildTime = performance.now() - startTime;
93
+
94
+ if (result.success) {
95
+ console.log(`✅ Rebuilt in ${buildTime.toFixed(0)}ms`);
96
+ onRebuild?.({
97
+ routeId,
98
+ success: true,
99
+ buildTime,
100
+ });
101
+ } else {
102
+ console.error(`❌ Build failed:`, result.errors);
103
+ onRebuild?.({
104
+ routeId,
105
+ success: false,
106
+ buildTime,
107
+ error: result.errors.join(", "),
108
+ });
109
+ }
110
+ } catch (error) {
111
+ const err = error instanceof Error ? error : new Error(String(error));
112
+ console.error(`❌ Build error:`, err.message);
113
+ onError?.(err, routeId);
114
+ }
115
+ }, 100); // 100ms debounce
116
+ });
117
+
118
+ console.log("👀 Watching for client slot changes...");
119
+ } catch {
120
+ console.warn(`⚠️ Slots directory not found: ${slotsDir}`);
121
+ }
122
+
123
+ return {
124
+ initialBuild,
125
+ close: () => {
126
+ if (debounceTimer) {
127
+ clearTimeout(debounceTimer);
128
+ }
129
+ if (watcher) {
130
+ watcher.close();
131
+ }
132
+ },
133
+ };
134
+ }
135
+
136
+ /**
137
+ * HMR WebSocket 서버
138
+ */
139
+ export interface HMRServer {
140
+ /** 연결된 클라이언트 수 */
141
+ clientCount: number;
142
+ /** 모든 클라이언트에게 메시지 전송 */
143
+ broadcast: (message: HMRMessage) => void;
144
+ /** 서버 중지 */
145
+ close: () => void;
146
+ }
147
+
148
+ export interface HMRMessage {
149
+ type: "connected" | "reload" | "island-update" | "error" | "ping";
150
+ data?: {
151
+ routeId?: string;
152
+ message?: string;
153
+ timestamp?: number;
154
+ };
155
+ }
156
+
157
+ /**
158
+ * HMR WebSocket 서버 생성
159
+ */
160
+ export function createHMRServer(port: number): HMRServer {
161
+ const clients = new Set<any>();
162
+ const hmrPort = port + 1;
163
+
164
+ const server = Bun.serve({
165
+ port: hmrPort,
166
+ fetch(req, server) {
167
+ // WebSocket 업그레이드
168
+ if (server.upgrade(req)) {
169
+ return;
170
+ }
171
+
172
+ // 일반 HTTP 요청은 상태 반환
173
+ return new Response(
174
+ JSON.stringify({
175
+ status: "ok",
176
+ clients: clients.size,
177
+ port: hmrPort,
178
+ }),
179
+ {
180
+ headers: { "Content-Type": "application/json" },
181
+ }
182
+ );
183
+ },
184
+ websocket: {
185
+ open(ws) {
186
+ clients.add(ws);
187
+ ws.send(
188
+ JSON.stringify({
189
+ type: "connected",
190
+ data: { timestamp: Date.now() },
191
+ })
192
+ );
193
+ },
194
+ close(ws) {
195
+ clients.delete(ws);
196
+ },
197
+ message(ws, message) {
198
+ // 클라이언트로부터의 ping 처리
199
+ try {
200
+ const data = JSON.parse(String(message));
201
+ if (data.type === "ping") {
202
+ ws.send(JSON.stringify({ type: "pong", data: { timestamp: Date.now() } }));
203
+ }
204
+ } catch {
205
+ // 무시
206
+ }
207
+ },
208
+ },
209
+ });
210
+
211
+ console.log(`🔥 HMR server running on ws://localhost:${hmrPort}`);
212
+
213
+ return {
214
+ get clientCount() {
215
+ return clients.size;
216
+ },
217
+ broadcast: (message: HMRMessage) => {
218
+ const payload = JSON.stringify(message);
219
+ for (const client of clients) {
220
+ try {
221
+ client.send(payload);
222
+ } catch {
223
+ clients.delete(client);
224
+ }
225
+ }
226
+ },
227
+ close: () => {
228
+ for (const client of clients) {
229
+ try {
230
+ client.close();
231
+ } catch {
232
+ // 무시
233
+ }
234
+ }
235
+ clients.clear();
236
+ server.stop();
237
+ },
238
+ };
239
+ }
240
+
241
+ /**
242
+ * HMR 클라이언트 스크립트 생성
243
+ * 브라우저에서 실행되어 HMR 서버와 연결
244
+ */
245
+ export function generateHMRClientScript(port: number): string {
246
+ const hmrPort = port + 1;
247
+
248
+ return `
249
+ (function() {
250
+ const HMR_PORT = ${hmrPort};
251
+ let ws = null;
252
+ let reconnectAttempts = 0;
253
+ const maxReconnectAttempts = 10;
254
+ const reconnectDelay = 1000;
255
+
256
+ function connect() {
257
+ try {
258
+ ws = new WebSocket('ws://localhost:' + HMR_PORT);
259
+
260
+ ws.onopen = function() {
261
+ console.log('[Mandu HMR] Connected');
262
+ reconnectAttempts = 0;
263
+ };
264
+
265
+ ws.onmessage = function(event) {
266
+ try {
267
+ const message = JSON.parse(event.data);
268
+ handleMessage(message);
269
+ } catch (e) {
270
+ console.error('[Mandu HMR] Invalid message:', e);
271
+ }
272
+ };
273
+
274
+ ws.onclose = function() {
275
+ console.log('[Mandu HMR] Disconnected');
276
+ scheduleReconnect();
277
+ };
278
+
279
+ ws.onerror = function(error) {
280
+ console.error('[Mandu HMR] Error:', error);
281
+ };
282
+ } catch (error) {
283
+ console.error('[Mandu HMR] Connection failed:', error);
284
+ scheduleReconnect();
285
+ }
286
+ }
287
+
288
+ function scheduleReconnect() {
289
+ if (reconnectAttempts < maxReconnectAttempts) {
290
+ reconnectAttempts++;
291
+ console.log('[Mandu HMR] Reconnecting... (' + reconnectAttempts + '/' + maxReconnectAttempts + ')');
292
+ setTimeout(connect, reconnectDelay * reconnectAttempts);
293
+ }
294
+ }
295
+
296
+ function handleMessage(message) {
297
+ switch (message.type) {
298
+ case 'connected':
299
+ console.log('[Mandu HMR] Ready');
300
+ break;
301
+
302
+ case 'reload':
303
+ console.log('[Mandu HMR] Full reload requested');
304
+ location.reload();
305
+ break;
306
+
307
+ case 'island-update':
308
+ const routeId = message.data?.routeId;
309
+ console.log('[Mandu HMR] Island updated:', routeId);
310
+
311
+ // 현재 페이지의 island인지 확인
312
+ const island = document.querySelector('[data-mandu-island="' + routeId + '"]');
313
+ if (island) {
314
+ console.log('[Mandu HMR] Reloading page for island update');
315
+ location.reload();
316
+ }
317
+ break;
318
+
319
+ case 'error':
320
+ console.error('[Mandu HMR] Build error:', message.data?.message);
321
+ showErrorOverlay(message.data?.message);
322
+ break;
323
+
324
+ case 'pong':
325
+ // 연결 확인
326
+ break;
327
+ }
328
+ }
329
+
330
+ function showErrorOverlay(message) {
331
+ // 기존 오버레이 제거
332
+ const existing = document.getElementById('mandu-hmr-error');
333
+ if (existing) existing.remove();
334
+
335
+ const overlay = document.createElement('div');
336
+ overlay.id = 'mandu-hmr-error';
337
+ overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.9);color:#ff6b6b;font-family:monospace;padding:40px;z-index:99999;overflow:auto;';
338
+ overlay.innerHTML = '<h2 style="color:#ff6b6b;margin:0 0 20px;">🔥 Build Error</h2><pre style="white-space:pre-wrap;word-break:break-all;">' + (message || 'Unknown error') + '</pre><button onclick="this.parentElement.remove()" style="position:fixed;top:20px;right:20px;background:#333;color:#fff;border:none;padding:10px 20px;cursor:pointer;">Close</button>';
339
+ document.body.appendChild(overlay);
340
+ }
341
+
342
+ // 페이지 로드 시 연결
343
+ if (document.readyState === 'loading') {
344
+ document.addEventListener('DOMContentLoaded', connect);
345
+ } else {
346
+ connect();
347
+ }
348
+
349
+ // 페이지 이탈 시 정리
350
+ window.addEventListener('beforeunload', function() {
351
+ if (ws) ws.close();
352
+ });
353
+
354
+ // Ping 전송 (연결 유지)
355
+ setInterval(function() {
356
+ if (ws && ws.readyState === WebSocket.OPEN) {
357
+ ws.send(JSON.stringify({ type: 'ping' }));
358
+ }
359
+ }, 30000);
360
+ })();
361
+ `;
362
+ }
@@ -5,3 +5,4 @@
5
5
 
6
6
  export * from "./types";
7
7
  export * from "./build";
8
+ export * from "./dev";
@@ -14,6 +14,10 @@ import {
14
14
  export interface ServerOptions {
15
15
  port?: number;
16
16
  hostname?: string;
17
+ /** 개발 모드 여부 */
18
+ isDev?: boolean;
19
+ /** HMR 포트 (개발 모드에서 사용) */
20
+ hmrPort?: number;
17
21
  }
18
22
 
19
23
  export interface ManduServer {
@@ -40,6 +44,9 @@ const pageLoaders: Map<string, PageLoader> = new Map();
40
44
  const routeComponents: Map<string, RouteComponent> = new Map();
41
45
  let createAppFn: CreateAppFn | null = null;
42
46
 
47
+ // Dev mode settings (module-level for handleRequest access)
48
+ let devModeSettings: { isDev: boolean; hmrPort?: number } = { isDev: false };
49
+
43
50
  export function registerApiHandler(routeId: string, handler: ApiHandler): void {
44
51
  apiHandlers.set(routeId, handler);
45
52
  }
@@ -126,7 +133,11 @@ async function handleRequest(req: Request, router: Router): Promise<Response> {
126
133
  params,
127
134
  });
128
135
 
129
- return renderSSR(app, { title: `${route.id} - Mandu` });
136
+ return renderSSR(app, {
137
+ title: `${route.id} - Mandu`,
138
+ isDev: devModeSettings.isDev,
139
+ hmrPort: devModeSettings.hmrPort,
140
+ });
130
141
  } catch (err) {
131
142
  const ssrError = createSSRErrorResponse(
132
143
  route.id,
@@ -159,7 +170,10 @@ async function handleRequest(req: Request, router: Router): Promise<Response> {
159
170
  }
160
171
 
161
172
  export function startServer(manifest: RoutesManifest, options: ServerOptions = {}): ManduServer {
162
- const { port = 3000, hostname = "localhost" } = options;
173
+ const { port = 3000, hostname = "localhost", isDev = false, hmrPort } = options;
174
+
175
+ // Dev mode settings 저장
176
+ devModeSettings = { isDev, hmrPort };
163
177
 
164
178
  const router = new Router(manifest.routes);
165
179
 
@@ -169,7 +183,14 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
169
183
  fetch: (req) => handleRequest(req, router),
170
184
  });
171
185
 
172
- console.log(`🥟 Mandu server running at http://${hostname}:${port}`);
186
+ if (isDev) {
187
+ console.log(`🥟 Mandu Dev Server running at http://${hostname}:${port}`);
188
+ if (hmrPort) {
189
+ console.log(`🔥 HMR enabled on port ${hmrPort + 1}`);
190
+ }
191
+ } else {
192
+ console.log(`🥟 Mandu server running at http://${hostname}:${port}`);
193
+ }
173
194
 
174
195
  return {
175
196
  server,
@@ -18,6 +18,10 @@ export interface SSROptions {
18
18
  headTags?: string;
19
19
  /** 추가 body 끝 태그 */
20
20
  bodyEndTags?: string;
21
+ /** 개발 모드 여부 */
22
+ isDev?: boolean;
23
+ /** HMR 포트 (개발 모드에서 사용) */
24
+ hmrPort?: number;
21
25
  }
22
26
 
23
27
  /**
@@ -86,6 +90,8 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
86
90
  routeId,
87
91
  headTags = "",
88
92
  bodyEndTags = "",
93
+ isDev = false,
94
+ hmrPort,
89
95
  } = options;
90
96
 
91
97
  let content = renderToString(element);
@@ -116,6 +122,12 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
116
122
  hydrationScripts = generateHydrationScripts(routeId, bundleManifest);
117
123
  }
118
124
 
125
+ // HMR 스크립트 (개발 모드)
126
+ let hmrScript = "";
127
+ if (isDev && hmrPort) {
128
+ hmrScript = generateHMRScript(hmrPort);
129
+ }
130
+
119
131
  return `<!doctype html>
120
132
  <html lang="${lang}">
121
133
  <head>
@@ -128,11 +140,56 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
128
140
  <div id="root">${content}</div>
129
141
  ${dataScript}
130
142
  ${hydrationScripts}
143
+ ${hmrScript}
131
144
  ${bodyEndTags}
132
145
  </body>
133
146
  </html>`;
134
147
  }
135
148
 
149
+ /**
150
+ * HMR 스크립트 생성
151
+ */
152
+ function generateHMRScript(port: number): string {
153
+ const hmrPort = port + 1;
154
+ return `<script>
155
+ (function() {
156
+ var ws = null;
157
+ var reconnectAttempts = 0;
158
+ var maxReconnectAttempts = 10;
159
+
160
+ function connect() {
161
+ try {
162
+ ws = new WebSocket('ws://localhost:${hmrPort}');
163
+ ws.onopen = function() {
164
+ console.log('[Mandu HMR] Connected');
165
+ reconnectAttempts = 0;
166
+ };
167
+ ws.onmessage = function(e) {
168
+ try {
169
+ var msg = JSON.parse(e.data);
170
+ if (msg.type === 'reload' || msg.type === 'island-update') {
171
+ console.log('[Mandu HMR] Reloading...');
172
+ location.reload();
173
+ } else if (msg.type === 'error') {
174
+ console.error('[Mandu HMR] Build error:', msg.data?.message);
175
+ }
176
+ } catch(err) {}
177
+ };
178
+ ws.onclose = function() {
179
+ if (reconnectAttempts < maxReconnectAttempts) {
180
+ reconnectAttempts++;
181
+ setTimeout(connect, 1000 * reconnectAttempts);
182
+ }
183
+ };
184
+ } catch(err) {
185
+ setTimeout(connect, 1000);
186
+ }
187
+ }
188
+ connect();
189
+ })();
190
+ </script>`;
191
+ }
192
+
136
193
  export function createHTMLResponse(html: string, status: number = 200): Response {
137
194
  return new Response(html, {
138
195
  status,