clawfire 0.1.0 → 0.2.1

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/dist/dev.cjs CHANGED
@@ -38,8 +38,8 @@ module.exports = __toCommonJS(dev_exports);
38
38
 
39
39
  // src/dev/dev-server.ts
40
40
  var import_node_http = __toESM(require("http"), 1);
41
- var import_node_path = require("path");
42
- var import_node_fs = require("fs");
41
+ var import_node_path2 = require("path");
42
+ var import_node_fs2 = require("fs");
43
43
  var import_node_url = require("url");
44
44
 
45
45
  // src/core/schema.ts
@@ -946,6 +946,55 @@ var FileWatcher = class extends import_events.EventEmitter {
946
946
  this.watchers.push(watcher);
947
947
  return this;
948
948
  }
949
+ /**
950
+ * 프론트엔드 디렉터리 감시 (확장자별 이벤트 타입 자동 결정)
951
+ */
952
+ watchDirFrontend(dir) {
953
+ if (!(0, import_fs2.existsSync)(dir)) return this;
954
+ try {
955
+ const watcher = (0, import_fs2.watch)(dir, { recursive: true }, (event, filename) => {
956
+ if (!filename) return;
957
+ const filePath = (0, import_path2.join)(dir, filename);
958
+ if (!this.isWatchedFile(filePath)) return;
959
+ const ext = (0, import_path2.extname)(filePath);
960
+ const eventType = ext === ".css" ? "css-change" : "frontend-change";
961
+ this.emitDebounced(filePath, eventType);
962
+ });
963
+ watcher.on("error", (err) => {
964
+ if (err.code === "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM") {
965
+ this.watchDirFrontendRecursiveManual(dir);
966
+ }
967
+ });
968
+ this.watchers.push(watcher);
969
+ } catch {
970
+ this.watchDirFrontendRecursiveManual(dir);
971
+ }
972
+ return this;
973
+ }
974
+ /**
975
+ * Linux fallback: 프론트엔드 디렉터리 수동 재귀 감시
976
+ */
977
+ watchDirFrontendRecursiveManual(dir) {
978
+ if (!(0, import_fs2.existsSync)(dir)) return;
979
+ const watcher = (0, import_fs2.watch)(dir, (event, filename) => {
980
+ if (!filename) return;
981
+ const filePath = (0, import_path2.join)(dir, filename);
982
+ if (!this.isWatchedFile(filePath)) return;
983
+ const ext = (0, import_path2.extname)(filePath);
984
+ const eventType = ext === ".css" ? "css-change" : "frontend-change";
985
+ this.emitDebounced(filePath, eventType);
986
+ });
987
+ this.watchers.push(watcher);
988
+ try {
989
+ const entries = (0, import_fs2.readdirSync)(dir, { withFileTypes: true });
990
+ for (const entry of entries) {
991
+ if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") {
992
+ this.watchDirFrontendRecursiveManual((0, import_path2.join)(dir, entry.name));
993
+ }
994
+ }
995
+ } catch {
996
+ }
997
+ }
949
998
  /**
950
999
  * Linux fallback: 디렉터리 수동 재귀 감시
951
1000
  */
