cyclecad 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API-BUILD-MANIFEST.txt +339 -0
- package/API-SERVER.md +535 -0
- package/Architecture-Deck.pptx +0 -0
- package/CLAUDE.md +172 -11
- package/CLI-BUILD-SUMMARY.md +504 -0
- package/CLI-INDEX.md +356 -0
- package/CLI-README.md +466 -0
- package/COLLABORATION-INTEGRATION-GUIDE.md +325 -0
- package/CONNECTED_FABS_GUIDE.md +612 -0
- package/CONNECTED_FABS_README.md +310 -0
- package/DELIVERABLES.md +343 -0
- package/DFM-ANALYZER-INTEGRATION.md +368 -0
- package/DFM-QUICK-START.js +253 -0
- package/Dockerfile +69 -0
- package/IMPLEMENTATION.md +327 -0
- package/LICENSE +31 -0
- package/MARKETPLACE_QUICK_REFERENCE.txt +294 -0
- package/MCP-INDEX.md +264 -0
- package/QUICKSTART-API.md +388 -0
- package/QUICKSTART-CLI.md +211 -0
- package/QUICKSTART-MCP.md +196 -0
- package/README-MCP.md +208 -0
- package/TEST-TOKEN-ENGINE.md +319 -0
- package/TOKEN-ENGINE-SUMMARY.md +266 -0
- package/TOKENS-README.md +263 -0
- package/TOOLS-REFERENCE.md +254 -0
- package/app/index.html +168 -3
- package/app/js/TOKEN-INTEGRATION.md +391 -0
- package/app/js/agent-api.js +3 -3
- package/app/js/ai-copilot.js +1435 -0
- package/app/js/cam-pipeline.js +840 -0
- package/app/js/collaboration-ui.js +995 -0
- package/app/js/collaboration.js +1116 -0
- package/app/js/connected-fabs-example.js +404 -0
- package/app/js/connected-fabs.js +1449 -0
- package/app/js/dfm-analyzer.js +1760 -0
- package/app/js/marketplace.js +1994 -0
- package/app/js/material-library.js +2115 -0
- package/app/js/token-dashboard.js +563 -0
- package/app/js/token-engine.js +743 -0
- package/app/test-agent.html +1801 -0
- package/bin/cyclecad-cli.js +662 -0
- package/bin/cyclecad-mcp +2 -0
- package/bin/server.js +242 -0
- package/cycleCAD-Architecture.pptx +0 -0
- package/cycleCAD-Investor-Deck.pptx +0 -0
- package/demo-mcp.sh +60 -0
- package/docs/API-SERVER-SUMMARY.md +375 -0
- package/docs/API-SERVER.md +667 -0
- package/docs/CAM-EXAMPLES.md +344 -0
- package/docs/CAM-INTEGRATION.md +612 -0
- package/docs/CAM-QUICK-REFERENCE.md +199 -0
- package/docs/CLI-INTEGRATION.md +510 -0
- package/docs/CLI.md +872 -0
- package/docs/MARKETPLACE-API-SCHEMA.json +564 -0
- package/docs/MARKETPLACE-INTEGRATION.md +467 -0
- package/docs/MARKETPLACE-SETUP.html +439 -0
- package/docs/MCP-SERVER.md +403 -0
- package/examples/api-client-example.js +488 -0
- package/examples/api-client-example.py +359 -0
- package/examples/batch-manufacturing.txt +28 -0
- package/examples/batch-simple.txt +26 -0
- package/model-marketplace.html +1273 -0
- package/package.json +14 -3
- package/server/api-server.js +1120 -0
- package/server/mcp-server.js +1161 -0
- package/test-api-server.js +432 -0
- package/test-mcp.js +198 -0
- package/~$cycleCAD-Investor-Deck.pptx +0 -0
|
@@ -0,0 +1,1120 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* cycleCAD API Server v0.2.0
|
|
5
|
+
*
|
|
6
|
+
* REST API server for cycleCAD that exposes the Agent API via HTTP.
|
|
7
|
+
* Enables any language/platform to drive cycleCAD through JSON-RPC style endpoints.
|
|
8
|
+
*
|
|
9
|
+
* Features:
|
|
10
|
+
* - POST /api/execute — Execute single Agent API command
|
|
11
|
+
* - GET /api/schema — Introspect full API schema
|
|
12
|
+
* - POST /api/batch — Execute multiple commands
|
|
13
|
+
* - GET /api/history — View command history
|
|
14
|
+
* - GET /api/health — Health check
|
|
15
|
+
* - WebSocket /api/ws — Bidirectional real-time connection
|
|
16
|
+
* - Static file serving for cycleCAD web app
|
|
17
|
+
* - Rate limiting (100 req/min per IP)
|
|
18
|
+
* - API key authentication (optional)
|
|
19
|
+
*
|
|
20
|
+
* Zero external dependencies — uses only Node.js built-ins.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const http = require('http');
|
|
24
|
+
const https = require('https');
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const url = require('url');
|
|
28
|
+
const crypto = require('crypto');
|
|
29
|
+
const { EventEmitter } = require('events');
|
|
30
|
+
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// CONFIGURATION
|
|
33
|
+
// ============================================================================
|
|
34
|
+
|
|
35
|
+
const PORT = process.env.PORT || 3000;
|
|
36
|
+
const HOST = process.env.HOST || '0.0.0.0';
|
|
37
|
+
const API_KEY = process.env.CYCLECAD_API_KEY || null; // Optional auth
|
|
38
|
+
const STATIC_DIR = process.env.STATIC_DIR || path.join(__dirname, '../app');
|
|
39
|
+
const ENABLE_HTTPS = process.env.ENABLE_HTTPS === 'true';
|
|
40
|
+
const CERT_FILE = process.env.CERT_FILE || '';
|
|
41
|
+
const KEY_FILE = process.env.KEY_FILE || '';
|
|
42
|
+
const DEV_MODE = process.argv.includes('--dev');
|
|
43
|
+
|
|
44
|
+
// ============================================================================
|
|
45
|
+
// RATE LIMITER
|
|
46
|
+
// ============================================================================
|
|
47
|
+
|
|
48
|
+
class RateLimiter {
|
|
49
|
+
constructor(maxRequests = 100, windowMs = 60000) {
|
|
50
|
+
this.maxRequests = maxRequests;
|
|
51
|
+
this.windowMs = windowMs;
|
|
52
|
+
this.requests = new Map(); // ip -> [timestamps]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
isAllowed(ip) {
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
if (!this.requests.has(ip)) {
|
|
58
|
+
this.requests.set(ip, []);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const timestamps = this.requests.get(ip);
|
|
62
|
+
|
|
63
|
+
// Remove old entries outside the window
|
|
64
|
+
const validTimestamps = timestamps.filter(t => now - t < this.windowMs);
|
|
65
|
+
this.requests.set(ip, validTimestamps);
|
|
66
|
+
|
|
67
|
+
if (validTimestamps.length >= this.maxRequests) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
validTimestamps.push(now);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
remaining(ip) {
|
|
76
|
+
const now = Date.now();
|
|
77
|
+
const timestamps = this.requests.get(ip) || [];
|
|
78
|
+
const validTimestamps = timestamps.filter(t => now - t < this.windowMs);
|
|
79
|
+
return Math.max(0, this.maxRequests - validTimestamps.length);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const limiter = new RateLimiter(100, 60000);
|
|
84
|
+
|
|
85
|
+
// ============================================================================
|
|
86
|
+
// API SERVER STATE
|
|
87
|
+
// ============================================================================
|
|
88
|
+
|
|
89
|
+
class APIServer extends EventEmitter {
|
|
90
|
+
constructor() {
|
|
91
|
+
super();
|
|
92
|
+
this.sessionId = crypto.randomUUID();
|
|
93
|
+
this.startTime = Date.now();
|
|
94
|
+
this.commandLog = [];
|
|
95
|
+
this.features = [];
|
|
96
|
+
this.models = [];
|
|
97
|
+
this.wsClients = new Set();
|
|
98
|
+
this.tokenBuckets = new Map(); // IP -> { tokens, lastRefill }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
executeCommand(cmd) {
|
|
102
|
+
const start = performance.now();
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
if (!cmd || !cmd.method) {
|
|
106
|
+
return this._err('Missing "method" field in command');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const handler = AGENT_COMMANDS[cmd.method];
|
|
110
|
+
if (!handler) {
|
|
111
|
+
const suggestions = this._suggestMethod(cmd.method);
|
|
112
|
+
return this._err(
|
|
113
|
+
`Unknown method: "${cmd.method}".` +
|
|
114
|
+
(suggestions.length > 0 ? ` Did you mean: ${suggestions.join(', ')}?` : '') +
|
|
115
|
+
` Use GET /api/schema to see available commands.`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const result = handler.call(this, cmd.params || {});
|
|
120
|
+
const elapsed = Math.round(performance.now() - start);
|
|
121
|
+
|
|
122
|
+
// Log command
|
|
123
|
+
const entry = {
|
|
124
|
+
id: `cmd_${this.commandLog.length}`,
|
|
125
|
+
method: cmd.method,
|
|
126
|
+
params: cmd.params,
|
|
127
|
+
elapsed,
|
|
128
|
+
ok: true,
|
|
129
|
+
timestamp: new Date().toISOString(),
|
|
130
|
+
result: typeof result === 'object' ? Object.keys(result).slice(0, 3) : String(result).slice(0, 100)
|
|
131
|
+
};
|
|
132
|
+
this.commandLog.push(entry);
|
|
133
|
+
|
|
134
|
+
// Broadcast to WebSocket clients
|
|
135
|
+
this._broadcastWS({
|
|
136
|
+
type: 'commandExecuted',
|
|
137
|
+
method: cmd.method,
|
|
138
|
+
elapsed,
|
|
139
|
+
ok: true
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
ok: true,
|
|
144
|
+
result,
|
|
145
|
+
elapsed,
|
|
146
|
+
sessionId: this.sessionId
|
|
147
|
+
};
|
|
148
|
+
} catch (e) {
|
|
149
|
+
const elapsed = Math.round(performance.now() - start);
|
|
150
|
+
const entry = {
|
|
151
|
+
id: `cmd_${this.commandLog.length}`,
|
|
152
|
+
method: cmd.method,
|
|
153
|
+
params: cmd.params,
|
|
154
|
+
elapsed,
|
|
155
|
+
ok: false,
|
|
156
|
+
error: e.message,
|
|
157
|
+
timestamp: new Date().toISOString()
|
|
158
|
+
};
|
|
159
|
+
this.commandLog.push(entry);
|
|
160
|
+
|
|
161
|
+
this._broadcastWS({
|
|
162
|
+
type: 'commandFailed',
|
|
163
|
+
method: cmd.method,
|
|
164
|
+
error: e.message,
|
|
165
|
+
elapsed
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return this._err(e.message, elapsed);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
executeBatch(commands) {
|
|
173
|
+
const results = [];
|
|
174
|
+
const errors = [];
|
|
175
|
+
const start = performance.now();
|
|
176
|
+
|
|
177
|
+
for (let i = 0; i < commands.length; i++) {
|
|
178
|
+
const r = this.executeCommand(commands[i]);
|
|
179
|
+
results.push(r);
|
|
180
|
+
if (!r.ok) {
|
|
181
|
+
errors.push({ index: i, method: commands[i].method, error: r.error });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const elapsed = Math.round(performance.now() - start);
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
ok: errors.length === 0,
|
|
189
|
+
results,
|
|
190
|
+
errors,
|
|
191
|
+
executed: results.length - errors.length,
|
|
192
|
+
total: commands.length,
|
|
193
|
+
elapsed,
|
|
194
|
+
sessionId: this.sessionId
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
getSchema() {
|
|
199
|
+
const schema = {};
|
|
200
|
+
|
|
201
|
+
for (const [method, handler] of Object.entries(AGENT_COMMANDS)) {
|
|
202
|
+
const [namespace, command] = method.split('.');
|
|
203
|
+
if (!schema[namespace]) {
|
|
204
|
+
schema[namespace] = { description: NAMESPACE_DESCRIPTIONS[namespace] || '', commands: {} };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
schema[namespace].commands[command] = {
|
|
208
|
+
method,
|
|
209
|
+
description: handler.description || 'No description',
|
|
210
|
+
params: handler.params || {},
|
|
211
|
+
returns: handler.returns || {}
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
version: '0.2.0',
|
|
217
|
+
sessionId: this.sessionId,
|
|
218
|
+
namespaces: schema,
|
|
219
|
+
totalCommands: Object.keys(AGENT_COMMANDS).length
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
getHistory(count = 20) {
|
|
224
|
+
return {
|
|
225
|
+
sessionId: this.sessionId,
|
|
226
|
+
total: this.commandLog.length,
|
|
227
|
+
recent: this.commandLog.slice(-count),
|
|
228
|
+
timestamp: new Date().toISOString()
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
getHealth() {
|
|
233
|
+
return {
|
|
234
|
+
status: 'ok',
|
|
235
|
+
version: '0.2.0',
|
|
236
|
+
uptime: Math.round((Date.now() - this.startTime) / 1000),
|
|
237
|
+
sessionId: this.sessionId,
|
|
238
|
+
commands: Object.keys(AGENT_COMMANDS).length,
|
|
239
|
+
commandsExecuted: this.commandLog.length,
|
|
240
|
+
features: this.features.length,
|
|
241
|
+
models: this.models.length,
|
|
242
|
+
wsClients: this.wsClients.size,
|
|
243
|
+
timestamp: new Date().toISOString()
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
addWSClient(ws) {
|
|
248
|
+
this.wsClients.add(ws);
|
|
249
|
+
return this.wsClients.size;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
removeWSClient(ws) {
|
|
253
|
+
this.wsClients.delete(ws);
|
|
254
|
+
return this.wsClients.size;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
_broadcastWS(message) {
|
|
258
|
+
const data = JSON.stringify(message);
|
|
259
|
+
for (const ws of this.wsClients) {
|
|
260
|
+
try {
|
|
261
|
+
ws.send(data);
|
|
262
|
+
} catch (e) {
|
|
263
|
+
// Silently ignore errors
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
_err(msg, elapsed = 0) {
|
|
269
|
+
return {
|
|
270
|
+
ok: false,
|
|
271
|
+
error: msg,
|
|
272
|
+
elapsed,
|
|
273
|
+
sessionId: this.sessionId
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
_suggestMethod(invalid) {
|
|
278
|
+
const allMethods = Object.keys(AGENT_COMMANDS);
|
|
279
|
+
const suggestions = allMethods
|
|
280
|
+
.filter(m => this._editDistance(invalid, m) <= 3)
|
|
281
|
+
.slice(0, 3);
|
|
282
|
+
return suggestions;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
_editDistance(a, b) {
|
|
286
|
+
if (a.length === 0) return b.length;
|
|
287
|
+
if (b.length === 0) return a.length;
|
|
288
|
+
const matrix = Array(b.length + 1).fill(null).map(() => Array(a.length + 1).fill(0));
|
|
289
|
+
for (let i = 0; i <= a.length; i++) matrix[0][i] = i;
|
|
290
|
+
for (let j = 0; j <= b.length; j++) matrix[j][0] = j;
|
|
291
|
+
for (let j = 1; j <= b.length; j++) {
|
|
292
|
+
for (let i = 1; i <= a.length; i++) {
|
|
293
|
+
const indicator = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
294
|
+
matrix[j][i] = Math.min(
|
|
295
|
+
matrix[j][i - 1] + 1,
|
|
296
|
+
matrix[j - 1][i] + 1,
|
|
297
|
+
matrix[j - 1][i - 1] + indicator
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return matrix[b.length][a.length];
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const server = new APIServer();
|
|
306
|
+
|
|
307
|
+
// ============================================================================
|
|
308
|
+
// AGENT COMMANDS (Mock implementations for server-side)
|
|
309
|
+
// ============================================================================
|
|
310
|
+
|
|
311
|
+
const AGENT_COMMANDS = {
|
|
312
|
+
// SKETCH commands
|
|
313
|
+
'sketch.start': {
|
|
314
|
+
description: 'Start a 2D sketch on a plane',
|
|
315
|
+
params: { plane: 'string (XY|XZ|YZ)' },
|
|
316
|
+
returns: { sketchId: 'string', plane: 'string', status: 'string' },
|
|
317
|
+
handler(params) {
|
|
318
|
+
const plane = params.plane || 'XY';
|
|
319
|
+
if (!['XY', 'XZ', 'YZ'].includes(plane)) {
|
|
320
|
+
throw new Error(`Invalid plane: ${plane}`);
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
sketchId: `sketch_${Date.now()}`,
|
|
324
|
+
plane,
|
|
325
|
+
status: 'active',
|
|
326
|
+
message: `Sketch started on ${plane} plane`
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
'sketch.line': {
|
|
332
|
+
description: 'Draw a line segment',
|
|
333
|
+
params: { x1: 'number', y1: 'number', x2: 'number', y2: 'number' },
|
|
334
|
+
returns: { entityId: 'string', type: 'string', length: 'number' },
|
|
335
|
+
handler(params) {
|
|
336
|
+
const dx = params.x2 - params.x1;
|
|
337
|
+
const dy = params.y2 - params.y1;
|
|
338
|
+
const length = Math.sqrt(dx * dx + dy * dy);
|
|
339
|
+
return { entityId: `line_${Date.now()}`, type: 'line', length };
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
|
|
343
|
+
'sketch.circle': {
|
|
344
|
+
description: 'Draw a circle',
|
|
345
|
+
params: { cx: 'number', cy: 'number', radius: 'number' },
|
|
346
|
+
returns: { entityId: 'string', type: 'string', radius: 'number' },
|
|
347
|
+
handler(params) {
|
|
348
|
+
return {
|
|
349
|
+
entityId: `circle_${Date.now()}`,
|
|
350
|
+
type: 'circle',
|
|
351
|
+
radius: params.radius,
|
|
352
|
+
center: [params.cx, params.cy],
|
|
353
|
+
area: Math.PI * params.radius * params.radius
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
'sketch.rect': {
|
|
359
|
+
description: 'Draw a rectangle',
|
|
360
|
+
params: { x: 'number', y: 'number', width: 'number', height: 'number' },
|
|
361
|
+
returns: { entityId: 'string', type: 'string', area: 'number' },
|
|
362
|
+
handler(params) {
|
|
363
|
+
return {
|
|
364
|
+
entityId: `rect_${Date.now()}`,
|
|
365
|
+
type: 'rect',
|
|
366
|
+
x: params.x,
|
|
367
|
+
y: params.y,
|
|
368
|
+
width: params.width,
|
|
369
|
+
height: params.height,
|
|
370
|
+
area: params.width * params.height
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
|
|
375
|
+
'sketch.end': {
|
|
376
|
+
description: 'End the sketch',
|
|
377
|
+
params: {},
|
|
378
|
+
returns: { status: 'string', message: 'string' },
|
|
379
|
+
handler(params) {
|
|
380
|
+
return { status: 'complete', message: 'Sketch ended' };
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
|
|
384
|
+
// OPERATIONS commands
|
|
385
|
+
'ops.extrude': {
|
|
386
|
+
description: 'Extrude a sketch into 3D',
|
|
387
|
+
params: { height: 'number', symmetric: 'boolean', material: 'string' },
|
|
388
|
+
returns: { featureId: 'string', type: 'string', volume: 'number' },
|
|
389
|
+
handler(params) {
|
|
390
|
+
const volume = params.height * 100; // Mock calculation
|
|
391
|
+
return {
|
|
392
|
+
featureId: `extrude_${Date.now()}`,
|
|
393
|
+
type: 'extrude',
|
|
394
|
+
height: params.height,
|
|
395
|
+
symmetric: params.symmetric || false,
|
|
396
|
+
material: params.material || 'steel',
|
|
397
|
+
volume
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
},
|
|
401
|
+
|
|
402
|
+
'ops.fillet': {
|
|
403
|
+
description: 'Fillet edges of a feature',
|
|
404
|
+
params: { target: 'string', radius: 'number' },
|
|
405
|
+
returns: { featureId: 'string', radius: 'number' },
|
|
406
|
+
handler(params) {
|
|
407
|
+
return {
|
|
408
|
+
featureId: `fillet_${Date.now()}`,
|
|
409
|
+
target: params.target,
|
|
410
|
+
radius: params.radius
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
|
|
415
|
+
'ops.chamfer': {
|
|
416
|
+
description: 'Chamfer edges of a feature',
|
|
417
|
+
params: { target: 'string', distance: 'number' },
|
|
418
|
+
returns: { featureId: 'string', distance: 'number' },
|
|
419
|
+
handler(params) {
|
|
420
|
+
return {
|
|
421
|
+
featureId: `chamfer_${Date.now()}`,
|
|
422
|
+
target: params.target,
|
|
423
|
+
distance: params.distance
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
|
|
428
|
+
'ops.hole': {
|
|
429
|
+
description: 'Create a hole',
|
|
430
|
+
params: { radius: 'number', depth: 'number' },
|
|
431
|
+
returns: { featureId: 'string', radius: 'number', depth: 'number' },
|
|
432
|
+
handler(params) {
|
|
433
|
+
return {
|
|
434
|
+
featureId: `hole_${Date.now()}`,
|
|
435
|
+
radius: params.radius,
|
|
436
|
+
depth: params.depth,
|
|
437
|
+
type: 'hole'
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
},
|
|
441
|
+
|
|
442
|
+
'ops.pattern': {
|
|
443
|
+
description: 'Create a rectangular or circular pattern',
|
|
444
|
+
params: { target: 'string', type: 'string', count: 'number', spacing: 'number' },
|
|
445
|
+
returns: { featureId: 'string', count: 'number' },
|
|
446
|
+
handler(params) {
|
|
447
|
+
return {
|
|
448
|
+
featureId: `pattern_${Date.now()}`,
|
|
449
|
+
target: params.target,
|
|
450
|
+
type: params.type || 'rect',
|
|
451
|
+
count: params.count,
|
|
452
|
+
spacing: params.spacing
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
},
|
|
456
|
+
|
|
457
|
+
// VIEW commands
|
|
458
|
+
'view.set': {
|
|
459
|
+
description: 'Set viewport view (isometric, top, front, right, bottom, back, left)',
|
|
460
|
+
params: { view: 'string' },
|
|
461
|
+
returns: { view: 'string', message: 'string' },
|
|
462
|
+
handler(params) {
|
|
463
|
+
const validViews = ['isometric', 'top', 'front', 'right', 'bottom', 'back', 'left'];
|
|
464
|
+
if (!validViews.includes(params.view)) {
|
|
465
|
+
throw new Error(`Invalid view: ${params.view}`);
|
|
466
|
+
}
|
|
467
|
+
return { view: params.view, message: `View set to ${params.view}` };
|
|
468
|
+
}
|
|
469
|
+
},
|
|
470
|
+
|
|
471
|
+
'view.grid': {
|
|
472
|
+
description: 'Toggle grid visibility',
|
|
473
|
+
params: { visible: 'boolean' },
|
|
474
|
+
returns: { visible: 'boolean', message: 'string' },
|
|
475
|
+
handler(params) {
|
|
476
|
+
return {
|
|
477
|
+
visible: params.visible !== false,
|
|
478
|
+
message: `Grid ${params.visible !== false ? 'enabled' : 'disabled'}`
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
},
|
|
482
|
+
|
|
483
|
+
'view.wireframe': {
|
|
484
|
+
description: 'Toggle wireframe mode',
|
|
485
|
+
params: { enabled: 'boolean' },
|
|
486
|
+
returns: { enabled: 'boolean', message: 'string' },
|
|
487
|
+
handler(params) {
|
|
488
|
+
return {
|
|
489
|
+
enabled: params.enabled !== false,
|
|
490
|
+
message: `Wireframe ${params.enabled !== false ? 'enabled' : 'disabled'}`
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
},
|
|
494
|
+
|
|
495
|
+
// EXPORT commands
|
|
496
|
+
'export.stl': {
|
|
497
|
+
description: 'Export to STL format',
|
|
498
|
+
params: { filename: 'string', binary: 'boolean' },
|
|
499
|
+
returns: { filename: 'string', format: 'string', message: 'string' },
|
|
500
|
+
handler(params) {
|
|
501
|
+
return {
|
|
502
|
+
filename: params.filename || 'output.stl',
|
|
503
|
+
format: params.binary ? 'binary' : 'ascii',
|
|
504
|
+
bytes: Math.floor(Math.random() * 10000000),
|
|
505
|
+
message: 'Export would save to file'
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
},
|
|
509
|
+
|
|
510
|
+
'export.obj': {
|
|
511
|
+
description: 'Export to OBJ format',
|
|
512
|
+
params: { filename: 'string' },
|
|
513
|
+
returns: { filename: 'string', format: 'string' },
|
|
514
|
+
handler(params) {
|
|
515
|
+
return {
|
|
516
|
+
filename: params.filename || 'output.obj',
|
|
517
|
+
format: 'obj',
|
|
518
|
+
message: 'Export would save to file'
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
},
|
|
522
|
+
|
|
523
|
+
'export.gltf': {
|
|
524
|
+
description: 'Export to glTF format',
|
|
525
|
+
params: { filename: 'string' },
|
|
526
|
+
returns: { filename: 'string', format: 'string' },
|
|
527
|
+
handler(params) {
|
|
528
|
+
return {
|
|
529
|
+
filename: params.filename || 'output.gltf',
|
|
530
|
+
format: 'gltf',
|
|
531
|
+
message: 'Export would save to file'
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
},
|
|
535
|
+
|
|
536
|
+
// QUERY commands
|
|
537
|
+
'query.features': {
|
|
538
|
+
description: 'List all features in the model',
|
|
539
|
+
params: {},
|
|
540
|
+
returns: { features: 'array', count: 'number' },
|
|
541
|
+
handler(params) {
|
|
542
|
+
return {
|
|
543
|
+
features: server.features,
|
|
544
|
+
count: server.features.length
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
},
|
|
548
|
+
|
|
549
|
+
'query.bbox': {
|
|
550
|
+
description: 'Get bounding box of a feature',
|
|
551
|
+
params: { target: 'string' },
|
|
552
|
+
returns: { min: 'array', max: 'array', size: 'array' },
|
|
553
|
+
handler(params) {
|
|
554
|
+
return {
|
|
555
|
+
target: params.target,
|
|
556
|
+
min: [-50, -50, -50],
|
|
557
|
+
max: [50, 50, 50],
|
|
558
|
+
size: [100, 100, 100]
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
},
|
|
562
|
+
|
|
563
|
+
'query.materials': {
|
|
564
|
+
description: 'List available materials',
|
|
565
|
+
params: {},
|
|
566
|
+
returns: { materials: 'array' },
|
|
567
|
+
handler(params) {
|
|
568
|
+
return {
|
|
569
|
+
materials: ['steel', 'aluminum', 'brass', 'plastic', 'titanium', 'carbon-fiber', 'rubber', 'wood']
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
},
|
|
573
|
+
|
|
574
|
+
// VALIDATE commands
|
|
575
|
+
'validate.dimensions': {
|
|
576
|
+
description: 'Check feature dimensions',
|
|
577
|
+
params: { target: 'string' },
|
|
578
|
+
returns: { target: 'string', length: 'number', width: 'number', height: 'number' },
|
|
579
|
+
handler(params) {
|
|
580
|
+
return {
|
|
581
|
+
target: params.target,
|
|
582
|
+
length: 100,
|
|
583
|
+
width: 50,
|
|
584
|
+
height: 75,
|
|
585
|
+
message: 'Dimensions OK'
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
},
|
|
589
|
+
|
|
590
|
+
'validate.cost': {
|
|
591
|
+
description: 'Estimate manufacturing cost',
|
|
592
|
+
params: { target: 'string', process: 'string', material: 'string' },
|
|
593
|
+
returns: { target: 'string', process: 'string', estimatedCost: 'number' },
|
|
594
|
+
handler(params) {
|
|
595
|
+
const costMap = { 'FDM': 15, 'SLA': 25, 'CNC': 50, 'injection': 100 };
|
|
596
|
+
const cost = costMap[params.process] || 20;
|
|
597
|
+
return {
|
|
598
|
+
target: params.target,
|
|
599
|
+
process: params.process || 'FDM',
|
|
600
|
+
material: params.material || 'PLA',
|
|
601
|
+
estimatedCost: cost,
|
|
602
|
+
currency: 'USD'
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
},
|
|
606
|
+
|
|
607
|
+
'validate.mass': {
|
|
608
|
+
description: 'Calculate feature mass',
|
|
609
|
+
params: { target: 'string', material: 'string' },
|
|
610
|
+
returns: { target: 'string', mass: 'number', material: 'string' },
|
|
611
|
+
handler(params) {
|
|
612
|
+
const densities = { steel: 7.85, aluminum: 2.70, brass: 8.56, titanium: 4.5 };
|
|
613
|
+
const density = densities[params.material] || 7.85;
|
|
614
|
+
return {
|
|
615
|
+
target: params.target,
|
|
616
|
+
mass: Math.round(density * 100) / 100,
|
|
617
|
+
unit: 'kg',
|
|
618
|
+
material: params.material || 'steel'
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
},
|
|
622
|
+
|
|
623
|
+
// ASSEMBLY commands
|
|
624
|
+
'assembly.addComponent': {
|
|
625
|
+
description: 'Add a component to the assembly',
|
|
626
|
+
params: { name: 'string', position: 'array' },
|
|
627
|
+
returns: { componentId: 'string', name: 'string' },
|
|
628
|
+
handler(params) {
|
|
629
|
+
const comp = {
|
|
630
|
+
id: `comp_${Date.now()}`,
|
|
631
|
+
name: params.name || 'Component',
|
|
632
|
+
position: params.position || [0, 0, 0]
|
|
633
|
+
};
|
|
634
|
+
server.models.push(comp);
|
|
635
|
+
return comp;
|
|
636
|
+
}
|
|
637
|
+
},
|
|
638
|
+
|
|
639
|
+
'assembly.list': {
|
|
640
|
+
description: 'List all components in assembly',
|
|
641
|
+
params: {},
|
|
642
|
+
returns: { components: 'array', count: 'number' },
|
|
643
|
+
handler(params) {
|
|
644
|
+
return {
|
|
645
|
+
components: server.models,
|
|
646
|
+
count: server.models.length
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
},
|
|
650
|
+
|
|
651
|
+
// META commands
|
|
652
|
+
'meta.ping': {
|
|
653
|
+
description: 'Ping the server',
|
|
654
|
+
params: {},
|
|
655
|
+
returns: { status: 'string', timestamp: 'string' },
|
|
656
|
+
handler(params) {
|
|
657
|
+
return {
|
|
658
|
+
status: 'pong',
|
|
659
|
+
timestamp: new Date().toISOString()
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
},
|
|
663
|
+
|
|
664
|
+
'meta.version': {
|
|
665
|
+
description: 'Get server and API version',
|
|
666
|
+
params: {},
|
|
667
|
+
returns: { version: 'string', name: 'string' },
|
|
668
|
+
handler(params) {
|
|
669
|
+
return {
|
|
670
|
+
name: 'cycleCAD API Server',
|
|
671
|
+
version: '0.2.0',
|
|
672
|
+
nodeVersion: process.version
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
},
|
|
676
|
+
|
|
677
|
+
'meta.schema': {
|
|
678
|
+
description: 'Get API schema',
|
|
679
|
+
params: {},
|
|
680
|
+
returns: { schema: 'object' },
|
|
681
|
+
handler(params) {
|
|
682
|
+
return server.getSchema();
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
// Bind handlers
|
|
688
|
+
for (const [key, cmd] of Object.entries(AGENT_COMMANDS)) {
|
|
689
|
+
if (cmd.handler) {
|
|
690
|
+
// Create a wrapper that calls handler with 'this' bound to server
|
|
691
|
+
const originalHandler = cmd.handler;
|
|
692
|
+
cmd.handler = function(params) {
|
|
693
|
+
return originalHandler.call(this, params);
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const NAMESPACE_DESCRIPTIONS = {
|
|
699
|
+
sketch: 'Create 2D sketches on planes',
|
|
700
|
+
ops: 'Perform 3D operations (extrude, fillet, etc)',
|
|
701
|
+
view: 'Control viewport and visualization',
|
|
702
|
+
export: 'Export to various file formats',
|
|
703
|
+
query: 'Query model data',
|
|
704
|
+
validate: 'Validate and analyze models',
|
|
705
|
+
assembly: 'Manage assemblies and components',
|
|
706
|
+
meta: 'Server metadata and introspection'
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
// ============================================================================
|
|
710
|
+
// HTTP SERVER
|
|
711
|
+
// ============================================================================
|
|
712
|
+
|
|
713
|
+
function createServer() {
|
|
714
|
+
return http.createServer((req, res) => {
|
|
715
|
+
const clientIp = req.headers['x-forwarded-for']?.split(',')[0].trim() || req.socket.remoteAddress;
|
|
716
|
+
|
|
717
|
+
// CORS headers
|
|
718
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
719
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, DELETE, PUT');
|
|
720
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-API-Key, Authorization');
|
|
721
|
+
res.setHeader('Access-Control-Max-Age', '3600');
|
|
722
|
+
|
|
723
|
+
// COOP/COEP for SharedArrayBuffer
|
|
724
|
+
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
|
|
725
|
+
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
|
|
726
|
+
|
|
727
|
+
// Handle OPTIONS
|
|
728
|
+
if (req.method === 'OPTIONS') {
|
|
729
|
+
res.writeHead(204);
|
|
730
|
+
res.end();
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Rate limiting
|
|
735
|
+
if (!limiter.isAllowed(clientIp)) {
|
|
736
|
+
respondJSON(res, 429, {
|
|
737
|
+
ok: false,
|
|
738
|
+
error: 'Too many requests',
|
|
739
|
+
retryAfter: 60
|
|
740
|
+
});
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
res.setHeader('RateLimit-Remaining', limiter.remaining(clientIp));
|
|
745
|
+
res.setHeader('RateLimit-Limit', '100');
|
|
746
|
+
res.setHeader('RateLimit-Reset', Math.ceil(Date.now() / 1000) + 60);
|
|
747
|
+
|
|
748
|
+
// API key auth
|
|
749
|
+
if (API_KEY) {
|
|
750
|
+
const providedKey = req.headers['x-api-key'] || new url.URL(req.url, `http://${req.headers.host}`).searchParams.get('api_key');
|
|
751
|
+
if (providedKey !== API_KEY) {
|
|
752
|
+
respondJSON(res, 401, {
|
|
753
|
+
ok: false,
|
|
754
|
+
error: 'Unauthorized - invalid or missing API key'
|
|
755
|
+
});
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const parsedUrl = new url.URL(req.url, `http://${req.headers.host}`);
|
|
761
|
+
const pathname = parsedUrl.pathname;
|
|
762
|
+
|
|
763
|
+
// === ROUTING ===
|
|
764
|
+
|
|
765
|
+
if (pathname === '/api/execute' && req.method === 'POST') {
|
|
766
|
+
handleExecute(req, res);
|
|
767
|
+
} else if (pathname === '/api/batch' && req.method === 'POST') {
|
|
768
|
+
handleBatch(req, res);
|
|
769
|
+
} else if (pathname === '/api/schema' && req.method === 'GET') {
|
|
770
|
+
respondJSON(res, 200, server.getSchema());
|
|
771
|
+
} else if (pathname === '/api/health' && req.method === 'GET') {
|
|
772
|
+
respondJSON(res, 200, server.getHealth());
|
|
773
|
+
} else if (pathname === '/api/history' && req.method === 'GET') {
|
|
774
|
+
const count = parseInt(parsedUrl.searchParams.get('count') || '20', 10);
|
|
775
|
+
respondJSON(res, 200, server.getHistory(count));
|
|
776
|
+
} else if (pathname === '/api/models' && req.method === 'GET') {
|
|
777
|
+
respondJSON(res, 200, {
|
|
778
|
+
ok: true,
|
|
779
|
+
models: server.models,
|
|
780
|
+
count: server.models.length
|
|
781
|
+
});
|
|
782
|
+
} else if (pathname.match(/^\/api\/models\/[^\/]+$/) && req.method === 'GET') {
|
|
783
|
+
const id = pathname.split('/').pop();
|
|
784
|
+
const model = server.models.find(m => m.id === id);
|
|
785
|
+
if (!model) {
|
|
786
|
+
respondJSON(res, 404, { ok: false, error: 'Model not found' });
|
|
787
|
+
} else {
|
|
788
|
+
respondJSON(res, 200, { ok: true, model });
|
|
789
|
+
}
|
|
790
|
+
} else if (pathname.match(/^\/api\/models\/[^\/]+$/) && req.method === 'DELETE') {
|
|
791
|
+
const id = pathname.split('/').pop();
|
|
792
|
+
server.models = server.models.filter(m => m.id !== id);
|
|
793
|
+
respondJSON(res, 200, {
|
|
794
|
+
ok: true,
|
|
795
|
+
message: `Model ${id} deleted`,
|
|
796
|
+
remaining: server.models.length
|
|
797
|
+
});
|
|
798
|
+
} else if (pathname === '/api/ws' && req.headers.upgrade === 'websocket') {
|
|
799
|
+
handleWebSocket(req);
|
|
800
|
+
} else if (pathname === '/') {
|
|
801
|
+
serveFile(res, path.join(STATIC_DIR, 'index.html'));
|
|
802
|
+
} else if (pathname === '/app' || pathname === '/app/') {
|
|
803
|
+
serveFile(res, path.join(STATIC_DIR, 'index.html'));
|
|
804
|
+
} else if (pathname.startsWith('/api/')) {
|
|
805
|
+
respondJSON(res, 404, {
|
|
806
|
+
ok: false,
|
|
807
|
+
error: 'Unknown API endpoint',
|
|
808
|
+
endpoint: pathname,
|
|
809
|
+
availableEndpoints: [
|
|
810
|
+
'POST /api/execute',
|
|
811
|
+
'POST /api/batch',
|
|
812
|
+
'GET /api/schema',
|
|
813
|
+
'GET /api/health',
|
|
814
|
+
'GET /api/history',
|
|
815
|
+
'GET /api/models',
|
|
816
|
+
'GET /api/models/:id',
|
|
817
|
+
'DELETE /api/models/:id',
|
|
818
|
+
'WebSocket /api/ws'
|
|
819
|
+
]
|
|
820
|
+
});
|
|
821
|
+
} else {
|
|
822
|
+
// Serve static files
|
|
823
|
+
const filePath = path.join(STATIC_DIR, pathname);
|
|
824
|
+
serveFile(res, filePath);
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// ============================================================================
|
|
830
|
+
// REQUEST HANDLERS
|
|
831
|
+
// ============================================================================
|
|
832
|
+
|
|
833
|
+
function handleExecute(req, res) {
|
|
834
|
+
let body = '';
|
|
835
|
+
|
|
836
|
+
req.on('data', chunk => {
|
|
837
|
+
body += chunk.toString();
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
req.on('end', () => {
|
|
841
|
+
try {
|
|
842
|
+
const cmd = JSON.parse(body);
|
|
843
|
+
const result = server.executeCommand(cmd);
|
|
844
|
+
respondJSON(res, result.ok ? 200 : 400, result);
|
|
845
|
+
} catch (e) {
|
|
846
|
+
respondJSON(res, 400, {
|
|
847
|
+
ok: false,
|
|
848
|
+
error: `Invalid JSON: ${e.message}`
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function handleBatch(req, res) {
|
|
855
|
+
let body = '';
|
|
856
|
+
|
|
857
|
+
req.on('data', chunk => {
|
|
858
|
+
body += chunk.toString();
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
req.on('end', () => {
|
|
862
|
+
try {
|
|
863
|
+
const payload = JSON.parse(body);
|
|
864
|
+
if (!Array.isArray(payload.commands)) {
|
|
865
|
+
return respondJSON(res, 400, {
|
|
866
|
+
ok: false,
|
|
867
|
+
error: 'Expected { commands: [{ method, params }] }'
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const result = server.executeBatch(payload.commands);
|
|
872
|
+
respondJSON(res, result.ok ? 200 : 400, result);
|
|
873
|
+
} catch (e) {
|
|
874
|
+
respondJSON(res, 400, {
|
|
875
|
+
ok: false,
|
|
876
|
+
error: `Invalid JSON: ${e.message}`
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function handleWebSocket(req) {
|
|
883
|
+
// Basic WebSocket upgrade (simplified, not full RFC 6455)
|
|
884
|
+
const key = req.headers['sec-websocket-key'];
|
|
885
|
+
const sha1 = crypto.createHash('sha1');
|
|
886
|
+
sha1.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11');
|
|
887
|
+
const accept = sha1.digest('base64');
|
|
888
|
+
|
|
889
|
+
const head = Buffer.from(
|
|
890
|
+
`HTTP/1.1 101 Switching Protocols\r\n` +
|
|
891
|
+
`Upgrade: websocket\r\n` +
|
|
892
|
+
`Connection: Upgrade\r\n` +
|
|
893
|
+
`Sec-WebSocket-Accept: ${accept}\r\n` +
|
|
894
|
+
`\r\n`
|
|
895
|
+
);
|
|
896
|
+
|
|
897
|
+
req.socket.write(head);
|
|
898
|
+
|
|
899
|
+
// Create a mock WebSocket client
|
|
900
|
+
const ws = {
|
|
901
|
+
send: (data) => {
|
|
902
|
+
const frame = createWebSocketFrame(data);
|
|
903
|
+
req.socket.write(frame);
|
|
904
|
+
},
|
|
905
|
+
close: () => {
|
|
906
|
+
req.socket.destroy();
|
|
907
|
+
}
|
|
908
|
+
};
|
|
909
|
+
|
|
910
|
+
server.addWSClient(ws);
|
|
911
|
+
|
|
912
|
+
// Send welcome message
|
|
913
|
+
ws.send(JSON.stringify({
|
|
914
|
+
type: 'welcome',
|
|
915
|
+
sessionId: server.sessionId,
|
|
916
|
+
message: 'Connected to cycleCAD API Server'
|
|
917
|
+
}));
|
|
918
|
+
|
|
919
|
+
// Send ping every 30s
|
|
920
|
+
const pingInterval = setInterval(() => {
|
|
921
|
+
try {
|
|
922
|
+
ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() }));
|
|
923
|
+
} catch (e) {
|
|
924
|
+
clearInterval(pingInterval);
|
|
925
|
+
}
|
|
926
|
+
}, 30000);
|
|
927
|
+
|
|
928
|
+
// Parse incoming WebSocket frames (simplified)
|
|
929
|
+
let frameBuffer = Buffer.alloc(0);
|
|
930
|
+
|
|
931
|
+
req.socket.on('data', (chunk) => {
|
|
932
|
+
frameBuffer = Buffer.concat([frameBuffer, chunk]);
|
|
933
|
+
|
|
934
|
+
while (frameBuffer.length >= 2) {
|
|
935
|
+
try {
|
|
936
|
+
const { payload, bytesRead } = parseWebSocketFrame(frameBuffer);
|
|
937
|
+
if (payload === null) break; // Incomplete frame
|
|
938
|
+
|
|
939
|
+
frameBuffer = frameBuffer.slice(bytesRead);
|
|
940
|
+
|
|
941
|
+
if (payload) {
|
|
942
|
+
try {
|
|
943
|
+
const cmd = JSON.parse(payload);
|
|
944
|
+
const result = server.executeCommand(cmd);
|
|
945
|
+
ws.send(JSON.stringify(result));
|
|
946
|
+
} catch (e) {
|
|
947
|
+
ws.send(JSON.stringify({ ok: false, error: e.message }));
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
} catch (e) {
|
|
951
|
+
ws.close();
|
|
952
|
+
break;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
req.socket.on('end', () => {
|
|
958
|
+
clearInterval(pingInterval);
|
|
959
|
+
server.removeWSClient(ws);
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
req.socket.on('error', () => {
|
|
963
|
+
clearInterval(pingInterval);
|
|
964
|
+
server.removeWSClient(ws);
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// ============================================================================
|
|
969
|
+
// HELPERS
|
|
970
|
+
// ============================================================================
|
|
971
|
+
|
|
972
|
+
function respondJSON(res, statusCode, data) {
|
|
973
|
+
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
974
|
+
res.end(JSON.stringify(data, null, 2));
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function serveFile(res, filePath) {
|
|
978
|
+
// Prevent directory traversal
|
|
979
|
+
const normalizedPath = path.normalize(filePath);
|
|
980
|
+
const normalizedBase = path.normalize(STATIC_DIR);
|
|
981
|
+
|
|
982
|
+
if (!normalizedPath.startsWith(normalizedBase)) {
|
|
983
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
984
|
+
res.end('Forbidden');
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
fs.stat(filePath, (err, stats) => {
|
|
989
|
+
if (err) {
|
|
990
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
991
|
+
res.end('Not found');
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
if (stats.isDirectory()) {
|
|
996
|
+
serveFile(res, path.join(filePath, 'index.html'));
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const ext = path.extname(filePath);
|
|
1001
|
+
const mimeTypes = {
|
|
1002
|
+
'.html': 'text/html; charset=utf-8',
|
|
1003
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
1004
|
+
'.css': 'text/css',
|
|
1005
|
+
'.json': 'application/json',
|
|
1006
|
+
'.png': 'image/png',
|
|
1007
|
+
'.jpg': 'image/jpeg',
|
|
1008
|
+
'.gif': 'image/gif',
|
|
1009
|
+
'.svg': 'image/svg+xml',
|
|
1010
|
+
'.wasm': 'application/wasm',
|
|
1011
|
+
'.woff': 'font/woff',
|
|
1012
|
+
'.woff2': 'font/woff2'
|
|
1013
|
+
};
|
|
1014
|
+
|
|
1015
|
+
const contentType = mimeTypes[ext] || 'application/octet-stream';
|
|
1016
|
+
|
|
1017
|
+
res.writeHead(200, {
|
|
1018
|
+
'Content-Type': contentType,
|
|
1019
|
+
'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=3600'
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
fs.createReadStream(filePath).pipe(res);
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function createWebSocketFrame(data) {
|
|
1027
|
+
const buffer = Buffer.from(data);
|
|
1028
|
+
const frame = Buffer.alloc(buffer.length + 2);
|
|
1029
|
+
frame[0] = 0x81; // FIN + text frame
|
|
1030
|
+
frame[1] = buffer.length;
|
|
1031
|
+
buffer.copy(frame, 2);
|
|
1032
|
+
return frame;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function parseWebSocketFrame(buffer) {
|
|
1036
|
+
if (buffer.length < 2) return { payload: null, bytesRead: 0 };
|
|
1037
|
+
|
|
1038
|
+
const opcode = buffer[0] & 0x0f;
|
|
1039
|
+
const masked = (buffer[1] & 0x80) !== 0;
|
|
1040
|
+
let payloadLength = buffer[1] & 0x7f;
|
|
1041
|
+
let offset = 2;
|
|
1042
|
+
|
|
1043
|
+
if (payloadLength === 126) {
|
|
1044
|
+
if (buffer.length < 4) return { payload: null, bytesRead: 0 };
|
|
1045
|
+
payloadLength = buffer.readUInt16BE(2);
|
|
1046
|
+
offset = 4;
|
|
1047
|
+
} else if (payloadLength === 127) {
|
|
1048
|
+
if (buffer.length < 10) return { payload: null, bytesRead: 0 };
|
|
1049
|
+
payloadLength = Number(buffer.readBigUInt64BE(2));
|
|
1050
|
+
offset = 10;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
const maskOffset = masked ? 4 : 0;
|
|
1054
|
+
const totalLength = offset + maskOffset + payloadLength;
|
|
1055
|
+
|
|
1056
|
+
if (buffer.length < totalLength) return { payload: null, bytesRead: 0 };
|
|
1057
|
+
|
|
1058
|
+
let payload = buffer.slice(offset + maskOffset, offset + maskOffset + payloadLength);
|
|
1059
|
+
|
|
1060
|
+
if (masked) {
|
|
1061
|
+
const mask = buffer.slice(offset, offset + 4);
|
|
1062
|
+
for (let i = 0; i < payload.length; i++) {
|
|
1063
|
+
payload[i] ^= mask[i % 4];
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
return {
|
|
1068
|
+
payload: opcode === 1 ? payload.toString('utf8') : null,
|
|
1069
|
+
bytesRead: totalLength
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// ============================================================================
|
|
1074
|
+
// STARTUP
|
|
1075
|
+
// ============================================================================
|
|
1076
|
+
|
|
1077
|
+
function startup() {
|
|
1078
|
+
const httpServer = createServer();
|
|
1079
|
+
|
|
1080
|
+
httpServer.listen(PORT, HOST, () => {
|
|
1081
|
+
const isDev = DEV_MODE ? ' [DEV MODE]' : '';
|
|
1082
|
+
const banner = `
|
|
1083
|
+
╔═══════════════════════════════════════════════════════════╗
|
|
1084
|
+
║ cycleCAD API Server v0.2.0${isDev.padEnd(20)} ║
|
|
1085
|
+
║ ║
|
|
1086
|
+
║ HTTP: http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${PORT} ║
|
|
1087
|
+
║ API: POST /api/execute ║
|
|
1088
|
+
║ Batch: POST /api/batch ║
|
|
1089
|
+
║ Schema: GET /api/schema ║
|
|
1090
|
+
║ Health: GET /api/health ║
|
|
1091
|
+
║ History: GET /api/history ║
|
|
1092
|
+
║ Models: GET /api/models ║
|
|
1093
|
+
║ WebSocket: ws://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${PORT}/api/ws ║
|
|
1094
|
+
║ Static: ${STATIC_DIR.slice(-30).padEnd(30)} ║
|
|
1095
|
+
║ ║
|
|
1096
|
+
║ Rate Limit: 100 requests/minute ║
|
|
1097
|
+
║ Session ID: ${server.sessionId.slice(0, 50).padEnd(50)} ║
|
|
1098
|
+
╚═══════════════════════════════════════════════════════════╝
|
|
1099
|
+
`;
|
|
1100
|
+
console.log(banner);
|
|
1101
|
+
|
|
1102
|
+
if (DEV_MODE) {
|
|
1103
|
+
console.log('✓ Development mode enabled');
|
|
1104
|
+
}
|
|
1105
|
+
if (API_KEY) {
|
|
1106
|
+
console.log(`✓ API key authentication enabled (${API_KEY.slice(0, 8)}...)`);
|
|
1107
|
+
}
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
// Graceful shutdown
|
|
1111
|
+
process.on('SIGINT', () => {
|
|
1112
|
+
console.log('\n\nShutting down gracefully...');
|
|
1113
|
+
httpServer.close(() => {
|
|
1114
|
+
console.log('Server closed');
|
|
1115
|
+
process.exit(0);
|
|
1116
|
+
});
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
startup();
|