clawfire 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +182 -0
  2. package/dist/admin.cjs +309 -0
  3. package/dist/admin.cjs.map +1 -0
  4. package/dist/admin.d.cts +93 -0
  5. package/dist/admin.d.ts +93 -0
  6. package/dist/admin.js +274 -0
  7. package/dist/admin.js.map +1 -0
  8. package/dist/auth-DQ3cifhb.d.cts +55 -0
  9. package/dist/auth-DtnUPbXT.d.ts +55 -0
  10. package/dist/chunk-37Y2XI7X.js +75 -0
  11. package/dist/chunk-YGIPORYL.js +339 -0
  12. package/dist/cli.js +241 -0
  13. package/dist/client.cjs +97 -0
  14. package/dist/client.cjs.map +1 -0
  15. package/dist/client.d.cts +4 -0
  16. package/dist/client.d.ts +4 -0
  17. package/dist/client.js +68 -0
  18. package/dist/client.js.map +1 -0
  19. package/dist/codegen.cjs +648 -0
  20. package/dist/codegen.cjs.map +1 -0
  21. package/dist/codegen.d.cts +25 -0
  22. package/dist/codegen.d.ts +25 -0
  23. package/dist/codegen.js +617 -0
  24. package/dist/codegen.js.map +1 -0
  25. package/dist/config-QMBJRn9G.d.cts +46 -0
  26. package/dist/config-QMBJRn9G.d.ts +46 -0
  27. package/dist/dev-server-QAVWINAT.js +973 -0
  28. package/dist/dev.cjs +1388 -0
  29. package/dist/dev.cjs.map +1 -0
  30. package/dist/dev.d.cts +111 -0
  31. package/dist/dev.d.ts +111 -0
  32. package/dist/dev.js +1349 -0
  33. package/dist/dev.js.map +1 -0
  34. package/dist/discover-BPMAZFBD.js +9 -0
  35. package/dist/discover-DYNqz_ym.d.cts +28 -0
  36. package/dist/discover-DYNqz_ym.d.ts +28 -0
  37. package/dist/errors-s_mP7rs9.d.cts +33 -0
  38. package/dist/errors-s_mP7rs9.d.ts +33 -0
  39. package/dist/functions.cjs +1156 -0
  40. package/dist/functions.cjs.map +1 -0
  41. package/dist/functions.d.cts +115 -0
  42. package/dist/functions.d.ts +115 -0
  43. package/dist/functions.js +1108 -0
  44. package/dist/functions.js.map +1 -0
  45. package/dist/hosting-7WVFHAYJ.js +85 -0
  46. package/dist/html-PCUCJGBH.js +7 -0
  47. package/dist/index.cjs +349 -0
  48. package/dist/index.cjs.map +1 -0
  49. package/dist/index.d.cts +22 -0
  50. package/dist/index.d.ts +22 -0
  51. package/dist/index.js +312 -0
  52. package/dist/index.js.map +1 -0
  53. package/dist/playground.cjs +364 -0
  54. package/dist/playground.cjs.map +1 -0
  55. package/dist/playground.d.cts +12 -0
  56. package/dist/playground.d.ts +12 -0
  57. package/dist/playground.js +337 -0
  58. package/dist/playground.js.map +1 -0
  59. package/dist/router-BVB_I-tu.d.ts +65 -0
  60. package/dist/router-Cikk8Heq.d.cts +65 -0
  61. package/dist/schema-BJsictSV.d.cts +172 -0
  62. package/dist/schema-BJsictSV.d.ts +172 -0
  63. package/package.json +150 -0
  64. package/templates/CLAUDE.md +71 -0
  65. package/templates/app/routes/auth/login.ts +35 -0
  66. package/templates/app/routes/health.ts +20 -0
  67. package/templates/app/schemas/user.ts +26 -0
  68. package/templates/clawfire.config.ts +25 -0
  69. package/templates/functions/index.ts +43 -0
  70. package/templates/starter/.claude/skills/clawfire-api/SKILL.md +131 -0
  71. package/templates/starter/.claude/skills/clawfire-auth/SKILL.md +111 -0
  72. package/templates/starter/.claude/skills/clawfire-deploy/SKILL.md +95 -0
  73. package/templates/starter/.claude/skills/clawfire-diagnose/SKILL.md +99 -0
  74. package/templates/starter/.claude/skills/clawfire-model/SKILL.md +128 -0
  75. package/templates/starter/CLAUDE.md +227 -0
  76. package/templates/starter/app/routes/health.ts +20 -0
  77. package/templates/starter/app/routes/todos/create.ts +25 -0
  78. package/templates/starter/app/routes/todos/delete.ts +20 -0
  79. package/templates/starter/app/routes/todos/list.ts +26 -0
  80. package/templates/starter/app/routes/todos/update.ts +32 -0
  81. package/templates/starter/app/schemas/todo.ts +16 -0
  82. package/templates/starter/app/store.ts +56 -0
  83. package/templates/starter/clawfire.config.ts +25 -0
  84. package/templates/starter/dev.ts +12 -0
  85. package/templates/starter/package.json +19 -0
  86. package/templates/starter/public/index.html +365 -0
  87. package/templates/starter/tsconfig.json +17 -0