@@ -973,7 +1022,7 @@ var FileWatcher = class extends import_events.EventEmitter {
973
1022
  */
974
1023
  isWatchedFile(filePath) {
975
1024
  const ext = (0, import_path2.extname)(filePath);
976
- return [".ts", ".tsx", ".js", ".jsx", ".json"].includes(ext);
1025
+ return [".ts", ".tsx", ".js", ".jsx", ".json", ".html", ".css", ".svg", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".webp", ".woff", ".woff2"].includes(ext);
977
1026
  }
978
1027
  /**
979
1028
  * 디바운스 이벤트 발생
@@ -1007,78 +1056,486 @@ var FileWatcher = class extends import_events.EventEmitter {
1007
1056
  }
1008
1057
  };
1009
1058
 
1059
+ // src/dev/page-compiler.ts
1060
+ var import_node_path = require("path");
1061
+ var import_node_fs = require("fs");
1062
+ var MAX_COMPONENT_DEPTH = 10;
1063
+ var META_REGEX = /<!--\s*@(\w+):\s*(.+?)\s*-->/g;
1064
+ var COMPONENT_REGEX = /<c-([a-z][a-z0-9-]*)\s*\/>/g;
1065
+ var SLOT_MARKER = "<slot />";
1066
+ var PageCompiler = class {
1067
+ constructor(projectDir) {
1068
+ this.projectDir = projectDir;
1069
+ this.pagesDir = (0, import_node_path.resolve)(projectDir, "app/pages");
1070
+ this.componentsDir = (0, import_node_path.resolve)(projectDir, "app/components");
1071
+ }
1072
+ pagesDir;
1073
+ componentsDir;
1074
+ /**
1075
+ * Check if the page system is active (app/pages/ exists)
1076
+ */
1077
+ isActive() {
1078
+ return (0, import_node_fs.existsSync)(this.pagesDir);
1079
+ }
1080
+ /**
1081
+ * Resolve a URL pathname to a page file path.
1082
+ * Returns null if no matching page exists.
1083
+ *
1084
+ * / → app/pages/index.html
1085
+ * /about → app/pages/about.html OR app/pages/about/index.html
1086
+ * /todos → app/pages/todos/index.html OR app/pages/todos.html
1087
+ */
1088
+ resolve(pathname) {
1089
+ if (!this.isActive()) return null;
1090
+ const clean = pathname === "/" ? "" : pathname.replace(/\/+$/, "");
1091
+ const segments = clean ? clean.split("/").filter(Boolean) : [];
1092
+ const candidates = [];
1093
+ if (segments.length === 0) {
1094
+ candidates.push((0, import_node_path.join)(this.pagesDir, "index.html"));
1095
+ } else {
1096
+ const pathPart = segments.join("/");
1097
+ candidates.push((0, import_node_path.join)(this.pagesDir, `${pathPart}.html`));
1098
+ candidates.push((0, import_node_path.join)(this.pagesDir, pathPart, "index.html"));
1099
+ }
1100
+ for (const candidate of candidates) {
1101
+ if (!candidate.startsWith(this.pagesDir)) continue;
1102
+ const name = (0, import_node_path.basename)(candidate);
1103
+ if (name.startsWith("_")) continue;
1104
+ if ((0, import_node_fs.existsSync)(candidate)) return candidate;
1105
+ }
1106
+ return null;
1107
+ }
1108
+ /**
1109
+ * Resolve the 404 page file path.
1110
+ */
1111
+ resolve404() {
1112
+ const path404 = (0, import_node_path.join)(this.pagesDir, "_404.html");
1113
+ return (0, import_node_fs.existsSync)(path404) ? path404 : null;
1114
+ }
1115
+ /**
1116
+ * Compile a full page with layouts and components.
1117
+ */
1118
+ compile(pagePath) {
1119
+ let pageHtml = (0, import_node_fs.readFileSync)(pagePath, "utf-8");
1120
+ const meta = this.extractMeta(pageHtml);
1121
+ pageHtml = this.stripMeta(pageHtml);
1122
+ pageHtml = this.processComponents(pageHtml);
1123
+ pageHtml = this.wrapPageContent(pageHtml);
1124
+ const layouts = this.collectLayouts(pagePath);
1125
+ let html = pageHtml;
1126
+ for (const layoutPath of layouts) {
1127
+ let layoutHtml = (0, import_node_fs.readFileSync)(layoutPath, "utf-8");
1128
+ layoutHtml = this.processComponents(layoutHtml);
1129
+ html = layoutHtml.replace(SLOT_MARKER, html);
1130
+ }
1131
+ if (meta.title) {
1132
+ html = this.setTitle(html, meta.title);
1133
+ }
1134
+ return {
1135
+ html,
1136
+ meta,
1137
+ filePath: pagePath,
1138
+ layouts
1139
+ };
1140
+ }
1141
+ /**
1142
+ * Compile a partial page for SPA navigation.
1143
+ * Returns only the page content (no layout) plus metadata.
1144
+ */
1145
+ compilePartial(pagePath, pathname) {
1146
+ let pageHtml = (0, import_node_fs.readFileSync)(pagePath, "utf-8");
1147
+ const meta = this.extractMeta(pageHtml);
1148
+ pageHtml = this.stripMeta(pageHtml);
1149
+ pageHtml = this.processComponents(pageHtml);
1150
+ return {
1151
+ html: pageHtml,
1152
+ meta,
1153
+ path: pathname
1154
+ };
1155
+ }
1156
+ // ─── Internal Methods ────────────────────────────────────────────
1157
+ /**
1158
+ * Extract <!-- @key: value --> metadata from HTML.
1159
+ */
1160
+ extractMeta(html) {
1161
+ const meta = {};
1162
+ let match;
1163
+ const regex = new RegExp(META_REGEX.source, META_REGEX.flags);
1164
+ while ((match = regex.exec(html)) !== null) {
1165
+ meta[match[1]] = match[2];
1166
+ }
1167
+ return meta;
1168
+ }
1169
+ /**
1170
+ * Strip metadata comments from HTML.
1171
+ */
1172
+ stripMeta(html) {
1173
+ return html.replace(META_REGEX, "").trim();
1174
+ }
1175
+ /**
1176
+ * Process <c-name /> component tags by replacing them with component file contents.
1177
+ * Supports nested components up to MAX_COMPONENT_DEPTH.
1178
+ */
1179
+ processComponents(html, depth = 0) {
1180
+ if (depth >= MAX_COMPONENT_DEPTH) return html;
1181
+ if (!COMPONENT_REGEX.test(html)) return html;
1182
+ const regex = new RegExp(COMPONENT_REGEX.source, COMPONENT_REGEX.flags);
1183
+ const result = html.replace(regex, (_match, name) => {
1184
+ const componentPath = (0, import_node_path.join)(this.componentsDir, `${name}.html`);
1185
+ if (!(0, import_node_fs.existsSync)(componentPath)) {
1186
+ return `<!-- component "${name}" not found -->`;
1187
+ }
1188
+ const content = (0, import_node_fs.readFileSync)(componentPath, "utf-8").trim();
1189
+ return this.processComponents(content, depth + 1);
1190
+ });
1191
+ return result;
1192
+ }
1193
+ /**
1194
+ * Wrap page content in the router target div.
1195
+ */
1196
+ wrapPageContent(html) {
1197
+ return `<div id="clawfire-page">
1198
+ ${html}
1199
+ </div>`;
1200
+ }
1201
+ /**
1202
+ * Collect layout files from page directory up to pages root.
1203
+ * Returns innermost layout first (closest to page).
1204
+ */
1205
+ collectLayouts(pagePath) {
1206
+ const layouts = [];
1207
+ let dir = (0, import_node_path.dirname)(pagePath);
1208
+ while (dir.startsWith(this.pagesDir)) {
1209
+ const layoutPath = (0, import_node_path.join)(dir, "_layout.html");
1210
+ if ((0, import_node_fs.existsSync)(layoutPath)) {
1211
+ layouts.push(layoutPath);
1212
+ }
1213
+ if (dir === this.pagesDir) break;
1214
+ dir = (0, import_node_path.dirname)(dir);
1215
+ }
1216
+ return layouts;
1217
+ }
1218
+ /**
1219
+ * Set or update <title> tag in HTML.
1220
+ */
1221
+ setTitle(html, title) {
1222
+ if (html.includes("<title>")) {
1223
+ return html.replace(/<title>[^<]*<\/title>/, `<title>${title}</title>`);
1224
+ }
1225
+ if (html.includes("</head>")) {
1226
+ return html.replace("</head>", ` <title>${title}</title>
1227
+ </head>`);
1228
+ }
1229
+ return html;
1230
+ }
1231
+ };
1232
+
1010
1233
  // src/dev/dev-server.ts
