clawfire 0.2.0 → 0.3.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.
@@ -1,1220 +0,0 @@
1
- #!/usr/bin/env node
2
- import {
3
- discoverRoutes
4
- } from "./chunk-37Y2XI7X.js";
5
- import {
6
- generatePlaygroundHtml
7
- } from "./chunk-YGIPORYL.js";
8
-
9
- // src/dev/dev-server.ts
10
- import http from "http";
11
- import { resolve, relative, extname as extname2 } from "path";
12
- import { existsSync as existsSync2, readFileSync } from "fs";
13
- import { pathToFileURL } from "url";
14
-
15
- // src/core/schema.ts
16
- import { z } from "zod";
17
- function zodToJsonSchema(schema) {
18
- return extractZodShape(schema);
19
- }
20
- function extractZodShape(schema) {
21
- const def = schema._def;
22
- if (!def) return { type: "unknown" };
23
- switch (def.typeName) {
24
- case "ZodObject": {
25
- const shape = schema.shape;
26
- const properties = {};
27
- const required = [];
28
- for (const [key, value] of Object.entries(shape)) {
29
- properties[key] = extractZodShape(value);
30
- if (!value.isOptional?.()) {
31
- const innerDef = value._def;
32
- if (innerDef?.typeName !== "ZodOptional" && innerDef?.typeName !== "ZodDefault") {
33
- required.push(key);
34
- }
35
- }
36
- }
37
- return { type: "object", properties, ...required.length > 0 ? { required } : {} };
38
- }
39
- case "ZodString":
40
- return { type: "string", ...def.checks?.length ? extractStringChecks(def.checks) : {} };
41
- case "ZodNumber":
42
- return { type: "number" };
43
- case "ZodBoolean":
44
- return { type: "boolean" };
45
- case "ZodArray":
46
- return { type: "array", items: extractZodShape(def.type) };
47
- case "ZodEnum":
48
- return { type: "string", enum: def.values };
49
- case "ZodOptional":
50
- return { ...extractZodShape(def.innerType), optional: true };
51
- case "ZodDefault":
52
- return { ...extractZodShape(def.innerType), default: def.defaultValue() };
53
- case "ZodNullable":
54
- return { ...extractZodShape(def.innerType), nullable: true };
55
- case "ZodLiteral":
56
- return { type: typeof def.value, const: def.value };
57
- case "ZodUnion":
58
- return { oneOf: def.options.map((o) => extractZodShape(o)) };
59
- case "ZodRecord":
60
- return { type: "object", additionalProperties: extractZodShape(def.valueType) };
61
- case "ZodDate":
62
- return { type: "string", format: "date-time" };
63
- default:
64
- return { type: "unknown" };
65
- }
66
- }
67
- function extractStringChecks(checks) {
68
- const result = {};
69
- for (const check of checks) {
70
- switch (check.kind) {
71
- case "min":
72
- result.minLength = check.value;
73
- break;
74
- case "max":
75
- result.maxLength = check.value;
76
- break;
77
- case "email":
78
- result.format = "email";
79
- break;
80
- case "url":
81
- result.format = "uri";
82
- break;
83
- case "uuid":
84
- result.format = "uuid";
85
- break;
86
- }
87
- }
88
- return result;
89
- }
90
- function contractToManifest(path, contract) {
91
- return {
92
- path,
93
- method: "POST",
94
- meta: contract.meta,
95
- inputSchema: zodToJsonSchema(contract.input),
96
- outputSchema: zodToJsonSchema(contract.output)
97
- };
98
- }
99
-
100
- // src/core/errors.ts
101
- var HTTP_STATUS_MAP = {
102
- VALIDATION_ERROR: 400,
103
- UNAUTHORIZED: 401,
104
- FORBIDDEN: 403,
105
- NOT_FOUND: 404,
106
- CONFLICT: 409,
107
- RATE_LIMITED: 429,
108
- REAUTH_REQUIRED: 401,
109
- INTERNAL_ERROR: 500,
110
- SERVICE_UNAVAILABLE: 503
111
- };
112
- var ClawfireError = class extends Error {
113
- code;
114
- statusCode;
115
- details;
116
- constructor(code, message, details) {
117
- super(message);
118
- this.name = "ClawfireError";
119
- this.code = code;
120
- this.statusCode = HTTP_STATUS_MAP[code];
121
- this.details = details;
122
- }
123
- toJSON() {
124
- return {
125
- error: {
126
- code: this.code,
127
- message: this.message,
128
- ...this.details ? { details: this.details } : {}
129
- }
130
- };
131
- }
132
- };
133
- var Errors = {
134
- validation: (message, details) => new ClawfireError("VALIDATION_ERROR", message, details),
135
- unauthorized: (message = "Authentication required") => new ClawfireError("UNAUTHORIZED", message),
136
- forbidden: (message = "Insufficient permissions") => new ClawfireError("FORBIDDEN", message),
137
- notFound: (message = "Resource not found") => new ClawfireError("NOT_FOUND", message),
138
- conflict: (message) => new ClawfireError("CONFLICT", message),
139
- rateLimited: (message = "Too many requests") => new ClawfireError("RATE_LIMITED", message),
140
- reauthRequired: (message = "Re-authentication required for this action") => new ClawfireError("REAUTH_REQUIRED", message),
141
- internal: (message = "Internal server error") => new ClawfireError("INTERNAL_ERROR", message),
142
- unavailable: (message = "Service temporarily unavailable") => new ClawfireError("SERVICE_UNAVAILABLE", message)
143
- };
144
-
145
- // src/core/logger.ts
146
- var SENSITIVE_FIELDS = [
147
- "password",
148
- "token",
149
- "secret",
150
- "apiKey",
151
- "api_key",
152
- "authorization",
153
- "credit_card",
154
- "creditCard",
155
- "ssn",
156
- "cardNumber",
157
- "card_number",
158
- "cvv",
159
- "pin"
160
- ];
161
- var LOG_LEVELS = {
162
- debug: 0,
163
- info: 1,
164
- warn: 2,
165
- error: 3
166
- };
167
- var currentLevel = "info";
168
- function shouldLog(level) {
169
- return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
170
- }
171
- function maskSensitive(obj) {
172
- if (obj === null || obj === void 0) return obj;
173
- if (typeof obj === "string") return obj;
174
- if (typeof obj !== "object") return obj;
175
- if (Array.isArray(obj)) {
176
- return obj.map(maskSensitive);
177
- }
178
- const masked = {};
179
- for (const [key, value] of Object.entries(obj)) {
180
- if (SENSITIVE_FIELDS.some((f) => key.toLowerCase().includes(f.toLowerCase()))) {
181
- masked[key] = "***MASKED***";
182
- } else if (typeof value === "object" && value !== null) {
183
- masked[key] = maskSensitive(value);
184
- } else {
185
- masked[key] = value;
186
- }
187
- }
188
- return masked;
189
- }
190
- function formatLog(level, message, data) {
191
- const timestamp = (/* @__PURE__ */ new Date()).toISOString();
192
- const prefix = `[${timestamp}] [CLAWFIRE] [${level.toUpperCase()}]`;
193
- if (data !== void 0) {
194
- return `${prefix} ${message} ${JSON.stringify(maskSensitive(data))}`;
195
- }
196
- return `${prefix} ${message}`;
197
- }
198
- var logger = {
199
- debug(message, data) {
200
- if (shouldLog("debug")) console.debug(formatLog("debug", message, data));
201
- },
202
- info(message, data) {
203
- if (shouldLog("info")) console.info(formatLog("info", message, data));
204
- },
205
- warn(message, data) {
206
- if (shouldLog("warn")) console.warn(formatLog("warn", message, data));
207
- },
208
- error(message, data) {
209
- if (shouldLog("error")) console.error(formatLog("error", message, data));
210
- }
211
- };
212
-
213
- // src/firebase/auth.ts
214
- async function verifyToken(auth, idToken) {
215
- const decoded = await auth.verifyIdToken(idToken);
216
- return {
217
- uid: decoded.uid,
218
- email: decoded.email,
219
- emailVerified: decoded.email_verified,
220
- role: decoded.role || decoded.customClaims?.role,
221
- customClaims: decoded,
222
- token: idToken
223
- };
224
- }
225
- async function verifyReauth(auth, idToken, maxAgeSeconds = 300) {
226
- const decoded = await auth.verifyIdToken(idToken, true);
227
- const authTime = decoded.auth_time * 1e3;
228
- const now = Date.now();
229
- if (now - authTime > maxAgeSeconds * 1e3) {
230
- throw Errors.reauthRequired(
231
- `Re-authentication required. Last auth was ${Math.floor((now - authTime) / 1e3)}s ago.`
232
- );
233
- }
234
- return {
235
- uid: decoded.uid,
236
- email: decoded.email,
237
- emailVerified: decoded.email_verified,
238
- role: decoded.role || decoded.customClaims?.role,
239
- customClaims: decoded,
240
- token: idToken
241
- };
242
- }
243
- function extractBearerToken(authHeader) {
244
- if (!authHeader) return null;
245
- const parts = authHeader.split(" ");
246
- if (parts.length !== 2 || parts[0] !== "Bearer") return null;
247
- return parts[1];
248
- }
249
- function checkAuthLevel(authCtx, level, roles, reauthenticated) {
250
- switch (level) {
251
- case "public":
252
- return;
253
- // 누구나 접근 가능
254
- case "authenticated":
255
- if (!authCtx) throw Errors.unauthorized();
256
- return;
257
- case "role":
258
- if (!authCtx) throw Errors.unauthorized();
259
- if (!roles || roles.length === 0) return;
260
- if (!authCtx.role || !roles.includes(authCtx.role)) {
261
- throw Errors.forbidden(`Required role: ${roles.join(" or ")}`);
262
- }
263
- return;
264
- case "reauth":
265
- if (!authCtx) throw Errors.unauthorized();
266
- if (!reauthenticated) {
267
- throw Errors.reauthRequired();
268
- }
269
- return;
270
- }
271
- }
272
-
273
- // src/routing/router.ts
274
- var RateLimiter = class {
275
- store = /* @__PURE__ */ new Map();
276
- defaultLimit;
277
- constructor(defaultLimit) {
278
- this.defaultLimit = defaultLimit;
279
- }
280
- check(key, limit) {
281
- const max = limit || this.defaultLimit;
282
- const now = Date.now();
283
- const entry = this.store.get(key);
284
- if (!entry || now > entry.resetAt) {
285
- this.store.set(key, { count: 1, resetAt: now + 6e4 });
286
- return true;
287
- }
288
- if (entry.count >= max) {
289
- return false;
290
- }
291
- entry.count++;
292
- return true;
293
- }
294
- // 오래된 엔트리 정리
295
- cleanup() {
296
- const now = Date.now();
297
- for (const [key, entry] of this.store) {
298
- if (now > entry.resetAt) this.store.delete(key);
299
- }
300
- }
301
- };
302
- var ClawfireRouter = class {
303
- routes = /* @__PURE__ */ new Map();
304
- options;
305
- rateLimiter;
306
- cleanupInterval;
307
- constructor(options = {}) {
308
- this.options = options;
309
- this.rateLimiter = new RateLimiter(options.rateLimit || 100);
310
- this.cleanupInterval = setInterval(() => this.rateLimiter.cleanup(), 6e4);
311
- }
312
- /** 라우트 등록 */
313
- register(path, contract) {
314
- const normalizedPath = path.startsWith("/") ? path : `/${path}`;
315
- this.routes.set(normalizedPath, { path: normalizedPath, contract });
316
- logger.debug(`Route registered: ${normalizedPath}`);
317
- return this;
318
- }
319
- /** 여러 라우트 한 번에 등록 */
320
- registerAll(routes) {
321
- for (const [path, contract] of Object.entries(routes)) {
322
- this.register(path, contract);
323
- }
324
- return this;
325
- }
326
- /** 매니페스트 생성 */
327
- getManifest() {
328
- const apis = [];
329
- for (const [path, route] of this.routes) {
330
- apis.push(contractToManifest(path, route.contract));
331
- }
332
- return {
333
- version: "1.0.0",
334
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
335
- apis,
336
- models: {}
337
- };
338
- }
339
- /** HTTP 요청 핸들러 (Firebase Functions에서 사용) */
340
- async handleRequest(req, res) {
341
- const corsOrigins = this.options.cors || [];
342
- const origin = req.headers?.origin || req.headers?.Origin || "";
343
- if (corsOrigins.length > 0 && corsOrigins.includes(origin)) {
344
- res.set({
345
- "Access-Control-Allow-Origin": origin,
346
- "Access-Control-Allow-Methods": "POST, OPTIONS",
347
- "Access-Control-Allow-Headers": "Content-Type, Authorization",
348
- "Access-Control-Max-Age": "86400"
349
- });
350
- } else if (corsOrigins.length === 0) {
351
- res.set({
352
- "Access-Control-Allow-Origin": "",
353
- "Access-Control-Allow-Methods": "POST, OPTIONS",
354
- "Access-Control-Allow-Headers": "Content-Type, Authorization"
355
- });
356
- }
357
- if (req.method === "OPTIONS") {
358
- res.status(204).end();
359
- return;
360
- }
361
- res.set({
362
- "X-Content-Type-Options": "nosniff",
363
- "X-Frame-Options": "DENY",
364
- "X-XSS-Protection": "1; mode=block",
365
- "Content-Type": "application/json"
366
- });
367
- let routePath = req.path || req.url || "";
368
- if (routePath.startsWith("/api")) {
369
- routePath = routePath.slice(4);
370
- }
371
- if (routePath === "/__manifest") {
372
- res.status(200).json(this.getManifest());
373
- return;
374
- }
375
- if (req.method && req.method !== "POST") {
376
- res.status(405).json({ error: { code: "METHOD_NOT_ALLOWED", message: "Only POST is allowed" } });
377
- return;
378
- }
379
- const route = this.matchRoute(routePath);
380
- if (!route) {
381
- res.status(404).json({ error: { code: "NOT_FOUND", message: `API not found: ${routePath}` } });
382
- return;
383
- }
384
- try {
385
- const clientKey = req.ip || "unknown";
386
- const rateLimit = route.contract.meta.rateLimit || this.options.rateLimit;
387
- if (rateLimit && !this.rateLimiter.check(clientKey, rateLimit)) {
388
- throw Errors.rateLimited();
389
- }
390
- let authCtx = null;
391
- let reauthenticated = false;
392
- const authHeader = req.headers?.authorization || req.headers?.Authorization;
393
- if (authHeader && this.options.auth) {
394
- const token = extractBearerToken(authHeader);
395
- if (token) {
396
- if (route.contract.meta.reauth || route.contract.meta.auth === "reauth") {
397
- authCtx = await verifyReauth(this.options.auth, token);
398
- reauthenticated = true;
399
- } else {
400
- authCtx = await verifyToken(this.options.auth, token);
401
- }
402
- }
403
- }
404
- if (route.contract.meta.auth) {
405
- checkAuthLevel(authCtx, route.contract.meta.auth, route.contract.meta.roles, reauthenticated);
406
- }
407
- const rawInput = req.body || {};
408
- const parsed = route.contract.input.safeParse(rawInput);
409
- if (!parsed.success) {
410
- throw Errors.validation("Invalid input", parsed.error.flatten());
411
- }
412
- const ctx = {
413
- auth: authCtx,
414
- reauthenticated,
415
- headers: req.headers,
416
- ip: req.ip
417
- };
418
- let result;
419
- if (this.options.middleware && this.options.middleware.length > 0) {
420
- result = await this.runMiddleware(
421
- this.options.middleware,
422
- parsed.data,
423
- ctx,
424
- () => route.contract.handler(parsed.data, ctx)
425
- );
426
- } else {
427
- result = await route.contract.handler(parsed.data, ctx);
428
- }
429
- res.status(200).json({ data: result });
430
- } catch (err) {
431
- if (err instanceof ClawfireError) {
432
- logger.warn(`API error [${err.code}]: ${err.message}`);
433
- res.status(err.statusCode).json(err.toJSON());
434
- } else {
435
- logger.error("Unhandled error", err);
436
- res.status(500).json({
437
- error: {
438
- code: "INTERNAL_ERROR",
439
- message: "An unexpected error occurred"
440
- }
441
- });
442
- }
443
- }
444
- }
445
- /** 라우트 매칭 (동적 파라미터 지원) */
446
- matchRoute(path) {
447
- const exact = this.routes.get(path);
448
- if (exact) return exact;
449
- for (const [routePath, route] of this.routes) {
450
- if (this.matchDynamicRoute(routePath, path)) {
451
- return route;
452
- }
453
- }
454
- return void 0;
455
- }
456
- matchDynamicRoute(pattern, actual) {
457
- const patternParts = pattern.split("/").filter(Boolean);
458
- const actualParts = actual.split("/").filter(Boolean);
459
- if (patternParts.length !== actualParts.length) return false;
460
- return patternParts.every(
461
- (part, i) => part.startsWith(":") || part.startsWith("[") || part === actualParts[i]
462
- );
463
- }
464
- /** 미들웨어 체인 실행 */
465
- async runMiddleware(middlewares, input, ctx, handler) {
466
- let index = 0;
467
- const next = async () => {
468
- if (index >= middlewares.length) {
469
- return handler();
470
- }
471
- const mw = middlewares[index++];
472
- return mw(input, ctx, next);
473
- };
474
- return next();
475
- }
476
- /** 등록된 라우트 목록 */
477
- getRoutes() {
478
- return Array.from(this.routes.values());
479
- }
480
- /** 리소스 정리 */
481
- destroy() {
482
- if (this.cleanupInterval) {
483
- clearInterval(this.cleanupInterval);
484
- }
485
- }
486
- };
487
- function createRouter(options) {
488
- return new ClawfireRouter(options);
489
- }
490
-
491
- // src/dev/watcher.ts
492
- import { watch, existsSync, readdirSync } from "fs";
493
- import { join, extname } from "path";
494
- import { EventEmitter } from "events";
495
- var FileWatcher = class extends EventEmitter {
496
- watchers = [];
497
- debounceTimers = /* @__PURE__ */ new Map();
498
- debounceMs;
499
- constructor(debounceMs = 150) {
500
- super();
501
- this.debounceMs = debounceMs;
502
- }
503
- /**
504
- * 디렉터리 감시 시작
505
- */
506
- watchDir(dir, eventType) {
507
- if (!existsSync(dir)) return this;
508
- try {
509
- const watcher = watch(dir, { recursive: true }, (event, filename) => {
510
- if (!filename) return;
511
- const filePath = join(dir, filename);
512
- if (!this.isWatchedFile(filePath)) return;
513
- this.emitDebounced(filePath, eventType);
514
- });
515
- watcher.on("error", (err) => {
516
- if (err.code === "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM") {
517
- this.watchDirRecursiveManual(dir, eventType);
518
- }
519
- });
520
- this.watchers.push(watcher);
521
- } catch {
522
- this.watchDirRecursiveManual(dir, eventType);
523
- }
524
- return this;
525
- }
526
- /**
527
- * 단일 파일 감시
528
- */
529
- watchFile(filePath, eventType) {
530
- if (!existsSync(filePath)) return this;
531
- const watcher = watch(filePath, (event) => {
532
- this.emitDebounced(filePath, eventType);
533
- });
534
- this.watchers.push(watcher);
535
- return this;
536
- }
537
- /**
538
- * 프론트엔드 디렉터리 감시 (확장자별 이벤트 타입 자동 결정)
539
- */
540
- watchDirFrontend(dir) {
541
- if (!existsSync(dir)) return this;
542
- try {
543
- const watcher = watch(dir, { recursive: true }, (event, filename) => {
544
- if (!filename) return;
545
- const filePath = join(dir, filename);
546
- if (!this.isWatchedFile(filePath)) return;
547
- const ext = extname(filePath);
548
- const eventType = ext === ".css" ? "css-change" : "frontend-change";
549
- this.emitDebounced(filePath, eventType);
550
- });
551
- watcher.on("error", (err) => {
552
- if (err.code === "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM") {
553
- this.watchDirFrontendRecursiveManual(dir);
554
- }
555
- });
556
- this.watchers.push(watcher);
557
- } catch {
558
- this.watchDirFrontendRecursiveManual(dir);
559
- }
560
- return this;
561
- }
562
- /**
563
- * Linux fallback: 프론트엔드 디렉터리 수동 재귀 감시
564
- */
565
- watchDirFrontendRecursiveManual(dir) {
566
- if (!existsSync(dir)) return;
567
- const watcher = watch(dir, (event, filename) => {
568
- if (!filename) return;
569
- const filePath = join(dir, filename);
570
- if (!this.isWatchedFile(filePath)) return;
571
- const ext = extname(filePath);
572
- const eventType = ext === ".css" ? "css-change" : "frontend-change";
573
- this.emitDebounced(filePath, eventType);
574
- });
575
- this.watchers.push(watcher);
576
- try {
577
- const entries = readdirSync(dir, { withFileTypes: true });
578
- for (const entry of entries) {
579
- if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") {
580
- this.watchDirFrontendRecursiveManual(join(dir, entry.name));
581
- }
582
- }
583
- } catch {
584
- }
585
- }
586
- /**
587
- * Linux fallback: 디렉터리 수동 재귀 감시
588
- */
589
- watchDirRecursiveManual(dir, eventType) {
590
- if (!existsSync(dir)) return;
591
- const watcher = watch(dir, (event, filename) => {
592
- if (!filename) return;
593
- const filePath = join(dir, filename);
594
- if (!this.isWatchedFile(filePath)) return;
595
- this.emitDebounced(filePath, eventType);
596
- });
597
- this.watchers.push(watcher);
598
- try {
599
- const entries = readdirSync(dir, { withFileTypes: true });
600
- for (const entry of entries) {
601
- if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") {
602
- this.watchDirRecursiveManual(join(dir, entry.name), eventType);
603
- }
604
- }
605
- } catch {
606
- }
607
- }
608
- /**
609
- * 감시 대상 파일인지 확인
610
- */
611
- isWatchedFile(filePath) {
612
- const ext = extname(filePath);
613
- return [".ts", ".tsx", ".js", ".jsx", ".json", ".html", ".css", ".svg", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".webp", ".woff", ".woff2"].includes(ext);
614
- }
615
- /**
616
- * 디바운스 이벤트 발생
617
- */
618
- emitDebounced(filePath, type) {
619
- const existing = this.debounceTimers.get(filePath);
620
- if (existing) clearTimeout(existing);
621
- this.debounceTimers.set(
622
- filePath,
623
- setTimeout(() => {
624
- this.debounceTimers.delete(filePath);
625
- const event = { type, filePath, timestamp: Date.now() };
626
- this.emit("change", event);
627
- this.emit(type, event);
628
- }, this.debounceMs)
629
- );
630
- }
631
- /**
632
- * 모든 감시 중지
633
- */
634
- close() {
635
- for (const watcher of this.watchers) {
636
- watcher.close();
637
- }
638
- this.watchers = [];
639
- for (const timer of this.debounceTimers.values()) {
640
- clearTimeout(timer);
641
- }
642
- this.debounceTimers.clear();
643
- this.removeAllListeners();
644
- }
645
- };
646
-
647
- // src/dev/dev-server.ts
648
- var MIME_TYPES = {
649
- html: "text/html; charset=utf-8",
650
- css: "text/css; charset=utf-8",
651
- js: "application/javascript; charset=utf-8",
652
- mjs: "application/javascript; charset=utf-8",
653
- json: "application/json; charset=utf-8",
654
- png: "image/png",
655
- jpg: "image/jpeg",
656
- jpeg: "image/jpeg",
657
- gif: "image/gif",
658
- svg: "image/svg+xml",
659
- ico: "image/x-icon",
660
- webp: "image/webp",
661
- woff: "font/woff",
662
- woff2: "font/woff2",
663
- ttf: "font/ttf",
664
- eot: "application/vnd.ms-fontobject",
665
- mp4: "video/mp4",
666
- webm: "video/webm",
667
- mp3: "audio/mpeg",
668
- wav: "audio/wav",
669
- pdf: "application/pdf",
670
- txt: "text/plain; charset=utf-8",
671
- xml: "application/xml; charset=utf-8"
672
- };
673
- function generateHmrScript(port) {
674
- return `
675
- <script data-clawfire-hmr>
676
- (function() {
677
- var dot, status;
678
- function createBanner() {
679
- var banner = document.createElement('div');
680
- banner.id = 'clawfire-dev-banner';
681
- 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;';
682
- 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>';
683
- document.body.appendChild(banner);
684
- dot = document.getElementById('clawfire-dot');
685
- status = document.getElementById('clawfire-status');
686
- }
687
-
688
- function refreshCss() {
689
- var links = document.querySelectorAll('link[rel="stylesheet"]');
690
- for (var i = 0; i < links.length; i++) {
691
- var href = links[i].getAttribute('href');
692
- if (href) {
693
- var url = new URL(href, location.href);
694
- url.searchParams.set('_hmr', Date.now().toString());
695
- links[i].setAttribute('href', url.toString());
696
- }
697
- }
698
- var styles = document.querySelectorAll('style[data-href]');
699
- for (var j = 0; j < styles.length; j++) {
700
- var dataHref = styles[j].getAttribute('data-href');
701
- if (dataHref) {
702
- fetch(dataHref + '?_hmr=' + Date.now())
703
- .then(function(r) { return r.text(); })
704
- .then(function(css) { styles[j].textContent = css; })
705
- .catch(function() {});
706
- }
707
- }
708
- if (status) status.textContent = 'CSS updated';
709
- setTimeout(function() { if (status) status.textContent = 'Connected'; }, 1500);
710
- }
711
-
712
- var reconnectTimer;
713
- function connect() {
714
- var es = new EventSource('http://localhost:${port}/__dev/events');
715
- es.onopen = function() {
716
- if (!dot) createBanner();
717
- dot.style.background = '#22c55e';
718
- status.textContent = 'Connected';
719
- if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
720
- };
721
- es.onmessage = function(e) {
722
- try {
723
- var data = JSON.parse(e.data);
724
- if (data.type === 'connected') return;
725
- if (data.type === 'css-change') {
726
- refreshCss();
727
- return;
728
- }
729
- if (data.type === 'error') {
730
- if (dot) dot.style.background = '#ef4444';
731
- if (status) status.textContent = 'Error: ' + (data.message || 'reload failed');
732
- return;
733
- }
734
- // frontend-change, route-change, etc \u2192 full reload
735
- if (dot) dot.style.background = '#eab308';
736
- if (status) status.textContent = 'Reloading...';
737
- setTimeout(function() { location.reload(); }, 300);
738
- } catch(err) {}
739
- };
740
- es.onerror = function() {
741
- es.close();
742
- if (dot) dot.style.background = '#ef4444';
743
- if (status) status.textContent = 'Disconnected';
744
- reconnectTimer = setTimeout(connect, 2000);
745
- };
746
- }
747
-
748
- if (document.readyState === 'loading') {
749
- document.addEventListener('DOMContentLoaded', function() { connect(); });
750
- } else {
751
- connect();
752
- }
753
- })();
754
- </script>`;
755
- }
756
- var DevServer = class {
757
- frontendServer = null;
758
- apiServer = null;
759
- router;
760
- watcher = null;
761
- frontendSseClients = [];
762
- apiSseClients = [];
763
- sseIdCounter = 0;
764
- options;
765
- routesDir;
766
- schemasDir;
767
- publicDir;
768
- playgroundHtml = "";
769
- importCounter = 0;
770
- isReloading = false;
771
- constructor(options = {}) {
772
- this.options = {
773
- projectDir: options.projectDir || process.cwd(),
774
- port: options.port || 3e3,
775
- apiPort: options.apiPort || 3456,
776
- routerOptions: options.routerOptions || {},
777
- hotReload: options.hotReload !== false,
778
- debounceMs: options.debounceMs || 150,
779
- onSetupRoutes: options.onSetupRoutes || (() => {
780
- })
781
- };
782
- this.routesDir = resolve(this.options.projectDir, "app/routes");
783
- this.schemasDir = resolve(this.options.projectDir, "app/schemas");
784
- this.publicDir = resolve(this.options.projectDir, "public");
785
- this.router = createRouter({
786
- cors: ["*"],
787
- rateLimit: 0,
788
- ...this.options.routerOptions
789
- });
790
- }
791
- // ─── Lifecycle ──────────────────────────────────────────────────────
792
- async start() {
793
- await this.loadRoutes();
794
- this.regeneratePlayground();
795
- this.apiServer = http.createServer((req, res) => this.handleApiRequest(req, res));
796
- this.frontendServer = http.createServer((req, res) => this.handleFrontendRequest(req, res));
797
- if (this.options.hotReload) {
798
- this.startWatcher();
799
- }
800
- await new Promise((resolve2, reject) => {
801
- this.apiServer.listen(this.options.apiPort, () => resolve2());
802
- this.apiServer.on("error", reject);
803
- });
804
- await new Promise((resolve2, reject) => {
805
- this.frontendServer.listen(this.options.port, () => resolve2());
806
- this.frontendServer.on("error", reject);
807
- });
808
- this.printStartupBanner();
809
- }
810
- async stop() {
811
- this.watcher?.close();
812
- for (const client of [...this.frontendSseClients, ...this.apiSseClients]) {
813
- client.res.end();
814
- }
815
- this.frontendSseClients = [];
816
- this.apiSseClients = [];
817
- this.router.destroy();
818
- if (this.apiServer) {
819
- await new Promise((resolve2) => {
820
- this.apiServer.close(() => resolve2());
821
- });
822
- }
823
- if (this.frontendServer) {
824
- await new Promise((resolve2) => {
825
- this.frontendServer.close(() => resolve2());
826
- });
827
- }
828
- }
829
- // ─── Route Loading ─────────────────────────────────────────────────
830
- async loadRoutes() {
831
- this.router.destroy();
832
- this.router = createRouter({
833
- cors: ["*"],
834
- rateLimit: 0,
835
- ...this.options.routerOptions
836
- });
837
- if (this.options.onSetupRoutes) {
838
- await this.options.onSetupRoutes(this.router);
839
- if (this.router.getRoutes().length > 0) return;
840
- }
841
- if (!existsSync2(this.routesDir)) return;
842
- const discovered = discoverRoutes(this.routesDir);
843
- for (const route of discovered) {
844
- try {
845
- const fullPath = resolve(this.routesDir, route.filePath);
846
- const fileUrl = pathToFileURL(fullPath).href;
847
- const mod = await import(`${fileUrl}?v=${++this.importCounter}`);
848
- const contract = mod.default;
849
- if (contract && typeof contract.handler === "function" && contract.input && contract.output && contract.meta) {
850
- this.router.register(route.apiPath, contract);
851
- }
852
- } catch (err) {
853
- logger.warn(`Failed to load route: ${route.filePath}`, err);
854
- }
855
- }
856
- }
857
- async reloadRoutes(event) {
858
- if (this.isReloading) return;
859
- this.isReloading = true;
860
- const relPath = relative(this.options.projectDir, event.filePath);
861
- const timestamp = (/* @__PURE__ */ new Date()).toLocaleTimeString();
862
- console.log(`
863
- \x1B[33m[${timestamp}]\x1B[0m \x1B[36m${relPath}\x1B[0m changed`);
864
- console.log(" Reloading routes...");
865
- try {
866
- await this.loadRoutes();
867
- this.regeneratePlayground();
868
- const routeCount = this.router.getRoutes().length;
869
- console.log(` \x1B[32m\u2713\x1B[0m ${routeCount} routes loaded`);
870
- this.broadcastSSE(this.apiSseClients, {
871
- type: event.type,
872
- file: relPath,
873
- timestamp: event.timestamp,
874
- routes: routeCount
875
- });
876
- this.broadcastSSE(this.frontendSseClients, {
877
- type: event.type,
878
- file: relPath,
879
- timestamp: event.timestamp,
880
- routes: routeCount
881
- });
882
- } catch (err) {
883
- console.log(` \x1B[31m\u2717\x1B[0m Reload failed:`, err);
884
- const errorData = {
885
- type: "error",
886
- file: relPath,
887
- message: err instanceof Error ? err.message : "Unknown error"
888
- };
889
- this.broadcastSSE(this.apiSseClients, errorData);
890
- this.broadcastSSE(this.frontendSseClients, errorData);
891
- } finally {
892
- this.isReloading = false;
893
- }
894
- }
895
- // ─── Frontend Change Handler ───────────────────────────────────────
896
- handleFrontendChange(event) {
897
- const relPath = relative(this.options.projectDir, event.filePath);
898
- const timestamp = (/* @__PURE__ */ new Date()).toLocaleTimeString();
899
- if (event.type === "css-change") {
900
- console.log(`
901
- \x1B[33m[${timestamp}]\x1B[0m \x1B[35m${relPath}\x1B[0m CSS updated`);
902
- this.broadcastSSE(this.frontendSseClients, {
903
- type: "css-change",
904
- file: relPath,
905
- timestamp: event.timestamp
906
- });
907
- } else {
908
- console.log(`
909
- \x1B[33m[${timestamp}]\x1B[0m \x1B[35m${relPath}\x1B[0m changed \u2192 reload`);
910
- this.broadcastSSE(this.frontendSseClients, {
911
- type: "frontend-change",
912
- file: relPath,
913
- timestamp: event.timestamp
914
- });
915
- }
916
- }
917
- // ─── File Watcher ──────────────────────────────────────────────────
918
- startWatcher() {
919
- this.watcher = new FileWatcher(this.options.debounceMs);
920
- if (existsSync2(this.routesDir)) {
921
- this.watcher.watchDir(this.routesDir, "route-change");
922
- }
923
- if (existsSync2(this.schemasDir)) {
924
- this.watcher.watchDir(this.schemasDir, "schema-change");
925
- }
926
- const configFile = resolve(this.options.projectDir, "clawfire.config.ts");
927
- if (existsSync2(configFile)) {
928
- this.watcher.watchFile(configFile, "config-change");
929
- }
930
- if (existsSync2(this.publicDir)) {
931
- this.watcher.watchDirFrontend(this.publicDir);
932
- }
933
- this.watcher.on("route-change", (event) => this.reloadRoutes(event));
934
- this.watcher.on("schema-change", (event) => this.reloadRoutes(event));
935
- this.watcher.on("config-change", (event) => this.reloadRoutes(event));
936
- this.watcher.on("frontend-change", (event) => this.handleFrontendChange(event));
937
- this.watcher.on("css-change", (event) => this.handleFrontendChange(event));
938
- }
939
- // ─── SSE (Server-Sent Events) ──────────────────────────────────────
940
- handleSSE(req, res, clients) {
941
- res.writeHead(200, {
942
- "Content-Type": "text/event-stream",
943
- "Cache-Control": "no-cache",
944
- "Connection": "keep-alive",
945
- "Access-Control-Allow-Origin": "*"
946
- });
947
- const clientId = ++this.sseIdCounter;
948
- const client = { id: clientId, res };
949
- clients.push(client);
950
- res.write(`data: ${JSON.stringify({ type: "connected", id: clientId })}
951
-
952
- `);
953
- req.on("close", () => {
954
- const idx = clients.indexOf(client);
955
- if (idx !== -1) clients.splice(idx, 1);
956
- });
957
- }
958
- broadcastSSE(clients, data) {
959
- const message = `data: ${JSON.stringify(data)}
960
-
961
- `;
962
- for (const client of clients) {
963
- try {
964
- client.res.write(message);
965
- } catch {
966
- }
967
- }
968
- }
969
- // ─── Playground ────────────────────────────────────────────────────
970
- regeneratePlayground() {
971
- const baseHtml = generatePlaygroundHtml({
972
- title: "Clawfire Dev Playground",
973
- apiBaseUrl: `http://localhost:${this.options.apiPort}`
974
- });
975
- const liveReloadScript = `
976
- <script>
977
- (function() {
978
- var banner = document.createElement('div');
979
- banner.id = 'dev-banner';
980
- 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;';
981
- 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>';
982
- document.body.appendChild(banner);
983
-
984
- var reconnectTimer;
985
- function connect() {
986
- var es = new EventSource('http://localhost:${this.options.apiPort}/__dev/events');
987
- es.onopen = function() {
988
- document.getElementById('dev-dot').style.background = '#22c55e';
989
- document.getElementById('dev-status').textContent = 'Connected';
990
- if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
991
- };
992
- es.onmessage = function(e) {
993
- try {
994
- var data = JSON.parse(e.data);
995
- if (data.type === 'connected') return;
996
- if (data.type === 'error') {
997
- document.getElementById('dev-dot').style.background = '#ef4444';
998
- document.getElementById('dev-status').textContent = 'Error: ' + (data.message || 'reload failed');
999
- return;
1000
- }
1001
- document.getElementById('dev-dot').style.background = '#eab308';
1002
- document.getElementById('dev-status').textContent = 'Reloading...';
1003
- setTimeout(function() { window.location.reload(); }, 300);
1004
- } catch(err) {}
1005
- };
1006
- es.onerror = function() {
1007
- es.close();
1008
- document.getElementById('dev-dot').style.background = '#ef4444';
1009
- document.getElementById('dev-status').textContent = 'Disconnected \u2014 reconnecting...';
1010
- reconnectTimer = setTimeout(connect, 2000);
1011
- };
1012
- }
1013
- connect();
1014
- })();
1015
- </script>`;
1016
- this.playgroundHtml = baseHtml.replace("</body>", liveReloadScript + "\n</body>");
1017
- }
1018
- // ─── HMR Script Injection ─────────────────────────────────────────
1019
- injectHmrScript(html) {
1020
- const script = generateHmrScript(this.options.port);
1021
- if (html.includes("</body>")) {
1022
- return html.replace("</body>", script + "\n</body>");
1023
- }
1024
- return html + script;
1025
- }
1026
- // ─── Static File Serving ──────────────────────────────────────────
1027
- serveStaticFile(filePath, res) {
1028
- if (!existsSync2(filePath)) return false;
1029
- try {
1030
- const content = readFileSync(filePath);
1031
- const ext = extname2(filePath).slice(1).toLowerCase();
1032
- const mime = MIME_TYPES[ext] || "application/octet-stream";
1033
- if (ext === "html") {
1034
- const html = content.toString("utf-8");
1035
- const injected = this.injectHmrScript(html);
1036
- res.writeHead(200, { "Content-Type": mime });
1037
- res.end(injected);
1038
- return true;
1039
- }
1040
- res.writeHead(200, { "Content-Type": mime });
1041
- res.end(content);
1042
- return true;
1043
- } catch {
1044
- return false;
1045
- }
1046
- }
1047
- // ─── API Proxy ────────────────────────────────────────────────────
1048
- proxyToApiServer(req, res) {
1049
- const proxyReq = http.request(
1050
- {
1051
- hostname: "127.0.0.1",
1052
- port: this.options.apiPort,
1053
- path: req.url,
1054
- method: req.method,
1055
- headers: {
1056
- ...req.headers,
1057
- host: `localhost:${this.options.apiPort}`
1058
- }
1059
- },
1060
- (proxyRes) => {
1061
- res.writeHead(proxyRes.statusCode || 500, proxyRes.headers);
1062
- proxyRes.pipe(res, { end: true });
1063
- }
1064
- );
1065
- proxyReq.on("error", (err) => {
1066
- res.writeHead(502, { "Content-Type": "application/json" });
1067
- res.end(JSON.stringify({ error: { code: "PROXY_ERROR", message: "API server unavailable" } }));
1068
- });
1069
- req.pipe(proxyReq, { end: true });
1070
- }
1071
- // ─── Frontend Request Handler ─────────────────────────────────────
1072
- handleFrontendRequest(req, res) {
1073
- const url = new URL(req.url || "/", `http://localhost:${this.options.port}`);
1074
- if (url.pathname === "/__dev/events") {
1075
- this.handleSSE(req, res, this.frontendSseClients);
1076
- return;
1077
- }
1078
- if (url.pathname.startsWith("/api")) {
1079
- this.proxyToApiServer(req, res);
1080
- return;
1081
- }
1082
- if (url.pathname.startsWith("/__")) {
1083
- this.proxyToApiServer(req, res);
1084
- return;
1085
- }
1086
- const requestedPath = url.pathname === "/" ? "index.html" : url.pathname.slice(1);
1087
- const filePath = resolve(this.publicDir, requestedPath);
1088
- if (!filePath.startsWith(this.publicDir)) {
1089
- res.writeHead(403);
1090
- res.end("Forbidden");
1091
- return;
1092
- }
1093
- if (this.serveStaticFile(filePath, res)) {
1094
- return;
1095
- }
1096
- const indexPath = resolve(this.publicDir, "index.html");
1097
- if (existsSync2(indexPath)) {
1098
- this.serveStaticFile(indexPath, res);
1099
- return;
1100
- }
1101
- res.writeHead(404);
1102
- res.end("Not found");
1103
- }
1104
- // ─── API Request Handler ──────────────────────────────────────────
1105
- handleApiRequest(req, res) {
1106
- const url = new URL(req.url || "/", `http://localhost:${this.options.apiPort}`);
1107
- if (url.pathname === "/__dev/events") {
1108
- this.handleSSE(req, res, this.apiSseClients);
1109
- return;
1110
- }
1111
- if (url.pathname === "/" || url.pathname === "/__playground") {
1112
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1113
- res.end(this.playgroundHtml);
1114
- return;
1115
- }
1116
- if (url.pathname.startsWith("/api")) {
1117
- res.setHeader("Access-Control-Allow-Origin", "*");
1118
- res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
1119
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
1120
- if (req.method === "OPTIONS") {
1121
- res.writeHead(204);
1122
- res.end();
1123
- return;
1124
- }
1125
- let body = "";
1126
- req.on("data", (chunk) => {
1127
- body += chunk;
1128
- });
1129
- req.on("end", async () => {
1130
- let parsed = {};
1131
- try {
1132
- parsed = body ? JSON.parse(body) : {};
1133
- } catch {
1134
- }
1135
- await this.router.handleRequest(
1136
- {
1137
- method: req.method,
1138
- path: url.pathname,
1139
- body: parsed,
1140
- headers: req.headers,
1141
- ip: req.socket.remoteAddress || "127.0.0.1"
1142
- },
1143
- {
1144
- set(h) {
1145
- for (const [k, v] of Object.entries(h)) {
1146
- try {
1147
- res.setHeader(k, v);
1148
- } catch {
1149
- }
1150
- }
1151
- },
1152
- status(code) {
1153
- return {
1154
- json(data) {
1155
- res.writeHead(code, { "Content-Type": "application/json" });
1156
- res.end(JSON.stringify(data));
1157
- },
1158
- send(data) {
1159
- res.writeHead(code);
1160
- res.end(data);
1161
- },
1162
- end() {
1163
- res.writeHead(code);
1164
- res.end();
1165
- }
1166
- };
1167
- }
1168
- }
1169
- );
1170
- });
1171
- return;
1172
- }
1173
- res.writeHead(404);
1174
- res.end("Not found");
1175
- }
1176
- // ─── Startup Banner ────────────────────────────────────────────────
1177
- printStartupBanner() {
1178
- const routes = this.router.getRoutes();
1179
- const watching = this.options.hotReload;
1180
- console.log("");
1181
- console.log(" \x1B[1m\x1B[33m\u26A1 Clawfire Dev Server\x1B[0m");
1182
- 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");
1183
- console.log(` \x1B[36mApp\x1B[0m : http://localhost:${this.options.port}`);
1184
- console.log(` \x1B[36mAPI\x1B[0m : http://localhost:${this.options.apiPort}/api/...`);
1185
- console.log(` \x1B[36mPlayground\x1B[0m : http://localhost:${this.options.apiPort}`);
1186
- console.log("");
1187
- console.log(` \x1B[32mRoutes (${routes.length})\x1B[0m:`);
1188
- for (const route of routes) {
1189
- const auth = route.contract.meta.auth || "public";
1190
- const authColor = auth === "public" ? "32" : auth === "authenticated" ? "34" : auth === "role" ? "33" : "31";
1191
- console.log(` POST /api\x1B[1m${route.path}\x1B[0m \x1B[${authColor}m[${auth}]\x1B[0m \x1B[2m${route.contract.meta.description}\x1B[0m`);
1192
- }
1193
- console.log("");
1194
- if (watching) {
1195
- console.log(` \x1B[35mHot Reload\x1B[0m : \x1B[32mON\x1B[0m`);
1196
- console.log(` \x1B[2mWatching: app/routes/, app/schemas/, public/\x1B[0m`);
1197
- } else {
1198
- console.log(` \x1B[35mHot Reload\x1B[0m : OFF`);
1199
- }
1200
- console.log(`
1201
- \x1B[2mPress Ctrl+C to stop\x1B[0m
1202
- `);
1203
- }
1204
- };
1205
- async function startDevServer(options) {
1206
- const server = new DevServer(options);
1207
- await server.start();
1208
- const shutdown = async () => {
1209
- console.log("\n Shutting down...");
1210
- await server.stop();
1211
- process.exit(0);
1212
- };
1213
- process.on("SIGINT", shutdown);
1214
- process.on("SIGTERM", shutdown);
1215
- return server;
1216
- }
1217
- export {
1218
- DevServer,
1219
- startDevServer
1220
- };