package/dist/dev.cjs ADDED
@@ -0,0 +1,1388 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/dev.ts
31
+ var dev_exports = {};
32
+ __export(dev_exports, {
33
+ DevServer: () => DevServer,
34
+ FileWatcher: () => FileWatcher,
35
+ startDevServer: () => startDevServer
36
+ });
37
+ module.exports = __toCommonJS(dev_exports);
38
+
39
+ // src/dev/dev-server.ts
40
+ var import_node_http = __toESM(require("http"), 1);
41
+ var import_node_path = require("path");
42
+ var import_node_fs = require("fs");
43
+ var import_node_url = require("url");
44
+
45
+ // src/core/schema.ts
46
+ var import_zod = require("zod");
47
+ function zodToJsonSchema(schema) {
48
+ return extractZodShape(schema);
49
+ }
50
+ function extractZodShape(schema) {
51
+ const def = schema._def;
52
+ if (!def) return { type: "unknown" };
53
+ switch (def.typeName) {
54
+ case "ZodObject": {
55
+ const shape = schema.shape;
56
+ const properties = {};
57
+ const required = [];
58
+ for (const [key, value] of Object.entries(shape)) {
59
+ properties[key] = extractZodShape(value);
60
+ if (!value.isOptional?.()) {
61
+ const innerDef = value._def;
62
+ if (innerDef?.typeName !== "ZodOptional" && innerDef?.typeName !== "ZodDefault") {
63
+ required.push(key);
64
+ }
65
+ }
66
+ }
67
+ return { type: "object", properties, ...required.length > 0 ? { required } : {} };
68
+ }
69
+ case "ZodString":
70
+ return { type: "string", ...def.checks?.length ? extractStringChecks(def.checks) : {} };
71
+ case "ZodNumber":
72
+ return { type: "number" };
73
+ case "ZodBoolean":
74
+ return { type: "boolean" };
75
+ case "ZodArray":
76
+ return { type: "array", items: extractZodShape(def.type) };
77
+ case "ZodEnum":
78
+ return { type: "string", enum: def.values };
79
+ case "ZodOptional":
80
+ return { ...extractZodShape(def.innerType), optional: true };
81
+ case "ZodDefault":
82
+ return { ...extractZodShape(def.innerType), default: def.defaultValue() };
83
+ case "ZodNullable":
84
+ return { ...extractZodShape(def.innerType), nullable: true };
85
+ case "ZodLiteral":
86
+ return { type: typeof def.value, const: def.value };
87
+ case "ZodUnion":
88
+ return { oneOf: def.options.map((o) => extractZodShape(o)) };
89
+ case "ZodRecord":
90
+ return { type: "object", additionalProperties: extractZodShape(def.valueType) };
91
+ case "ZodDate":
92
+ return { type: "string", format: "date-time" };
93
+ default:
94
+ return { type: "unknown" };
95
+ }
96
+ }
97
+ function extractStringChecks(checks) {
98
+ const result = {};
99
+ for (const check of checks) {
100
+ switch (check.kind) {
101
+ case "min":
102
+ result.minLength = check.value;
103
+ break;
104
+ case "max":
105
+ result.maxLength = check.value;
106
+ break;
107
+ case "email":
108
+ result.format = "email";
109
+ break;
110
+ case "url":
111
+ result.format = "uri";
112
+ break;
113
+ case "uuid":
114
+ result.format = "uuid";
115
+ break;
116
+ }
117
+ }
118
+ return result;
119
+ }
120
+ function contractToManifest(path, contract) {
121
+ return {
122
+ path,
123
+ method: "POST",
124
+ meta: contract.meta,
125
+ inputSchema: zodToJsonSchema(contract.input),
126
+ outputSchema: zodToJsonSchema(contract.output)
127
+ };
128
+ }
129
+
130
+ // src/core/errors.ts
131
+ var HTTP_STATUS_MAP = {
132
+ VALIDATION_ERROR: 400,
133
+ UNAUTHORIZED: 401,
134
+ FORBIDDEN: 403,
135
+ NOT_FOUND: 404,
136
+ CONFLICT: 409,
137
+ RATE_LIMITED: 429,
138
+ REAUTH_REQUIRED: 401,
139
+ INTERNAL_ERROR: 500,
140
+ SERVICE_UNAVAILABLE: 503
141
+ };
142
+ var ClawfireError = class extends Error {
143
+ code;
144
+ statusCode;
145
+ details;
146
+ constructor(code, message, details) {
147
+ super(message);
148
+ this.name = "ClawfireError";
149
+ this.code = code;
150
+ this.statusCode = HTTP_STATUS_MAP[code];
151
+ this.details = details;
152
+ }
153
+ toJSON() {
154
+ return {
155
+ error: {
156
+ code: this.code,
157
+ message: this.message,
158
+ ...this.details ? { details: this.details } : {}
159
+ }
160
+ };
161
+ }
162
+ };
163
+ var Errors = {
164
+ validation: (message, details) => new ClawfireError("VALIDATION_ERROR", message, details),
165
+ unauthorized: (message = "Authentication required") => new ClawfireError("UNAUTHORIZED", message),
166
+ forbidden: (message = "Insufficient permissions") => new ClawfireError("FORBIDDEN", message),
167
+ notFound: (message = "Resource not found") => new ClawfireError("NOT_FOUND", message),
168
+ conflict: (message) => new ClawfireError("CONFLICT", message),
169
+ rateLimited: (message = "Too many requests") => new ClawfireError("RATE_LIMITED", message),
170
+ reauthRequired: (message = "Re-authentication required for this action") => new ClawfireError("REAUTH_REQUIRED", message),
171
+ internal: (message = "Internal server error") => new ClawfireError("INTERNAL_ERROR", message),
172
+ unavailable: (message = "Service temporarily unavailable") => new ClawfireError("SERVICE_UNAVAILABLE", message)
173
+ };
174
+
175
+ // src/core/logger.ts
176
+ var SENSITIVE_FIELDS = [
177
+ "password",
178
+ "token",
179
+ "secret",
180
+ "apiKey",
181
+ "api_key",
182
+ "authorization",
183
+ "credit_card",
184
+ "creditCard",
185
+ "ssn",
186
+ "cardNumber",
187
+ "card_number",
188
+ "cvv",
189
+ "pin"
190
+ ];
191
+ var LOG_LEVELS = {
192
+ debug: 0,
193
+ info: 1,
194
+ warn: 2,
195
+ error: 3
196
+ };
197
+ var currentLevel = "info";
198
+ function shouldLog(level) {
199
+ return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
200
+ }
201
+ function maskSensitive(obj) {
202
+ if (obj === null || obj === void 0) return obj;
203
+ if (typeof obj === "string") return obj;
204
+ if (typeof obj !== "object") return obj;
205
+ if (Array.isArray(obj)) {
206
+ return obj.map(maskSensitive);
207
+ }
208
+ const masked = {};
209
+ for (const [key, value] of Object.entries(obj)) {
210
+ if (SENSITIVE_FIELDS.some((f) => key.toLowerCase().includes(f.toLowerCase()))) {
211
+ masked[key] = "***MASKED***";
212
+ } else if (typeof value === "object" && value !== null) {
213
+ masked[key] = maskSensitive(value);
214
+ } else {
215
+ masked[key] = value;
216
+ }
217
+ }
218
+ return masked;
219
+ }
220
+ function formatLog(level, message, data) {
221
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
222
+ const prefix = `[${timestamp}] [CLAWFIRE] [${level.toUpperCase()}]`;
223
+ if (data !== void 0) {
224
+ return `${prefix} ${message} ${JSON.stringify(maskSensitive(data))}`;
225
+ }
226
+ return `${prefix} ${message}`;
227
+ }
228
+ var logger = {
229
+ debug(message, data) {
230
+ if (shouldLog("debug")) console.debug(formatLog("debug", message, data));
231
+ },
232
+ info(message, data) {
233
+ if (shouldLog("info")) console.info(formatLog("info", message, data));
234
+ },
235
+ warn(message, data) {
236
+ if (shouldLog("warn")) console.warn(formatLog("warn", message, data));
237
+ },
238
+ error(message, data) {
239
+ if (shouldLog("error")) console.error(formatLog("error", message, data));
240
+ }
241
+ };
242
+
243
+ // src/firebase/auth.ts
244
+ async function verifyToken(auth, idToken) {
245
+ const decoded = await auth.verifyIdToken(idToken);
246
+ return {
247
+ uid: decoded.uid,
248
+ email: decoded.email,
249
+ emailVerified: decoded.email_verified,
250
+ role: decoded.role || decoded.customClaims?.role,
251
+ customClaims: decoded,
252
+ token: idToken
253
+ };
254
+ }
255
+ async function verifyReauth(auth, idToken, maxAgeSeconds = 300) {
256
+ const decoded = await auth.verifyIdToken(idToken, true);
257
+ const authTime = decoded.auth_time * 1e3;
258
+ const now = Date.now();
259
+ if (now - authTime > maxAgeSeconds * 1e3) {
260
+ throw Errors.reauthRequired(
261
+ `Re-authentication required. Last auth was ${Math.floor((now - authTime) / 1e3)}s ago.`
262
+ );
263
+ }
264
+ return {
265
+ uid: decoded.uid,
266
+ email: decoded.email,
267
+ emailVerified: decoded.email_verified,
268
+ role: decoded.role || decoded.customClaims?.role,
269
+ customClaims: decoded,
270
+ token: idToken
271
+ };
272
+ }
273
+ function extractBearerToken(authHeader) {
274
+ if (!authHeader) return null;
275
+ const parts = authHeader.split(" ");
276
+ if (parts.length !== 2 || parts[0] !== "Bearer") return null;
277
+ return parts[1];
278
+ }
279
+ function checkAuthLevel(authCtx, level, roles, reauthenticated) {
280
+ switch (level) {
281
+ case "public":
282
+ return;
283
+ // 누구나 접근 가능
284
+ case "authenticated":
285
+ if (!authCtx) throw Errors.unauthorized();
286
+ return;
287
+ case "role":
288
+ if (!authCtx) throw Errors.unauthorized();
289
+ if (!roles || roles.length === 0) return;
290
+ if (!authCtx.role || !roles.includes(authCtx.role)) {
291
+ throw Errors.forbidden(`Required role: ${roles.join(" or ")}`);
292
+ }
293
+ return;
294
+ case "reauth":
295
+ if (!authCtx) throw Errors.unauthorized();
296
+ if (!reauthenticated) {
297
+ throw Errors.reauthRequired();
298
+ }
299
+ return;
300
+ }
301
+ }
302
+
303
+ // src/routing/router.ts
304
+ var RateLimiter = class {
305
+ store = /* @__PURE__ */ new Map();
306
+ defaultLimit;
307
+ constructor(defaultLimit) {
308
+ this.defaultLimit = defaultLimit;
309
+ }
310
+ check(key, limit) {
311
+ const max = limit || this.defaultLimit;
312
+ const now = Date.now();
313
+ const entry = this.store.get(key);
314
+ if (!entry || now > entry.resetAt) {
315
+ this.store.set(key, { count: 1, resetAt: now + 6e4 });
316
+ return true;
317
+ }
318
+ if (entry.count >= max) {
319
+ return false;
320
+ }
321
+ entry.count++;
322
+ return true;
323
+ }
324
+ // 오래된 엔트리 정리
325
+ cleanup() {
326
+ const now = Date.now();
327
+ for (const [key, entry] of this.store) {
328
+ if (now > entry.resetAt) this.store.delete(key);
329
+ }
330
+ }
331
+ };
332
+ var ClawfireRouter = class {
333
+ routes = /* @__PURE__ */ new Map();
334
+ options;
335
+ rateLimiter;
336
+ cleanupInterval;
337
+ constructor(options = {}) {
338
+ this.options = options;
339
+ this.rateLimiter = new RateLimiter(options.rateLimit || 100);
340
+ this.cleanupInterval = setInterval(() => this.rateLimiter.cleanup(), 6e4);
341
+ }
342
+ /** 라우트 등록 */
343
+ register(path, contract) {
344
+ const normalizedPath = path.startsWith("/") ? path : `/${path}`;
345
+ this.routes.set(normalizedPath, { path: normalizedPath, contract });
346
+ logger.debug(`Route registered: ${normalizedPath}`);
347
+ return this;
348
+ }
349
+ /** 여러 라우트 한 번에 등록 */
350
+ registerAll(routes) {
351
+ for (const [path, contract] of Object.entries(routes)) {
352
+ this.register(path, contract);
353
+ }
354
+ return this;
355
+ }
356
+ /** 매니페스트 생성 */
357
+ getManifest() {
358
+ const apis = [];
359
+ for (const [path, route] of this.routes) {
360
+ apis.push(contractToManifest(path, route.contract));
361
+ }
362
+ return {
363
+ version: "1.0.0",
364
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
365
+ apis,
366
+ models: {}
367
+ };
368
+ }
369
+ /** HTTP 요청 핸들러 (Firebase Functions에서 사용) */
370
+ async handleRequest(req, res) {
371
+ const corsOrigins = this.options.cors || [];
372
+ const origin = req.headers?.origin || req.headers?.Origin || "";
373
+ if (corsOrigins.length > 0 && corsOrigins.includes(origin)) {
374
+ res.set({
375
+ "Access-Control-Allow-Origin": origin,
376
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
377
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
378
+ "Access-Control-Max-Age": "86400"
379
+ });
380
+ } else if (corsOrigins.length === 0) {
381
+ res.set({
382
+ "Access-Control-Allow-Origin": "",
383
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
384
+ "Access-Control-Allow-Headers": "Content-Type, Authorization"
385
+ });
386
+ }
387
+ if (req.method === "OPTIONS") {
388
+ res.status(204).end();
389
+ return;
390
+ }
391
+ res.set({
392
+ "X-Content-Type-Options": "nosniff",
393
+ "X-Frame-Options": "DENY",
394
+ "X-XSS-Protection": "1; mode=block",
395
+ "Content-Type": "application/json"
396
+ });
397
+ let routePath = req.path || req.url || "";
398
+ if (routePath.startsWith("/api")) {
399
+ routePath = routePath.slice(4);
400
+ }
401
+ if (routePath === "/__manifest") {
402
+ res.status(200).json(this.getManifest());
403
+ return;
404
+ }
405
+ if (req.method && req.method !== "POST") {
406
+ res.status(405).json({ error: { code: "METHOD_NOT_ALLOWED", message: "Only POST is allowed" } });
407
+ return;
408
+ }
409
+ const route = this.matchRoute(routePath);
410
+ if (!route) {
411
+ res.status(404).json({ error: { code: "NOT_FOUND", message: `API not found: ${routePath}` } });
412
+ return;
413
+ }
414
+ try {
415
+ const clientKey = req.ip || "unknown";
416
+ const rateLimit = route.contract.meta.rateLimit || this.options.rateLimit;
417
+ if (rateLimit && !this.rateLimiter.check(clientKey, rateLimit)) {
418
+ throw Errors.rateLimited();
419
+ }
420
+ let authCtx = null;
421
+ let reauthenticated = false;
422
+ const authHeader = req.headers?.authorization || req.headers?.Authorization;
423
+ if (authHeader && this.options.auth) {
424
+ const token = extractBearerToken(authHeader);
425
+ if (token) {
426
+ if (route.contract.meta.reauth || route.contract.meta.auth === "reauth") {
427
+ authCtx = await verifyReauth(this.options.auth, token);
428
+ reauthenticated = true;
429
+ } else {
430
+ authCtx = await verifyToken(this.options.auth, token);
431
+ }
432
+ }
433
+ }
434
+ if (route.contract.meta.auth) {
435
+ checkAuthLevel(authCtx, route.contract.meta.auth, route.contract.meta.roles, reauthenticated);
436
+ }
437
+ const rawInput = req.body || {};
438
+ const parsed = route.contract.input.safeParse(rawInput);
439
+ if (!parsed.success) {
440
+ throw Errors.validation("Invalid input", parsed.error.flatten());
441
+ }
442
+ const ctx = {
443
+ auth: authCtx,
444
+ reauthenticated,
445
+ headers: req.headers,
446
+ ip: req.ip
447
+ };
448
+ let result;
449
+ if (this.options.middleware && this.options.middleware.length > 0) {
450
+ result = await this.runMiddleware(
451
+ this.options.middleware,
452
+ parsed.data,
453
+ ctx,
454
+ () => route.contract.handler(parsed.data, ctx)
455
+ );
456
+ } else {
457
+ result = await route.contract.handler(parsed.data, ctx);
458
+ }
459
+ res.status(200).json({ data: result });
460
+ } catch (err) {
461
+ if (err instanceof ClawfireError) {
462
+ logger.warn(`API error [${err.code}]: ${err.message}`);
463
+ res.status(err.statusCode).json(err.toJSON());
464
+ } else {
465
+ logger.error("Unhandled error", err);
466
+ res.status(500).json({
467
+ error: {
468
+ code: "INTERNAL_ERROR",
469
+ message: "An unexpected error occurred"
470
+ }
471
+ });
472
+ }
473
+ }
474
+ }
475
+ /** 라우트 매칭 (동적 파라미터 지원) */
476
+ matchRoute(path) {
477
+ const exact = this.routes.get(path);
478
+ if (exact) return exact;
479
+ for (const [routePath, route] of this.routes) {
480
+ if (this.matchDynamicRoute(routePath, path)) {
481
+ return route;
482
+ }
483
+ }
484
+ return void 0;
485
+ }
486
+ matchDynamicRoute(pattern, actual) {
487
+ const patternParts = pattern.split("/").filter(Boolean);
488
+ const actualParts = actual.split("/").filter(Boolean);
489
+ if (patternParts.length !== actualParts.length) return false;
490
+ return patternParts.every(
491
+ (part, i) => part.startsWith(":") || part.startsWith("[") || part === actualParts[i]
492
+ );
493
+ }
494
+ /** 미들웨어 체인 실행 */
495
+ async runMiddleware(middlewares, input, ctx, handler) {
496
+ let index = 0;
497
+ const next = async () => {
498
+ if (index >= middlewares.length) {
499
+ return handler();
500
+ }
501
+ const mw = middlewares[index++];
502
+ return mw(input, ctx, next);
503
+ };
504
+ return next();
505
+ }
506
+ /** 등록된 라우트 목록 */
507
+ getRoutes() {
508
+ return Array.from(this.routes.values());
509
+ }
510
+ /** 리소스 정리 */
511
+ destroy() {
512
+ if (this.cleanupInterval) {
513
+ clearInterval(this.cleanupInterval);
514
+ }
515
+ }
516
+ };
517
+ function createRouter(options) {
518
+ return new ClawfireRouter(options);
519
+ }
520
+
521
+ // src/routing/discover.ts
522
+ var import_path = require("path");
523
+ var import_fs = require("fs");
524
+ function discoverRoutes(routesDir) {
525
+ if (!(0, import_fs.existsSync)(routesDir)) {
526
+ return [];
527
+ }
528
+ const routes = [];
529
+ scanDirectory(routesDir, routesDir, routes);
530
+ return routes.sort((a, b) => a.apiPath.localeCompare(b.apiPath));
531
+ }
532
+ function scanDirectory(baseDir, currentDir, routes) {
533
+ const entries = (0, import_fs.readdirSync)(currentDir);
534
+ for (const entry of entries) {
535
+ const fullPath = (0, import_path.join)(currentDir, entry);
536
+ const stat = (0, import_fs.statSync)(fullPath);
537
+ if (stat.isDirectory()) {
538
+ if (entry.startsWith(".") || entry === "node_modules") continue;
539
+ scanDirectory(baseDir, fullPath, routes);
540
+ } else if (stat.isFile()) {
541
+ if (!entry.endsWith(".ts") && !entry.endsWith(".js")) continue;
542
+ if (entry.startsWith("_")) continue;
543
+ if (entry.endsWith(".d.ts")) continue;
544
+ const relativePath = (0, import_path.relative)(baseDir, fullPath);
545
+ const route = filePathToRoute(relativePath);
546
+ routes.push(route);
547
+ }
548
+ }
549
+ }
550
+ function filePathToRoute(filePath) {
551
+ const params = [];
552
+ let routePath = filePath.replace(/\.(ts|js)$/, "");
553
+ routePath = routePath.replace(/\\/g, "/");
554
+ if (routePath.endsWith("/index") || routePath === "index") {
555
+ routePath = routePath.replace(/\/?index$/, "");
556
+ }
557
+ routePath = routePath.replace(/\[([^\]]+)\]/g, (_, param) => {
558
+ params.push(param);
559
+ return `:${param}`;
560
+ });
561
+ const apiPath = `/${routePath}`;
562
+ return {
563
+ filePath,
564
+ apiPath,
565
+ params
566
+ };
567
+ }
568
+
569
+ // src/playground/html.ts
570
+ function generatePlaygroundHtml(options) {
571
+ const title = options?.title || "Clawfire Playground";
572
+ const apiBaseUrl = options?.apiBaseUrl || "";
573
+ return `<!DOCTYPE html>
574
+ <html lang="en">
575
+ <head>
576
+ <meta charset="UTF-8">
577
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
578
+ <title>${title}</title>
579
+ <style>
580
+ :root {
581
+ --bg: #0a0a0a;
582
+ --surface: #141414;
583
+ --surface2: #1e1e1e;
584
+ --border: #2a2a2a;
585
+ --text: #e5e5e5;
586
+ --text2: #a3a3a3;
587
+ --accent: #f97316;
588
+ --accent2: #fb923c;
589
+ --green: #22c55e;
590
+ --red: #ef4444;
591
+ --blue: #3b82f6;
592
+ --yellow: #eab308;
593
+ --radius: 8px;
594
+ --font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
595
+ --mono: 'JetBrains Mono', 'Fira Code', monospace;
596
+ }
597
+ * { margin: 0; padding: 0; box-sizing: border-box; }
598
+ body { font-family: var(--font); background: var(--bg); color: var(--text); min-height: 100vh; }
599
+
600
+ .layout { display: grid; grid-template-columns: 320px 1fr; min-height: 100vh; }
601
+ .sidebar { background: var(--surface); border-right: 1px solid var(--border); overflow-y: auto; }
602
+ .main { padding: 24px; overflow-y: auto; }
603
+
604
+ .logo { padding: 20px; border-bottom: 1px solid var(--border); }
605
+ .logo h1 { font-size: 20px; font-weight: 700; color: var(--accent); }
606
+ .logo p { font-size: 12px; color: var(--text2); margin-top: 4px; }
607
+
608
+ .auth-section { padding: 16px; border-bottom: 1px solid var(--border); }
609
+ .auth-section label { font-size: 12px; color: var(--text2); display: block; margin-bottom: 6px; }
610
+ .auth-input { width: 100%; padding: 8px 12px; background: var(--surface2); border: 1px solid var(--border);
611
+ border-radius: var(--radius); color: var(--text); font-family: var(--mono); font-size: 12px; }
612
+ .auth-status { font-size: 11px; margin-top: 6px; }
613
+ .auth-status.ok { color: var(--green); }
614
+ .auth-status.no { color: var(--text2); }
615
+
616
+ .search { padding: 12px 16px; border-bottom: 1px solid var(--border); }
617
+ .search input { width: 100%; padding: 8px 12px; background: var(--surface2); border: 1px solid var(--border);
618
+ border-radius: var(--radius); color: var(--text); font-size: 13px; }
619
+
620
+ .api-list { padding: 8px 0; }
621
+ .api-group { padding: 4px 0; }
622
+ .api-group-title { padding: 8px 16px; font-size: 11px; color: var(--text2); text-transform: uppercase;
623
+ letter-spacing: 0.5px; font-weight: 600; }
624
+ .api-item { padding: 8px 16px; cursor: pointer; transition: background 0.15s; display: flex; align-items: center;
625
+ gap: 8px; font-size: 13px; }
626
+ .api-item:hover { background: var(--surface2); }
627
+ .api-item.active { background: var(--surface2); border-left: 2px solid var(--accent); }
628
+ .api-badge { font-size: 10px; padding: 2px 6px; border-radius: 4px; font-weight: 600; }
629
+ .badge-public { background: #22c55e20; color: var(--green); }
630
+ .badge-auth { background: #3b82f620; color: var(--blue); }
631
+ .badge-role { background: #eab30820; color: var(--yellow); }
632
+ .badge-reauth { background: #ef444420; color: var(--red); }
633
+
634
+ .panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 16px; }
635
+ .panel-header { padding: 16px; border-bottom: 1px solid var(--border); display: flex; align-items: center;
636
+ justify-content: space-between; }
637
+ .panel-header h2 { font-size: 16px; font-weight: 600; }
638
+ .panel-body { padding: 16px; }
639
+
640
+ .field { margin-bottom: 12px; }
641
+ .field label { font-size: 12px; color: var(--text2); display: block; margin-bottom: 4px; }
642
+ .field-type { font-size: 11px; color: var(--text2); font-family: var(--mono); }
643
+ .field-required { color: var(--red); font-size: 11px; }
644
+
645
+ textarea, input[type="text"] { width: 100%; padding: 10px 14px; background: var(--surface2);
646
+ border: 1px solid var(--border); border-radius: var(--radius); color: var(--text);
647
+ font-family: var(--mono); font-size: 13px; resize: vertical; }
648
+ textarea { min-height: 200px; }
649
+
650
+ .btn { padding: 10px 20px; border: none; border-radius: var(--radius); font-size: 14px;
651
+ font-weight: 600; cursor: pointer; transition: all 0.15s; }
652
+ .btn-primary { background: var(--accent); color: white; }
653
+ .btn-primary:hover { background: var(--accent2); }
654
+ .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
655
+
656
+ .response-section { margin-top: 16px; }
657
+ .status-badge { font-size: 12px; padding: 4px 8px; border-radius: 4px; font-weight: 600; }
658
+ .status-ok { background: #22c55e20; color: var(--green); }
659
+ .status-err { background: #ef444420; color: var(--red); }
660
+ pre { background: var(--surface2); padding: 16px; border-radius: var(--radius); overflow-x: auto;
661
+ font-family: var(--mono); font-size: 13px; line-height: 1.5; white-space: pre-wrap; }
662
+
663
+ .schema-info { font-size: 12px; color: var(--text2); line-height: 1.6; }
664
+ .schema-info code { background: var(--surface2); padding: 2px 6px; border-radius: 4px; font-family: var(--mono);
665
+ font-size: 11px; }
666
+
667
+ .empty-state { text-align: center; padding: 80px 40px; color: var(--text2); }
668
+ .empty-state h2 { font-size: 24px; margin-bottom: 8px; color: var(--text); }
669
+
670
+ .timer { font-size: 12px; color: var(--text2); font-family: var(--mono); }
671
+
672
+ @media (max-width: 768px) {
673
+ .layout { grid-template-columns: 1fr; }
674
+ .sidebar { max-height: 40vh; }
675
+ }
676
+ </style>
677
+ </head>
678
+ <body>
679
+ <div class="layout">
680
+ <div class="sidebar">
681
+ <div class="logo">
682
+ <h1>Clawfire</h1>
683
+ <p>API Playground</p>
684
+ </div>
685
+ <div class="auth-section">
686
+ <label>Bearer Token</label>
687
+ <input type="text" class="auth-input" id="token-input" placeholder="Paste your ID token...">
688
+ <div class="auth-status no" id="auth-status">Not authenticated</div>
689
+ </div>
690
+ <div class="search">
691
+ <input type="text" id="search-input" placeholder="Search APIs...">
692
+ </div>
693
+ <div class="api-list" id="api-list"></div>
694
+ </div>
695
+ <div class="main" id="main-content">
696
+ <div class="empty-state">
697
+ <h2>Select an API</h2>
698
+ <p>Choose an API from the sidebar to test it.</p>
699
+ </div>
700
+ </div>
701
+ </div>
702
+
703
+ <script>
704
+ const BASE_URL = ${JSON.stringify(apiBaseUrl)} || window.location.origin;
705
+ let manifest = null;
706
+ let selectedApi = null;
707
+
708
+ async function loadManifest() {
709
+ try {
710
+ const res = await fetch(BASE_URL + '/api/__manifest', { method: 'POST' });
711
+ manifest = await res.json();
712
+ renderApiList(manifest.apis);
713
+ } catch (e) {
714
+ document.getElementById('api-list').innerHTML =
715
+ '<div style="padding:16px;color:var(--red);font-size:13px;">Failed to load API manifest. Make sure your server is running.</div>';
716
+ }
717
+ }
718
+
719
+ function renderApiList(apis) {
720
+ const groups = {};
721
+ apis.forEach(api => {
722
+ const parts = api.path.split('/').filter(Boolean);
723
+ const group = parts.length > 1 ? parts[0] : 'root';
724
+ if (!groups[group]) groups[group] = [];
725
+ groups[group].push(api);
726
+ });
727
+
728
+ const el = document.getElementById('api-list');
729
+ el.innerHTML = Object.entries(groups).map(([group, items]) =>
730
+ '<div class="api-group">' +
731
+ '<div class="api-group-title">' + group + '</div>' +
732
+ items.map(api => {
733
+ const auth = api.meta.auth || 'public';
734
+ const badgeClass = 'badge-' + auth;
735
+ return '<div class="api-item" onclick="selectApi(\\'' + api.path + '\\')">' +
736
+ '<span class="api-badge ' + badgeClass + '">' + auth.toUpperCase() + '</span>' +
737
+ '<span>' + api.path + '</span>' +
738
+ '</div>';
739
+ }).join('') +
740
+ '</div>'
741
+ ).join('');
742
+ }
743
+
744
+ function selectApi(path) {
745
+ selectedApi = manifest.apis.find(a => a.path === path);
746
+ if (!selectedApi) return;
747
+
748
+ document.querySelectorAll('.api-item').forEach(el => el.classList.remove('active'));
749
+ event.currentTarget?.classList.add('active');
750
+
751
+ const main = document.getElementById('main-content');
752
+ const exampleInput = selectedApi.meta.exampleInput
753
+ ? JSON.stringify(selectedApi.meta.exampleInput, null, 2)
754
+ : generateExampleFromSchema(selectedApi.inputSchema);
755
+
756
+ main.innerHTML =
757
+ '<div class="panel">' +
758
+ '<div class="panel-header">' +
759
+ '<h2>POST ' + selectedApi.path + '</h2>' +
760
+ '<span class="api-badge badge-' + (selectedApi.meta.auth || 'public') + '">' +
761
+ (selectedApi.meta.auth || 'public').toUpperCase() + '</span>' +
762
+ '</div>' +
763
+ '<div class="panel-body">' +
764
+ '<p style="color:var(--text2);margin-bottom:16px;">' + (selectedApi.meta.description || '') + '</p>' +
765
+ (selectedApi.meta.tags ? '<div style="margin-bottom:12px;">' + selectedApi.meta.tags.map(t =>
766
+ '<span style="background:var(--surface2);padding:2px 8px;border-radius:4px;font-size:11px;margin-right:4px;">' + t + '</span>'
767
+ ).join('') + '</div>' : '') +
768
+ '<div class="schema-info" style="margin-bottom:16px;">' +
769
+ '<strong>Input Schema:</strong><br>' + renderSchemaInfo(selectedApi.inputSchema) +
770
+ '</div>' +
771
+ '<div class="schema-info" style="margin-bottom:16px;">' +
772
+ '<strong>Output Schema:</strong><br>' + renderSchemaInfo(selectedApi.outputSchema) +
773
+ '</div>' +
774
+ '</div>' +
775
+ '</div>' +
776
+ '<div class="panel">' +
777
+ '<div class="panel-header"><h2>Request</h2></div>' +
778
+ '<div class="panel-body">' +
779
+ '<textarea id="req-body" placeholder="Request JSON body">' + exampleInput + '</textarea>' +
780
+ '<div style="margin-top:12px;display:flex;align-items:center;gap:12px;">' +
781
+ '<button class="btn btn-primary" onclick="sendRequest()">Send Request</button>' +
782
+ '<span class="timer" id="timer"></span>' +
783
+ '</div>' +
784
+ '</div>' +
785
+ '</div>' +
786
+ '<div class="response-section" id="response-section"></div>';
787
+ }
788
+
789
+ function renderSchemaInfo(schema) {
790
+ if (!schema || !schema.properties) return '<code>void</code>';
791
+ return Object.entries(schema.properties).map(([key, prop]) => {
792
+ const required = schema.required?.includes(key);
793
+ const type = prop.type || 'unknown';
794
+ const enumVals = prop.enum ? ' (' + prop.enum.join(', ') + ')' : '';
795
+ return '<code>' + key + '</code>: <span class="field-type">' + type + enumVals + '</span>' +
796
+ (required ? ' <span class="field-required">required</span>' : ' <span style="color:var(--text2);font-size:11px;">optional</span>');
797
+ }).join('<br>');
798
+ }
799
+
800
+ function generateExampleFromSchema(schema) {
801
+ if (!schema || !schema.properties) return '{}';
802
+ const obj = {};
803
+ for (const [key, prop] of Object.entries(schema.properties)) {
804
+ if (prop.enum) { obj[key] = prop.enum[0]; continue; }
805
+ switch (prop.type) {
806
+ case 'string': obj[key] = prop.format === 'email' ? 'user@example.com' : 'string'; break;
807
+ case 'number': obj[key] = 0; break;
808
+ case 'boolean': obj[key] = false; break;
809
+ case 'array': obj[key] = []; break;
810
+ case 'object': obj[key] = {}; break;
811
+ default: obj[key] = null;
812
+ }
813
+ }
814
+ return JSON.stringify(obj, null, 2);
815
+ }
816
+
817
+ async function sendRequest() {
818
+ if (!selectedApi) return;
819
+ const body = document.getElementById('req-body').value;
820
+ const token = document.getElementById('token-input').value;
821
+ const timer = document.getElementById('timer');
822
+ const section = document.getElementById('response-section');
823
+
824
+ let parsed;
825
+ try { parsed = JSON.parse(body); } catch {
826
+ section.innerHTML = '<div class="panel"><div class="panel-body"><pre style="color:var(--red)">Invalid JSON</pre></div></div>';
827
+ return;
828
+ }
829
+
830
+ const start = performance.now();
831
+ timer.textContent = 'Sending...';
832
+
833
+ try {
834
+ const headers = { 'Content-Type': 'application/json' };
835
+ if (token) headers['Authorization'] = 'Bearer ' + token;
836
+
837
+ const res = await fetch(BASE_URL + '/api' + selectedApi.path, {
838
+ method: 'POST', headers, body: JSON.stringify(parsed)
839
+ });
840
+ const elapsed = Math.round(performance.now() - start);
841
+ timer.textContent = elapsed + 'ms';
842
+
843
+ const json = await res.json();
844
+ const isOk = res.ok;
845
+
846
+ section.innerHTML =
847
+ '<div class="panel">' +
848
+ '<div class="panel-header">' +
849
+ '<h2>Response</h2>' +
850
+ '<span class="status-badge ' + (isOk ? 'status-ok' : 'status-err') + '">' +
851
+ res.status + ' ' + res.statusText + '</span>' +
852
+ '</div>' +
853
+ '<div class="panel-body"><pre>' + syntaxHighlight(JSON.stringify(json, null, 2)) + '</pre></div>' +
854
+ '</div>';
855
+ } catch (e) {
856
+ const elapsed = Math.round(performance.now() - start);
857
+ timer.textContent = elapsed + 'ms';
858
+ section.innerHTML =
859
+ '<div class="panel"><div class="panel-body"><pre style="color:var(--red)">Network error: ' + e.message + '</pre></div></div>';
860
+ }
861
+ }
862
+
863
+ function syntaxHighlight(json) {
864
+ return json.replace(/("(\\\\u[a-fA-F0-9]{4}|\\\\[^u]|[^\\\\"])*"(\\s*:)?)|\\b(true|false|null)\\b|-?\\d+(\\.\\d+)?([eE][+-]?\\d+)?/g,
865
+ function(match) {
866
+ let cls = 'color:#eab308';
867
+ if (/^"/.test(match)) {
868
+ if (/:$/.test(match)) cls = 'color:#3b82f6';
869
+ else cls = 'color:#22c55e';
870
+ } else if (/true|false/.test(match)) cls = 'color:#f97316';
871
+ else if (/null/.test(match)) cls = 'color:#ef4444';
872
+ return '<span style="' + cls + '">' + match + '</span>';
873
+ }
874
+ );
875
+ }
876
+
877
+ // Search
878
+ document.getElementById('search-input')?.addEventListener('input', (e) => {
879
+ if (!manifest) return;
880
+ const q = e.target.value.toLowerCase();
881
+ const filtered = manifest.apis.filter(a => a.path.toLowerCase().includes(q) || a.meta.description?.toLowerCase().includes(q));
882
+ renderApiList(filtered);
883
+ });
884
+
885
+ // Token status
886
+ document.getElementById('token-input')?.addEventListener('input', (e) => {
887
+ const el = document.getElementById('auth-status');
888
+ if (e.target.value) {
889
+ el.textContent = 'Token set';
890
+ el.className = 'auth-status ok';
891
+ } else {
892
+ el.textContent = 'Not authenticated';
893
+ el.className = 'auth-status no';
894
+ }
895
+ });
896
+
897
+ loadManifest();
898
+ </script>
899
+ </body>
900
+ </html>`;
901
+ }
902
+
903
+ // src/dev/watcher.ts
904
+ var import_fs2 = require("fs");
905
+ var import_path2 = require("path");
906
+ var import_events = require("events");
907
+ var FileWatcher = class extends import_events.EventEmitter {
908
+ watchers = [];
909
+ debounceTimers = /* @__PURE__ */ new Map();
910
+ debounceMs;
911
+ constructor(debounceMs = 150) {
912
+ super();
913
+ this.debounceMs = debounceMs;
914
+ }
915
+ /**
916
+ * 디렉터리 감시 시작
917
+ */
918
+ watchDir(dir, eventType) {
919
+ if (!(0, import_fs2.existsSync)(dir)) return this;
920
+ try {
921
+ const watcher = (0, import_fs2.watch)(dir, { recursive: true }, (event, filename) => {
922
+ if (!filename) return;
923
+ const filePath = (0, import_path2.join)(dir, filename);
924
+ if (!this.isWatchedFile(filePath)) return;
925
+ this.emitDebounced(filePath, eventType);
926
+ });
927
+ watcher.on("error", (err) => {
928
+ if (err.code === "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM") {
929
+ this.watchDirRecursiveManual(dir, eventType);
930
+ }
931
+ });
932
+ this.watchers.push(watcher);
933
+ } catch {
934
+ this.watchDirRecursiveManual(dir, eventType);
935
+ }
936
+ return this;
937
+ }
938
+ /**
939
+ * 단일 파일 감시
940
+ */
941
+ watchFile(filePath, eventType) {
942
+ if (!(0, import_fs2.existsSync)(filePath)) return this;
943
+ const watcher = (0, import_fs2.watch)(filePath, (event) => {
944
+ this.emitDebounced(filePath, eventType);
945
+ });
946
+ this.watchers.push(watcher);
947
+ return this;
948
+ }
949
+ /**
950
+ * Linux fallback: 디렉터리 수동 재귀 감시
951
+ */
952
+ watchDirRecursiveManual(dir, eventType) {
953
+ if (!(0, import_fs2.existsSync)(dir)) return;
954
+ const watcher = (0, import_fs2.watch)(dir, (event, filename) => {
955
+ if (!filename) return;
956
+ const filePath = (0, import_path2.join)(dir, filename);
957
+ if (!this.isWatchedFile(filePath)) return;
958
+ this.emitDebounced(filePath, eventType);
959
+ });
960
+ this.watchers.push(watcher);
961
+ try {
962
+ const entries = (0, import_fs2.readdirSync)(dir, { withFileTypes: true });
963
+ for (const entry of entries) {
964
+ if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") {
965
+ this.watchDirRecursiveManual((0, import_path2.join)(dir, entry.name), eventType);
966
+ }
967
+ }
968
+ } catch {
969
+ }
970
+ }
971
+ /**
972
+ * 감시 대상 파일인지 확인
973
+ */
974
+ isWatchedFile(filePath) {
975
+ const ext = (0, import_path2.extname)(filePath);
976
+ return [".ts", ".tsx", ".js", ".jsx", ".json"].includes(ext);
977
+ }
978
+ /**
979
+ * 디바운스 이벤트 발생
980
+ */
981
+ emitDebounced(filePath, type) {
982
+ const existing = this.debounceTimers.get(filePath);
983
+ if (existing) clearTimeout(existing);
984
+ this.debounceTimers.set(
985
+ filePath,
986
+ setTimeout(() => {
987
+ this.debounceTimers.delete(filePath);
988
+ const event = { type, filePath, timestamp: Date.now() };
989
+ this.emit("change", event);
990
+ this.emit(type, event);
991
+ }, this.debounceMs)
992
+ );
993
+ }
994
+ /**
995
+ * 모든 감시 중지
996
+ */
997
+ close() {
998
+ for (const watcher of this.watchers) {
999
+ watcher.close();
1000
+ }
1001
+ this.watchers = [];
1002
+ for (const timer of this.debounceTimers.values()) {
1003
+ clearTimeout(timer);
1004
+ }
1005
+ this.debounceTimers.clear();
1006
+ this.removeAllListeners();
1007
+ }
1008
+ };
1009
+
1010
+ // src/dev/dev-server.ts
1011
+ var DevServer = class {
1012
+ server = null;
1013
+ router;
1014
+ watcher = null;
1015
+ sseClients = [];
1016
+ sseIdCounter = 0;
1017
+ options;
1018
+ routesDir;
1019
+ schemasDir;
1020
+ playgroundHtml = "";
1021
+ importCounter = 0;
1022
+ // ESM 캐시 버스팅용
1023
+ isReloading = false;
1024
+ constructor(options = {}) {
1025
+ this.options = {
1026
+ projectDir: options.projectDir || process.cwd(),
1027
+ port: options.port || 3456,
1028
+ routerOptions: options.routerOptions || {},
1029
+ hotReload: options.hotReload !== false,
1030
+ debounceMs: options.debounceMs || 150,
1031
+ onSetupRoutes: options.onSetupRoutes || (() => {
1032
+ })
1033
+ };
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");
1036
+ this.router = createRouter({
1037
+ cors: ["*"],
1038
+ // dev에서는 모든 origin 허용
1039
+ rateLimit: 0,
1040
+ // dev에서는 rate limit 비활성화
1041
+ ...this.options.routerOptions
1042
+ });
1043
+ }
1044
+ /**
1045
+ * 개발 서버 시작
1046
+ */
1047
+ async start() {
1048
+ await this.loadRoutes();
1049
+ this.regeneratePlayground();
1050
+ this.server = import_node_http.default.createServer((req, res) => this.handleHttpRequest(req, res));
1051
+ if (this.options.hotReload) {
1052
+ this.startWatcher();
1053
+ }
1054
+ await new Promise((resolve3, reject) => {
1055
+ this.server.listen(this.options.port, () => {
1056
+ resolve3();
1057
+ });
1058
+ this.server.on("error", reject);
1059
+ });
1060
+ this.printStartupBanner();
1061
+ }
1062
+ /**
1063
+ * 서버 종료
1064
+ */
1065
+ async stop() {
1066
+ this.watcher?.close();
1067
+ for (const client of this.sseClients) {
1068
+ client.res.end();
1069
+ }
1070
+ this.sseClients = [];
1071
+ this.router.destroy();
1072
+ if (this.server) {
1073
+ await new Promise((resolve3) => {
1074
+ this.server.close(() => resolve3());
1075
+ });
1076
+ }
1077
+ }
1078
+ // ─── Route Loading ─────────────────────────────────────────────────
1079
+ /**
1080
+ * 라우트 파일 로딩 (또는 수동 콜백)
1081
+ */
1082
+ async loadRoutes() {
1083
+ this.router.destroy();
1084
+ this.router = createRouter({
1085
+ cors: ["*"],
1086
+ rateLimit: 0,
1087
+ ...this.options.routerOptions
1088
+ });
1089
+ if (this.options.onSetupRoutes) {
1090
+ await this.options.onSetupRoutes(this.router);
1091
+ if (this.router.getRoutes().length > 0) return;
1092
+ }
1093
+ if (!(0, import_node_fs.existsSync)(this.routesDir)) return;
1094
+ const discovered = discoverRoutes(this.routesDir);
1095
+ for (const route of discovered) {
1096
+ try {
1097
+ const fullPath = (0, import_node_path.resolve)(this.routesDir, route.filePath);
1098
+ const fileUrl = (0, import_node_url.pathToFileURL)(fullPath).href;
1099
+ const mod = await import(`${fileUrl}?v=${++this.importCounter}`);
1100
+ const contract = mod.default;
1101
+ if (contract && typeof contract.handler === "function" && contract.input && contract.output && contract.meta) {
1102
+ this.router.register(route.apiPath, contract);
1103
+ }
1104
+ } catch (err) {
1105
+ logger.warn(`Failed to load route: ${route.filePath}`, err);
1106
+ }
1107
+ }
1108
+ }
1109
+ /**
1110
+ * 라우트 핫 리로드
1111
+ */
1112
+ async reloadRoutes(event) {
1113
+ if (this.isReloading) return;
1114
+ this.isReloading = true;
1115
+ const relPath = (0, import_node_path.relative)(this.options.projectDir, event.filePath);
1116
+ const timestamp = (/* @__PURE__ */ new Date()).toLocaleTimeString();
1117
+ console.log(`
1118
+ \x1B[33m[${timestamp}]\x1B[0m \x1B[36m${relPath}\x1B[0m changed`);
1119
+ console.log(" Reloading routes...");
1120
+ try {
1121
+ await this.loadRoutes();
1122
+ this.regeneratePlayground();
1123
+ const routeCount = this.router.getRoutes().length;
1124
+ console.log(` \x1B[32m\u2713\x1B[0m ${routeCount} routes loaded`);
1125
+ this.broadcastSSE({
1126
+ type: event.type,
1127
+ file: relPath,
1128
+ timestamp: event.timestamp,
1129
+ routes: routeCount
1130
+ });
1131
+ } catch (err) {
1132
+ console.log(` \x1B[31m\u2717\x1B[0m Reload failed:`, err);
1133
+ this.broadcastSSE({
1134
+ type: "error",
1135
+ file: relPath,
1136
+ message: err instanceof Error ? err.message : "Unknown error"
1137
+ });
1138
+ } finally {
1139
+ this.isReloading = false;
1140
+ }
1141
+ }
1142
+ // ─── File Watcher ──────────────────────────────────────────────────
1143
+ startWatcher() {
1144
+ this.watcher = new FileWatcher(this.options.debounceMs);
1145
+ if ((0, import_node_fs.existsSync)(this.routesDir)) {
1146
+ this.watcher.watchDir(this.routesDir, "route-change");
1147
+ }
1148
+ if ((0, import_node_fs.existsSync)(this.schemasDir)) {
1149
+ this.watcher.watchDir(this.schemasDir, "schema-change");
1150
+ }
1151
+ const configFile = (0, import_node_path.resolve)(this.options.projectDir, "clawfire.config.ts");
1152
+ if ((0, import_node_fs.existsSync)(configFile)) {
1153
+ this.watcher.watchFile(configFile, "config-change");
1154
+ }
1155
+ this.watcher.on("change", (event) => {
1156
+ this.reloadRoutes(event);
1157
+ });
1158
+ }
1159
+ // ─── SSE (Server-Sent Events) ──────────────────────────────────────
1160
+ handleSSE(req, res) {
1161
+ res.writeHead(200, {
1162
+ "Content-Type": "text/event-stream",
1163
+ "Cache-Control": "no-cache",
1164
+ "Connection": "keep-alive",
1165
+ "Access-Control-Allow-Origin": "*"
1166
+ });
1167
+ const clientId = ++this.sseIdCounter;
1168
+ const client = { id: clientId, res };
1169
+ this.sseClients.push(client);
1170
+ res.write(`data: ${JSON.stringify({ type: "connected", id: clientId })}
1171
+
1172
+ `);
1173
+ req.on("close", () => {
1174
+ this.sseClients = this.sseClients.filter((c) => c.id !== clientId);
1175
+ });
1176
+ }
1177
+ broadcastSSE(data) {
1178
+ const message = `data: ${JSON.stringify(data)}
1179
+
1180
+ `;
1181
+ for (const client of this.sseClients) {
1182
+ try {
1183
+ client.res.write(message);
1184
+ } catch {
1185
+ }
1186
+ }
1187
+ }
1188
+ // ─── Playground ────────────────────────────────────────────────────
1189
+ regeneratePlayground() {
1190
+ const baseHtml = generatePlaygroundHtml({
1191
+ title: "Clawfire Dev Playground",
1192
+ apiBaseUrl: `http://localhost:${this.options.port}`
1193
+ });
1194
+ const liveReloadScript = `
1195
+ <script>
1196
+ (function() {
1197
+ const banner = document.createElement('div');
1198
+ banner.id = 'dev-banner';
1199
+ 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
+ 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
+ document.body.appendChild(banner);
1202
+
1203
+ let reconnectTimer;
1204
+ function connect() {
1205
+ const es = new EventSource('http://localhost:${this.options.port}/__dev/events');
1206
+
1207
+ es.onopen = () => {
1208
+ document.getElementById('dev-dot').style.background = '#22c55e';
1209
+ document.getElementById('dev-status').textContent = 'Connected';
1210
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
1211
+ };
1212
+
1213
+ es.onmessage = (e) => {
1214
+ try {
1215
+ const data = JSON.parse(e.data);
1216
+ if (data.type === 'connected') return;
1217
+
1218
+ if (data.type === 'error') {
1219
+ document.getElementById('dev-dot').style.background = '#ef4444';
1220
+ document.getElementById('dev-status').textContent = 'Error: ' + (data.message || 'reload failed');
1221
+ return;
1222
+ }
1223
+
1224
+ // \uD30C\uC77C \uBCC0\uACBD \u2192 \uD398\uC774\uC9C0 \uC0C8\uB85C\uACE0\uCE68
1225
+ document.getElementById('dev-dot').style.background = '#eab308';
1226
+ document.getElementById('dev-status').textContent = 'Reloading...';
1227
+ setTimeout(() => window.location.reload(), 300);
1228
+ } catch {}
1229
+ };
1230
+
1231
+ es.onerror = () => {
1232
+ es.close();
1233
+ document.getElementById('dev-dot').style.background = '#ef4444';
1234
+ document.getElementById('dev-status').textContent = 'Disconnected \u2014 reconnecting...';
1235
+ reconnectTimer = setTimeout(connect, 2000);
1236
+ };
1237
+ }
1238
+
1239
+ connect();
1240
+ })();
1241
+ </script>`;
1242
+ this.playgroundHtml = baseHtml.replace("</body>", liveReloadScript + "\n</body>");
1243
+ }
1244
+ // ─── HTTP Request Handler ──────────────────────────────────────────
1245
+ handleHttpRequest(req, res) {
1246
+ const url = new URL(req.url || "/", `http://localhost:${this.options.port}`);
1247
+ if (url.pathname === "/__dev/events") {
1248
+ this.handleSSE(req, res);
1249
+ return;
1250
+ }
1251
+ if (url.pathname === "/" || url.pathname === "/__playground") {
1252
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1253
+ res.end(this.playgroundHtml);
1254
+ return;
1255
+ }
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("..")) {
1260
+ 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;
1277
+ } catch {
1278
+ }
1279
+ }
1280
+ }
1281
+ if (url.pathname.startsWith("/api")) {
1282
+ res.setHeader("Access-Control-Allow-Origin", "*");
1283
+ res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
1284
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
1285
+ if (req.method === "OPTIONS") {
1286
+ res.writeHead(204);
1287
+ res.end();
1288
+ return;
1289
+ }
1290
+ let body = "";
1291
+ req.on("data", (chunk) => {
1292
+ body += chunk;
1293
+ });
1294
+ req.on("end", async () => {
1295
+ let parsed = {};
1296
+ try {
1297
+ parsed = body ? JSON.parse(body) : {};
1298
+ } catch {
1299
+ }
1300
+ await this.router.handleRequest(
1301
+ {
1302
+ method: req.method,
1303
+ path: url.pathname,
1304
+ body: parsed,
1305
+ headers: req.headers,
1306
+ ip: req.socket.remoteAddress || "127.0.0.1"
1307
+ },
1308
+ {
1309
+ set(h) {
1310
+ for (const [k, v] of Object.entries(h)) {
1311
+ try {
1312
+ res.setHeader(k, v);
1313
+ } catch {
1314
+ }
1315
+ }
1316
+ },
1317
+ status(code) {
1318
+ return {
1319
+ json(data) {
1320
+ res.writeHead(code, { "Content-Type": "application/json" });
1321
+ res.end(JSON.stringify(data));
1322
+ },
1323
+ send(data) {
1324
+ res.writeHead(code);
1325
+ res.end(data);
1326
+ },
1327
+ end() {
1328
+ res.writeHead(code);
1329
+ res.end();
1330
+ }
1331
+ };
1332
+ }
1333
+ }
1334
+ );
1335
+ });
1336
+ return;
1337
+ }
1338
+ res.writeHead(404);
1339
+ res.end("Not found");
1340
+ }
1341
+ // ─── Startup Banner ────────────────────────────────────────────────
1342
+ printStartupBanner() {
1343
+ const routes = this.router.getRoutes();
1344
+ const watching = this.options.hotReload;
1345
+ console.log("");
1346
+ console.log(" \x1B[1m\x1B[33m\u26A1 Clawfire Dev Server\x1B[0m");
1347
+ 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`);
1351
+ console.log("");
1352
+ console.log(` \x1B[32mRoutes (${routes.length})\x1B[0m:`);
1353
+ for (const route of routes) {
1354
+ const auth = route.contract.meta.auth || "public";
1355
+ const authColor = auth === "public" ? "32" : auth === "authenticated" ? "34" : auth === "role" ? "33" : "31";
1356
+ console.log(` POST /api\x1B[1m${route.path}\x1B[0m \x1B[${authColor}m[${auth}]\x1B[0m \x1B[2m${route.contract.meta.description}\x1B[0m`);
1357
+ }
1358
+ console.log("");
1359
+ if (watching) {
1360
+ console.log(` \x1B[35mHot Reload\x1B[0m : \x1B[32mON\x1B[0m`);
1361
+ console.log(` \x1B[2mWatching: app/routes/, app/schemas/\x1B[0m`);
1362
+ } else {
1363
+ console.log(` \x1B[35mHot Reload\x1B[0m : OFF`);
1364
+ }
1365
+ console.log(`
1366
+ \x1B[2mPress Ctrl+C to stop\x1B[0m
1367
+ `);
1368
+ }
1369
+ };
1370
+ async function startDevServer(options) {
1371
+ const server = new DevServer(options);
1372
+ await server.start();
1373
+ const shutdown = async () => {
1374
+ console.log("\n Shutting down...");
1375
+ await server.stop();
1376
+ process.exit(0);
1377
+ };
1378
+ process.on("SIGINT", shutdown);
1379
+ process.on("SIGTERM", shutdown);
1380
+ return server;
1381
+ }
1382
+ // Annotate the CommonJS export names for ESM import in node:
1383
+ 0 && (module.exports = {
1384
+ DevServer,
1385
+ FileWatcher,
1386
+ startDevServer
1387
+ });
1388
+ //# sourceMappingURL=dev.cjs.map