1234
+ var MIME_TYPES = {
1235
+ html: "text/html; charset=utf-8",
1236
+ css: "text/css; charset=utf-8",
1237
+ js: "application/javascript; charset=utf-8",
1238
+ mjs: "application/javascript; charset=utf-8",
1239
+ json: "application/json; charset=utf-8",
1240
+ png: "image/png",
1241
+ jpg: "image/jpeg",
1242
+ jpeg: "image/jpeg",
1243
+ gif: "image/gif",
1244
+ svg: "image/svg+xml",
1245
+ ico: "image/x-icon",
1246
+ webp: "image/webp",
1247
+ woff: "font/woff",
1248
+ woff2: "font/woff2",
1249
+ ttf: "font/ttf",
1250
+ eot: "application/vnd.ms-fontobject",
1251
+ mp4: "video/mp4",
1252
+ webm: "video/webm",
1253
+ mp3: "audio/mpeg",
1254
+ wav: "audio/wav",
1255
+ pdf: "application/pdf",
1256
+ txt: "text/plain; charset=utf-8",
1257
+ xml: "application/xml; charset=utf-8"
1258
+ };
1259
+ var STATIC_EXTENSIONS = /* @__PURE__ */ new Set([
1260
+ ".css",
1261
+ ".js",
1262
+ ".mjs",
1263
+ ".json",
1264
+ ".png",
1265
+ ".jpg",
1266
+ ".jpeg",
1267
+ ".gif",
1268
+ ".svg",
1269
+ ".ico",
1270
+ ".webp",
1271
+ ".woff",
1272
+ ".woff2",
1273
+ ".ttf",
1274
+ ".eot",
1275
+ ".mp4",
1276
+ ".webm",
1277
+ ".mp3",
1278
+ ".wav",
1279
+ ".pdf",
1280
+ ".txt",
1281
+ ".xml",
1282
+ ".map"
1283
+ ]);
1284
+ function generateHmrScript(port) {
1285
+ return `
1286
+ <script data-clawfire-hmr>
1287
+ (function() {
1288
+ var dot, status;
1289
+ function createBanner() {
1290
+ var banner = document.createElement('div');
1291
+ banner.id = 'clawfire-dev-banner';
1292
+ banner.style.cssText = 'position:fixed;bottom:0;left:0;right:0;padding:6px 16px;background:#1a1a2e;color:#f97316;font-size:12px;font-family:monospace;z-index:99999;display:flex;align-items:center;gap:8px;border-top:1px solid #2a2a2a;';
1293
+ banner.innerHTML = '<span style="width:8px;height:8px;border-radius:50%;background:#22c55e;display:inline-block;" id="clawfire-dot"></span><span>Clawfire Dev</span><span style="color:#666;margin-left:auto;" id="clawfire-status">Connected</span>';
1294
+ document.body.appendChild(banner);
1295
+ dot = document.getElementById('clawfire-dot');
1296
+ status = document.getElementById('clawfire-status');
1297
+ }
1298
+
1299
+ function refreshCss() {
1300
+ var links = document.querySelectorAll('link[rel="stylesheet"]');
1301
+ for (var i = 0; i < links.length; i++) {
1302
+ var href = links[i].getAttribute('href');
1303
+ if (href) {
1304
+ var url = new URL(href, location.href);
1305
+ url.searchParams.set('_hmr', Date.now().toString());
1306
+ links[i].setAttribute('href', url.toString());
1307
+ }
1308
+ }
1309
+ var styles = document.querySelectorAll('style[data-href]');
1310
+ for (var j = 0; j < styles.length; j++) {
1311
+ var dataHref = styles[j].getAttribute('data-href');
1312
+ if (dataHref) {
1313
+ fetch(dataHref + '?_hmr=' + Date.now())
1314
+ .then(function(r) { return r.text(); })
1315
+ .then(function(css) { styles[j].textContent = css; })
1316
+ .catch(function() {});
1317
+ }
1318
+ }
1319
+ if (status) status.textContent = 'CSS updated';
1320
+ setTimeout(function() { if (status) status.textContent = 'Connected'; }, 1500);
1321
+ }
1322
+
1323
+ var reconnectTimer;
1324
+ function connect() {
1325
+ var es = new EventSource('http://localhost:${port}/__dev/events');
1326
+ es.onopen = function() {
1327
+ if (!dot) createBanner();
1328
+ dot.style.background = '#22c55e';
1329
+ status.textContent = 'Connected';
1330
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
1331
+ };
1332
+ es.onmessage = function(e) {
1333
+ try {
1334
+ var data = JSON.parse(e.data);
1335
+ if (data.type === 'connected') return;
1336
+ if (data.type === 'css-change') {
1337
+ refreshCss();
1338
+ return;
1339
+ }
1340
+ if (data.type === 'error') {
1341
+ if (dot) dot.style.background = '#ef4444';
1342
+ if (status) status.textContent = 'Error: ' + (data.message || 'reload failed');
1343
+ return;
1344
+ }
1345
+ // frontend-change, route-change, page-change, component-change \u2192 full reload
1346
+ if (dot) dot.style.background = '#eab308';
1347
+ if (status) status.textContent = 'Reloading...';
1348
+ setTimeout(function() { location.reload(); }, 300);
1349
+ } catch(err) {}
1350
+ };
1351
+ es.onerror = function() {
1352
+ es.close();
1353
+ if (dot) dot.style.background = '#ef4444';
1354
+ if (status) status.textContent = 'Disconnected';
1355
+ reconnectTimer = setTimeout(connect, 2000);
1356
+ };
1357
+ }
1358
+
1359
+ if (document.readyState === 'loading') {
1360
+ document.addEventListener('DOMContentLoaded', function() { connect(); });
1361
+ } else {
1362
+ connect();
1363
+ }
1364
+ })();
1365
+ </script>`;
1366
+ }
1367
+ function generateRouterScript() {
1368
+ return `
1369
+ <script data-clawfire-router>
1370
+ (function() {
1371
+ function updateActiveNav() {
1372
+ var path = location.pathname;
1373
+ var links = document.querySelectorAll('#nav-links a[href]');
1374
+ for (var i = 0; i < links.length; i++) {
1375
+ var href = links[i].getAttribute('href');
1376
+ if (!href || href.startsWith('http')) continue;
1377
+ var isActive = (path === '/' && href === '/') || (href !== '/' && path.startsWith(href));
1378
+ links[i].setAttribute('data-active', isActive ? 'true' : 'false');
1379
+ }
1380
+ }
1381
+
1382
+ function navigate(url) {
1383
+ var target = new URL(url, location.href);
1384
+ // Only handle same-origin, non-hash navigation
1385
+ if (target.origin !== location.origin) return false;
1386
+ if (target.pathname === location.pathname && target.hash) return false;
1387
+
1388
+ fetch(target.pathname, {
1389
+ headers: { 'X-Clawfire-Partial': 'true' }
1390
+ })
1391
+ .then(function(res) {
1392
+ if (!res.ok) throw new Error('Page not found');
1393
+ return res.json();
1394
+ })
1395
+ .then(function(data) {
1396
+ // Update page content
1397
+ var container = document.getElementById('clawfire-page');
1398
+ if (container) {
1399
+ container.innerHTML = data.html;
1400
+ // Execute scripts in new content
1401
+ var scripts = container.querySelectorAll('script');
1402
+ for (var i = 0; i < scripts.length; i++) {
1403
+ var newScript = document.createElement('script');
1404
+ if (scripts[i].src) {
1405
+ newScript.src = scripts[i].src;
1406
+ } else {
1407
+ newScript.textContent = scripts[i].textContent;
1408
+ }
1409
+ scripts[i].parentNode.replaceChild(newScript, scripts[i]);
1410
+ }
1411
+ }
1412
+ // Update title
1413
+ if (data.meta && data.meta.title) {
1414
+ document.title = data.meta.title;
1415
+ }
1416
+ // Update URL
1417
+ history.pushState(null, '', target.pathname);
1418
+ // Update nav active state
1419
+ updateActiveNav();
1420
+ // Dispatch event for page scripts
1421
+ document.dispatchEvent(new CustomEvent('clawfire:navigate', { detail: { path: data.path } }));
1422
+ // Scroll to top
1423
+ window.scrollTo(0, 0);
1424
+ })
1425
+ .catch(function(err) {
1426
+ // Fallback: full page navigation
1427
+ location.href = url;
1428
+ });
1429
+
1430
+ return true;
1431
+ }
1432
+
1433
+ // Intercept link clicks
1434
+ document.addEventListener('click', function(e) {
1435
+ var anchor = e.target.closest ? e.target.closest('a[href]') : null;
1436
+ if (!anchor) return;
1437
+ var href = anchor.getAttribute('href');
1438
+ if (!href) return;
1439
+ // Skip external links, new-tab links, modified clicks
1440
+ if (href.startsWith('http') || href.startsWith('//')) return;
1441
+ if (anchor.target === '_blank') return;
1442
+ if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
1443
+ if (anchor.hasAttribute('download')) return;
1444
+
1445
+ e.preventDefault();
1446
+ navigate(href);
1447
+ });
1448
+
1449
+ // Handle back/forward
1450
+ window.addEventListener('popstate', function() {
1451
+ navigate(location.pathname);
1452
+ });
1453
+
1454
+ // Set initial active nav state
1455
+ updateActiveNav();
1456
+ })();
1457
+ </script>`;
1458
+ }
1011
1459
  var DevServer = class {
1012
- server = null;
1460
+ frontendServer = null;
1461
+ apiServer = null;
1013
1462
  router;
1014
1463
  watcher = null;
1015
- sseClients = [];
1464
+ frontendSseClients = [];
1465
+ apiSseClients = [];
1016
1466
  sseIdCounter = 0;
1017
1467
  options;
1018
1468
  routesDir;
1019
1469
  schemasDir;
1470
+ publicDir;
1471
+ pagesDir;
1472
+ componentsDir;
1020
1473
  playgroundHtml = "";
1021
1474
  importCounter = 0;
1022
- // ESM 캐시 버스팅용
1023
1475
  isReloading = false;
1476
+ pageCompiler;
1024
1477
  constructor(options = {}) {
1025
1478
  this.options = {
1026
1479
  projectDir: options.projectDir || process.cwd(),
1027
- port: options.port || 3456,
1480
+ port: options.port || 3e3,
1481
+ apiPort: options.apiPort || 3456,
1028
1482
  routerOptions: options.routerOptions || {},
1029
1483
  hotReload: options.hotReload !== false,
1030
1484
  debounceMs: options.debounceMs || 150,
1031
1485
  onSetupRoutes: options.onSetupRoutes || (() => {
1032
1486
  })
1033
1487
  };
1034
- this.routesDir = (0, import_node_path.resolve)(this.options.projectDir, "app/routes");
1035
- this.schemasDir = (0, import_node_path.resolve)(this.options.projectDir, "app/schemas");
1488
+ this.routesDir = (0, import_node_path2.resolve)(this.options.projectDir, "app/routes");
1489
+ this.schemasDir = (0, import_node_path2.resolve)(this.options.projectDir, "app/schemas");
1490
+ this.publicDir = (0, import_node_path2.resolve)(this.options.projectDir, "public");
1491
+ this.pagesDir = (0, import_node_path2.resolve)(this.options.projectDir, "app/pages");
1492
+ this.componentsDir = (0, import_node_path2.resolve)(this.options.projectDir, "app/components");
1493
+ this.pageCompiler = new PageCompiler(this.options.projectDir);
1036
1494
  this.router = createRouter({
1037
1495
  cors: ["*"],
1038
- // dev에서는 모든 origin 허용
1039
1496
  rateLimit: 0,
1040
- // dev에서는 rate limit 비활성화
1041
1497
  ...this.options.routerOptions
1042
1498
  });
1043
1499
  }
1044
- /**
1045
- * 개발 서버 시작
1046
- */
1500
+ // ─── Lifecycle ──────────────────────────────────────────────────────
1047
1501
  async start() {
1048
1502
  await this.loadRoutes();
1049
1503
  this.regeneratePlayground();
1050
- this.server = import_node_http.default.createServer((req, res) => this.handleHttpRequest(req, res));
1504
+ this.apiServer = import_node_http.default.createServer((req, res) => this.handleApiRequest(req, res));
1505
+ this.frontendServer = import_node_http.default.createServer((req, res) => this.handleFrontendRequest(req, res));
1051
1506
  if (this.options.hotReload) {
1052
1507
  this.startWatcher();
1053
1508
  }
1054
- await new Promise((resolve3, reject) => {
1055
- this.server.listen(this.options.port, () => {
1056
- resolve3();
1057
- });
1058
- this.server.on("error", reject);
1509
+ await new Promise((resolve4, reject) => {
1510
+ this.apiServer.listen(this.options.apiPort, () => resolve4());
1511
+ this.apiServer.on("error", reject);
1512
+ });
1513
+ await new Promise((resolve4, reject) => {
1514
+ this.frontendServer.listen(this.options.port, () => resolve4());
1515
+ this.frontendServer.on("error", reject);
1059
1516
  });
1060
1517
  this.printStartupBanner();
1061
1518
  }
1062
- /**
1063
- * 서버 종료
1064
- */
1065
1519
  async stop() {
1066
1520
  this.watcher?.close();
1067
- for (const client of this.sseClients) {
1521
+ for (const client of [...this.frontendSseClients, ...this.apiSseClients]) {
1068
1522
  client.res.end();
1069
1523
  }
1070
- this.sseClients = [];
1524
+ this.frontendSseClients = [];
1525
+ this.apiSseClients = [];
1071
1526
  this.router.destroy();
1072
- if (this.server) {
1073
- await new Promise((resolve3) => {
1074
- this.server.close(() => resolve3());
1527
+ if (this.apiServer) {
1528
+ await new Promise((resolve4) => {
1529
+ this.apiServer.close(() => resolve4());
1530
+ });
1531
+ }
1532
+ if (this.frontendServer) {
1533
+ await new Promise((resolve4) => {
1534
+ this.frontendServer.close(() => resolve4());
1075
1535
  });
1076
1536
  }
1077
1537
  }
1078
1538
  // ─── Route Loading ─────────────────────────────────────────────────
1079
- /**
1080
- * 라우트 파일 로딩 (또는 수동 콜백)
1081
- */
1082
1539
  async loadRoutes() {
1083
1540
  this.router.destroy();
1084
1541
  this.router = createRouter({
@@ -1090,11 +1547,11 @@ var DevServer = class {
1090
1547
  await this.options.onSetupRoutes(this.router);
1091
1548
  if (this.router.getRoutes().length > 0) return;
1092
1549
  }
1093
- if (!(0, import_node_fs.existsSync)(this.routesDir)) return;
1550
+ if (!(0, import_node_fs2.existsSync)(this.routesDir)) return;
1094
1551
  const discovered = discoverRoutes(this.routesDir);
1095
1552
  for (const route of discovered) {
1096
1553
  try {
1097
- const fullPath = (0, import_node_path.resolve)(this.routesDir, route.filePath);
1554
+ const fullPath = (0, import_node_path2.resolve)(this.routesDir, route.filePath);
1098
1555
  const fileUrl = (0, import_node_url.pathToFileURL)(fullPath).href;
1099
1556
  const mod = await import(`${fileUrl}?v=${++this.importCounter}`);
1100
1557
  const contract = mod.default;
@@ -1106,13 +1563,10 @@ var DevServer = class {
1106
1563
  }
1107
1564
  }
1108
1565
  }
1109
- /**
1110
- * 라우트 핫 리로드
1111
- */
1112
1566
  async reloadRoutes(event) {
1113
1567
  if (this.isReloading) return;
1114
1568
  this.isReloading = true;
1115
- const relPath = (0, import_node_path.relative)(this.options.projectDir, event.filePath);
1569
+ const relPath = (0, import_node_path2.relative)(this.options.projectDir, event.filePath);
1116
1570
  const timestamp = (/* @__PURE__ */ new Date()).toLocaleTimeString();
1117
1571
  console.log(`
1118
1572
  \x1B[33m[${timestamp}]\x1B[0m \x1B[36m${relPath}\x1B[0m changed`);
@@ -1122,7 +1576,13 @@ var DevServer = class {
1122
1576
  this.regeneratePlayground();
1123
1577
  const routeCount = this.router.getRoutes().length;
1124
1578
  console.log(` \x1B[32m\u2713\x1B[0m ${routeCount} routes loaded`);
1125
- this.broadcastSSE({
1579
+ this.broadcastSSE(this.apiSseClients, {
1580
+ type: event.type,
1581
+ file: relPath,
1582
+ timestamp: event.timestamp,
1583
+ routes: routeCount
1584
+ });
1585
+ this.broadcastSSE(this.frontendSseClients, {
1126
1586
  type: event.type,
1127
1587
  file: relPath,
1128
1588
  timestamp: event.timestamp,
@@ -1130,34 +1590,71 @@ var DevServer = class {
1130
1590
  });
1131
1591
  } catch (err) {
1132
1592
  console.log(` \x1B[31m\u2717\x1B[0m Reload failed:`, err);
1133
- this.broadcastSSE({
1593
+ const errorData = {
1134
1594
  type: "error",
1135
1595
  file: relPath,
1136
1596
  message: err instanceof Error ? err.message : "Unknown error"
1137
- });
1597
+ };
1598
+ this.broadcastSSE(this.apiSseClients, errorData);
1599
+ this.broadcastSSE(this.frontendSseClients, errorData);
1138
1600
  } finally {
1139
1601
  this.isReloading = false;
1140
1602
  }
1141
1603
  }
1604
+ // ─── Frontend Change Handler ───────────────────────────────────────
1605
+ handleFrontendChange(event) {
1606
+ const relPath = (0, import_node_path2.relative)(this.options.projectDir, event.filePath);
1607
+ const timestamp = (/* @__PURE__ */ new Date()).toLocaleTimeString();
1608
+ if (event.type === "css-change") {
1609
+ console.log(`
1610
+ \x1B[33m[${timestamp}]\x1B[0m \x1B[35m${relPath}\x1B[0m CSS updated`);
1611
+ this.broadcastSSE(this.frontendSseClients, {
1612
+ type: "css-change",
1613
+ file: relPath,
1614
+ timestamp: event.timestamp
1615
+ });
1616
+ } else {
1617
+ console.log(`
1618
+ \x1B[33m[${timestamp}]\x1B[0m \x1B[35m${relPath}\x1B[0m changed \u2192 reload`);
1619
+ this.broadcastSSE(this.frontendSseClients, {
1620
+ type: event.type,
1621
+ file: relPath,
1622
+ timestamp: event.timestamp
1623
+ });
1624
+ }
1625
+ }
1142
1626
  // ─── File Watcher ──────────────────────────────────────────────────
1143
1627
  startWatcher() {
1144
1628
  this.watcher = new FileWatcher(this.options.debounceMs);
1145
- if ((0, import_node_fs.existsSync)(this.routesDir)) {
1629
+ if ((0, import_node_fs2.existsSync)(this.routesDir)) {
1146
1630
  this.watcher.watchDir(this.routesDir, "route-change");
1147
1631
  }
1148
- if ((0, import_node_fs.existsSync)(this.schemasDir)) {
1632
+ if ((0, import_node_fs2.existsSync)(this.schemasDir)) {
1149
1633
  this.watcher.watchDir(this.schemasDir, "schema-change");
1150
1634
  }
1151
- const configFile = (0, import_node_path.resolve)(this.options.projectDir, "clawfire.config.ts");
1152
- if ((0, import_node_fs.existsSync)(configFile)) {
1635
+ const configFile = (0, import_node_path2.resolve)(this.options.projectDir, "clawfire.config.ts");
1636
+ if ((0, import_node_fs2.existsSync)(configFile)) {
1153
1637
  this.watcher.watchFile(configFile, "config-change");
1154
1638
  }
1155
- this.watcher.on("change", (event) => {
1156
- this.reloadRoutes(event);
1157
- });
1639
+ if ((0, import_node_fs2.existsSync)(this.publicDir)) {
1640
+ this.watcher.watchDirFrontend(this.publicDir);
1641
+ }
1642
+ if ((0, import_node_fs2.existsSync)(this.pagesDir)) {
1643
+ this.watcher.watchDir(this.pagesDir, "page-change");
1644
+ }
1645
+ if ((0, import_node_fs2.existsSync)(this.componentsDir)) {
1646
+ this.watcher.watchDir(this.componentsDir, "component-change");
1647
+ }
1648
+ this.watcher.on("route-change", (event) => this.reloadRoutes(event));
1649
+ this.watcher.on("schema-change", (event) => this.reloadRoutes(event));
1650
+ this.watcher.on("config-change", (event) => this.reloadRoutes(event));
1651
+ this.watcher.on("frontend-change", (event) => this.handleFrontendChange(event));
1652
+ this.watcher.on("css-change", (event) => this.handleFrontendChange(event));
1653
+ this.watcher.on("page-change", (event) => this.handleFrontendChange(event));
1654
+ this.watcher.on("component-change", (event) => this.handleFrontendChange(event));
1158
1655
  }
1159
1656
  // ─── SSE (Server-Sent Events) ──────────────────────────────────────
1160
- handleSSE(req, res) {
1657
+ handleSSE(req, res, clients) {
1161
1658
  res.writeHead(200, {
1162
1659
  "Content-Type": "text/event-stream",
1163
1660
  "Cache-Control": "no-cache",
@@ -1166,19 +1663,20 @@ var DevServer = class {
1166
1663
  });
1167
1664
  const clientId = ++this.sseIdCounter;
1168
1665
  const client = { id: clientId, res };
1169
- this.sseClients.push(client);
1666
+ clients.push(client);
1170
1667
  res.write(`data: ${JSON.stringify({ type: "connected", id: clientId })}
1171
1668
 
1172
1669
  `);
1173
1670
  req.on("close", () => {
1174
- this.sseClients = this.sseClients.filter((c) => c.id !== clientId);
1671
+ const idx = clients.indexOf(client);
1672
+ if (idx !== -1) clients.splice(idx, 1);
1175
1673
  });
1176
1674
  }
1177
- broadcastSSE(data) {
1675
+ broadcastSSE(clients, data) {
1178
1676
  const message = `data: ${JSON.stringify(data)}
1179
1677
 
1180
1678
  `;
1181
- for (const client of this.sseClients) {
1679
+ for (const client of clients) {
1182
1680
  try {
1183
1681
  client.res.write(message);
1184
1682
  } catch {
@@ -1189,95 +1687,206 @@ var DevServer = class {
1189
1687
  regeneratePlayground() {
1190
1688
  const baseHtml = generatePlaygroundHtml({
1191
1689
  title: "Clawfire Dev Playground",
1192
- apiBaseUrl: `http://localhost:${this.options.port}`
1690
+ apiBaseUrl: `http://localhost:${this.options.apiPort}`
1193
1691
  });
1194
1692
  const liveReloadScript = `
1195
1693
  <script>
1196
1694
  (function() {
1197
- const banner = document.createElement('div');
1695
+ var banner = document.createElement('div');
1198
1696
  banner.id = 'dev-banner';
1199
1697
  banner.style.cssText = 'position:fixed;bottom:0;left:0;right:0;padding:6px 16px;background:#1a1a2e;color:#f97316;font-size:12px;font-family:monospace;z-index:9999;display:flex;align-items:center;gap:8px;border-top:1px solid #2a2a2a;';
1200
1698
  banner.innerHTML = '<span style="width:8px;height:8px;border-radius:50%;background:#22c55e;display:inline-block;" id="dev-dot"></span><span>Clawfire Dev Server</span><span style="color:#666;margin-left:auto;" id="dev-status">Connected</span>';
1201
1699
  document.body.appendChild(banner);
1202
1700
 
1203
- let reconnectTimer;
1701
+ var reconnectTimer;
1204
1702
  function connect() {
1205
- const es = new EventSource('http://localhost:${this.options.port}/__dev/events');
1206
-
1207
- es.onopen = () => {
1703
+ var es = new EventSource('http://localhost:${this.options.apiPort}/__dev/events');
1704
+ es.onopen = function() {
1208
1705
  document.getElementById('dev-dot').style.background = '#22c55e';
1209
1706
  document.getElementById('dev-status').textContent = 'Connected';
1210
1707
  if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
1211
1708
  };
1212
-
1213
- es.onmessage = (e) => {
1709
+ es.onmessage = function(e) {
1214
1710
  try {
1215
- const data = JSON.parse(e.data);
1711
+ var data = JSON.parse(e.data);
1216
1712
  if (data.type === 'connected') return;
1217
-
1218
1713
  if (data.type === 'error') {
1219
1714
  document.getElementById('dev-dot').style.background = '#ef4444';
1220
1715
  document.getElementById('dev-status').textContent = 'Error: ' + (data.message || 'reload failed');
1221
1716
  return;
1222
1717
  }
1223
-
1224
- // \uD30C\uC77C \uBCC0\uACBD \u2192 \uD398\uC774\uC9C0 \uC0C8\uB85C\uACE0\uCE68
1225
1718
  document.getElementById('dev-dot').style.background = '#eab308';
1226
1719
  document.getElementById('dev-status').textContent = 'Reloading...';
1227
- setTimeout(() => window.location.reload(), 300);
1228
- } catch {}
1720
+ setTimeout(function() { window.location.reload(); }, 300);
1721
+ } catch(err) {}
1229
1722
  };
1230
-
1231
- es.onerror = () => {
1723
+ es.onerror = function() {
1232
1724
  es.close();
1233
1725
  document.getElementById('dev-dot').style.background = '#ef4444';
1234
1726
  document.getElementById('dev-status').textContent = 'Disconnected \u2014 reconnecting...';
1235
1727
  reconnectTimer = setTimeout(connect, 2000);
1236
1728
  };
1237
1729
  }
1238
-
1239
1730
  connect();
1240
1731
  })();
1241
1732
  </script>`;
1242
1733
  this.playgroundHtml = baseHtml.replace("</body>", liveReloadScript + "\n</body>");
1243
1734
  }
1244
- // ─── HTTP Request Handler ──────────────────────────────────────────
1245
- handleHttpRequest(req, res) {
1735
+ // ─── Script Injection ──────────────────────────────────────────────
1736
+ /**
1737
+ * Inject HMR and (optionally) client-side router scripts before </body>.
1738
+ */
1739
+ injectScripts(html, includeRouter) {
1740
+ const hmr = generateHmrScript(this.options.port);
1741
+ const router = includeRouter ? generateRouterScript() : "";
1742
+ const scripts = router + hmr;
1743
+ if (html.includes("</body>")) {
1744
+ return html.replace("</body>", scripts + "\n</body>");
1745
+ }
1746
+ return html + scripts;
1747
+ }
1748
+ // ─── Static File Serving ──────────────────────────────────────────
1749
+ serveStaticFile(filePath, res) {
1750
+ if (!(0, import_node_fs2.existsSync)(filePath)) return false;
1751
+ try {
1752
+ const content = (0, import_node_fs2.readFileSync)(filePath);
1753
+ const ext = (0, import_node_path2.extname)(filePath).slice(1).toLowerCase();
1754
+ const mime = MIME_TYPES[ext] || "application/octet-stream";
1755
+ if (ext === "html") {
1756
+ const html = content.toString("utf-8");
1757
+ const injected = this.injectScripts(html, false);
1758
+ res.writeHead(200, { "Content-Type": mime });
1759
+ res.end(injected);
1760
+ return true;
1761
+ }
1762
+ res.writeHead(200, { "Content-Type": mime });
1763
+ res.end(content);
1764
+ return true;
1765
+ } catch {
1766
+ return false;
1767
+ }
1768
+ }
1769
+ // ─── API Proxy ────────────────────────────────────────────────────
1770
+ proxyToApiServer(req, res) {
1771
+ const proxyReq = import_node_http.default.request(
1772
+ {
1773
+ hostname: "127.0.0.1",
1774
+ port: this.options.apiPort,
1775
+ path: req.url,
1776
+ method: req.method,
1777
+ headers: {
1778
+ ...req.headers,
1779
+ host: `localhost:${this.options.apiPort}`
1780
+ }
1781
+ },
1782
+ (proxyRes) => {
1783
+ res.writeHead(proxyRes.statusCode || 500, proxyRes.headers);
1784
+ proxyRes.pipe(res, { end: true });
1785
+ }
1786
+ );
1787
+ proxyReq.on("error", (err) => {
1788
+ res.writeHead(502, { "Content-Type": "application/json" });
1789
+ res.end(JSON.stringify({ error: { code: "PROXY_ERROR", message: "API server unavailable" } }));
1790
+ });
1791
+ req.pipe(proxyReq, { end: true });
1792
+ }
1793
+ // ─── Frontend Request Handler ─────────────────────────────────────
1794
+ handleFrontendRequest(req, res) {
1246
1795
  const url = new URL(req.url || "/", `http://localhost:${this.options.port}`);
1247
1796
  if (url.pathname === "/__dev/events") {
1248
- this.handleSSE(req, res);
1797
+ this.handleSSE(req, res, this.frontendSseClients);
1249
1798
  return;
1250
1799
  }
1251
- if (url.pathname === "/" || url.pathname === "/__playground") {
1252
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1253
- res.end(this.playgroundHtml);
1800
+ if (url.pathname.startsWith("/api")) {
1801
+ this.proxyToApiServer(req, res);
1254
1802
  return;
1255
1803
  }
1256
- if (!url.pathname.startsWith("/api") && !url.pathname.startsWith("/__")) {
1257
- const publicDir = (0, import_node_path.resolve)(this.options.projectDir, "public");
1258
- const filePath = (0, import_node_path.resolve)(publicDir, url.pathname.slice(1));
1259
- if ((0, import_node_fs.existsSync)(filePath) && !filePath.includes("..")) {
1804
+ if (url.pathname.startsWith("/__")) {
1805
+ this.proxyToApiServer(req, res);
1806
+ return;
1807
+ }
1808
+ const ext = (0, import_node_path2.extname)(url.pathname);
1809
+ if (ext && STATIC_EXTENSIONS.has(ext)) {
1810
+ const filePath2 = (0, import_node_path2.resolve)(this.publicDir, url.pathname.slice(1));
1811
+ if (filePath2.startsWith(this.publicDir) && this.serveStaticFile(filePath2, res)) {
1812
+ return;
1813
+ }
1814
+ res.writeHead(404);
1815
+ res.end("Not found");
1816
+ return;
1817
+ }
1818
+ if (this.pageCompiler.isActive()) {
1819
+ const isPartial = req.headers["x-clawfire-partial"] === "true";
1820
+ const pagePath = this.pageCompiler.resolve(url.pathname);
1821
+ if (pagePath) {
1260
1822
  try {
1261
- const content = (0, import_node_fs.readFileSync)(filePath);
1262
- const ext = filePath.split(".").pop() || "";
1263
- const mimeTypes = {
1264
- html: "text/html",
1265
- css: "text/css",
1266
- js: "application/javascript",
1267
- json: "application/json",
1268
- png: "image/png",
1269
- jpg: "image/jpeg",
1270
- svg: "image/svg+xml",
1271
- ico: "image/x-icon",
1272
- woff2: "font/woff2"
1273
- };
1274
- res.writeHead(200, { "Content-Type": mimeTypes[ext] || "application/octet-stream" });
1275
- res.end(content);
1276
- return;
1823
+ if (isPartial) {
1824
+ const partial = this.pageCompiler.compilePartial(pagePath, url.pathname);
1825
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
1826
+ res.end(JSON.stringify(partial));
1827
+ } else {
1828
+ const compiled = this.pageCompiler.compile(pagePath);
1829
+ const html = this.injectScripts(compiled.html, true);
1830
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1831
+ res.end(html);
1832
+ }
1833
+ } catch (err) {
1834
+ logger.warn(`Page compilation error: ${url.pathname}`, err);
1835
+ res.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
1836
+ res.end(`<h1>500 \u2014 Page Compilation Error</h1><pre>${err instanceof Error ? err.message : "Unknown error"}</pre>`);
1837
+ }
1838
+ return;
1839
+ }
1840
+ const page404 = this.pageCompiler.resolve404();
1841
+ if (page404) {
1842
+ try {
1843
+ if (isPartial) {
1844
+ const partial = this.pageCompiler.compilePartial(page404, url.pathname);
1845
+ res.writeHead(404, { "Content-Type": "application/json; charset=utf-8" });
1846
+ res.end(JSON.stringify(partial));
1847
+ } else {
1848
+ const compiled = this.pageCompiler.compile(page404);
1849
+ const html = this.injectScripts(compiled.html, true);
1850
+ res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
1851
+ res.end(html);
1852
+ }
1277
1853
  } catch {
1854
+ res.writeHead(404);
1855
+ res.end("Not found");
1278
1856
  }
1857
+ return;
1279
1858
  }
1280
1859
  }
1860
+ const requestedPath = url.pathname === "/" ? "index.html" : url.pathname.slice(1);
1861
+ const filePath = (0, import_node_path2.resolve)(this.publicDir, requestedPath);
1862
+ if (!filePath.startsWith(this.publicDir)) {
1863
+ res.writeHead(403);
1864
+ res.end("Forbidden");
1865
+ return;
1866
+ }
1867
+ if (this.serveStaticFile(filePath, res)) {
1868
+ return;
1869
+ }
1870
+ const indexPath = (0, import_node_path2.resolve)(this.publicDir, "index.html");
1871
+ if ((0, import_node_fs2.existsSync)(indexPath)) {
1872
+ this.serveStaticFile(indexPath, res);
1873
+ return;
1874
+ }
1875
+ res.writeHead(404);
1876
+ res.end("Not found");
1877
+ }
1878
+ // ─── API Request Handler ──────────────────────────────────────────
1879
+ handleApiRequest(req, res) {
1880
+ const url = new URL(req.url || "/", `http://localhost:${this.options.apiPort}`);
1881
+ if (url.pathname === "/__dev/events") {
1882
+ this.handleSSE(req, res, this.apiSseClients);
1883
+ return;
1884
+ }
1885
+ if (url.pathname === "/" || url.pathname === "/__playground") {
1886
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1887
+ res.end(this.playgroundHtml);
1888
+ return;
1889
+ }
1281
1890
  if (url.pathname.startsWith("/api")) {
1282
1891
  res.setHeader("Access-Control-Allow-Origin", "*");
1283
1892
  res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
@@ -1342,12 +1951,14 @@ var DevServer = class {
1342
1951
  printStartupBanner() {
1343
1952
  const routes = this.router.getRoutes();
1344
1953
  const watching = this.options.hotReload;
1954
+ const pagesActive = this.pageCompiler.isActive();
1345
1955
  console.log("");
1346
1956
  console.log(" \x1B[1m\x1B[33m\u26A1 Clawfire Dev Server\x1B[0m");
1347
1957
  console.log(" \x1B[2m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\x1B[0m");
1348
- console.log(` \x1B[36mPlayground\x1B[0m : http://localhost:${this.options.port}`);
1349
- console.log(` \x1B[36mAPI\x1B[0m : http://localhost:${this.options.port}/api/...`);
1350
- console.log(` \x1B[36mManifest\x1B[0m : http://localhost:${this.options.port}/api/__manifest`);
1958
+ console.log(` \x1B[36mApp\x1B[0m : http://localhost:${this.options.port}`);
1959
+ console.log(` \x1B[36mAPI\x1B[0m : http://localhost:${this.options.apiPort}/api/...`);
1960
+ console.log(` \x1B[36mPlayground\x1B[0m : http://localhost:${this.options.apiPort}`);
1961
+ console.log(` \x1B[36mPages\x1B[0m : ${pagesActive ? "\x1B[32mON\x1B[0m (app/pages/)" : "\x1B[2mOFF\x1B[0m"}`);
1351
1962
  console.log("");
1352
1963
  console.log(` \x1B[32mRoutes (${routes.length})\x1B[0m:`);
1353
1964
  for (const route of routes) {
@@ -1357,8 +1968,10 @@ var DevServer = class {
1357
1968
  }
1358
1969
  console.log("");
1359
1970
  if (watching) {
1971
+ const watchDirs = ["app/routes/", "app/schemas/", "public/"];
1972
+ if (pagesActive) watchDirs.push("app/pages/", "app/components/");
1360
1973
  console.log(` \x1B[35mHot Reload\x1B[0m : \x1B[32mON\x1B[0m`);
1361
- console.log(` \x1B[2mWatching: app/routes/, app/schemas/\x1B[0m`);
1974
+ console.log(` \x1B[2mWatching: ${watchDirs.join(", ")}\x1B[0m`);
1362
1975
  } else {
1363
1976
  console.log(` \x1B[35mHot Reload\x1B[0m : OFF`);
1364
1977
  }