figma-local 1.0.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.
- package/README.md +342 -0
- package/bin/fig-start +289 -0
- package/bin/setup-alias.sh +48 -0
- package/package.json +47 -0
- package/src/blocks/dashboard-01.js +379 -0
- package/src/blocks/index.js +27 -0
- package/src/daemon.js +664 -0
- package/src/figjam-client.js +313 -0
- package/src/figma-client.js +4198 -0
- package/src/figma-patch.js +185 -0
- package/src/index.js +8543 -0
- package/src/platform.js +206 -0
- package/src/prompt-templates.js +289 -0
- package/src/read.js +243 -0
- package/src/shadcn.js +237 -0
package/src/daemon.js
ADDED
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Figma CLI Daemon
|
|
5
|
+
*
|
|
6
|
+
* Supports two modes:
|
|
7
|
+
* - Yolo Mode (CDP): Direct connection via Chrome DevTools Protocol (fast, requires patching)
|
|
8
|
+
* - Safe Mode (Plugin): Connection via Figma plugin WebSocket (secure, no patching)
|
|
9
|
+
*
|
|
10
|
+
* Security features:
|
|
11
|
+
* - Session token authentication (X-Daemon-Token header)
|
|
12
|
+
* - No CORS headers (blocks cross-origin browser requests)
|
|
13
|
+
* - Host header validation (blocks DNS rebinding)
|
|
14
|
+
* - Idle timeout auto-shutdown (configurable, default 10 minutes)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createServer } from 'http';
|
|
18
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
19
|
+
import { randomBytes } from 'crypto';
|
|
20
|
+
import { readFileSync, statSync, writeFileSync, unlinkSync, readdirSync } from 'fs';
|
|
21
|
+
import { join, dirname } from 'path';
|
|
22
|
+
import { homedir, tmpdir } from 'os';
|
|
23
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
24
|
+
|
|
25
|
+
// Hot-reload FigmaClient: copy to temp file and import (Node.js ES modules don't support cache busting)
|
|
26
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const figmaClientPath = join(__dirname, 'figma-client.js');
|
|
28
|
+
let FigmaClient = null;
|
|
29
|
+
let lastModTime = 0;
|
|
30
|
+
let lastTempFile = null;
|
|
31
|
+
|
|
32
|
+
async function getFigmaClient() {
|
|
33
|
+
try {
|
|
34
|
+
const stat = statSync(figmaClientPath);
|
|
35
|
+
const modTime = stat.mtimeMs;
|
|
36
|
+
|
|
37
|
+
// Reload if file changed or never loaded
|
|
38
|
+
if (!FigmaClient || modTime > lastModTime) {
|
|
39
|
+
// Clean up old temp files in project directory (keeps node_modules accessible)
|
|
40
|
+
try {
|
|
41
|
+
const oldFiles = readdirSync(__dirname).filter(f => f.startsWith('.figma-client-') && f.endsWith('.mjs'));
|
|
42
|
+
for (const f of oldFiles) {
|
|
43
|
+
try { unlinkSync(join(__dirname, f)); } catch {}
|
|
44
|
+
}
|
|
45
|
+
} catch {}
|
|
46
|
+
|
|
47
|
+
// Copy to temp file in same directory (so imports resolve correctly)
|
|
48
|
+
const tempFile = join(__dirname, `.figma-client-${modTime}.mjs`);
|
|
49
|
+
const content = readFileSync(figmaClientPath, 'utf8');
|
|
50
|
+
writeFileSync(tempFile, content);
|
|
51
|
+
lastTempFile = tempFile;
|
|
52
|
+
|
|
53
|
+
// Import from temp file
|
|
54
|
+
const tempUrl = pathToFileURL(tempFile).href;
|
|
55
|
+
const module = await import(tempUrl);
|
|
56
|
+
FigmaClient = module.FigmaClient;
|
|
57
|
+
|
|
58
|
+
const wasReload = lastModTime > 0;
|
|
59
|
+
lastModTime = modTime;
|
|
60
|
+
if (wasReload) console.log('[daemon] Hot-reloaded figma-client.js');
|
|
61
|
+
}
|
|
62
|
+
} catch (e) {
|
|
63
|
+
console.error('[daemon] Hot-reload error:', e.message);
|
|
64
|
+
// Fallback: just import normally
|
|
65
|
+
if (!FigmaClient) {
|
|
66
|
+
const module = await import('./figma-client.js');
|
|
67
|
+
FigmaClient = module.FigmaClient;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return FigmaClient;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const PORT = parseInt(process.env.DAEMON_PORT) || 3456;
|
|
74
|
+
const MODE = process.env.DAEMON_MODE || 'auto'; // 'auto', 'cdp', 'plugin'
|
|
75
|
+
const IDLE_TIMEOUT_MS = parseInt(process.env.DAEMON_IDLE_TIMEOUT) || 10 * 60 * 1000; // Default: 10 minutes
|
|
76
|
+
|
|
77
|
+
// ============ SECURITY ============
|
|
78
|
+
|
|
79
|
+
// Load session token (generated by CLI on daemon start)
|
|
80
|
+
const TOKEN_FILE = join(homedir(), '.figma-ds-cli', '.daemon-token');
|
|
81
|
+
let SESSION_TOKEN = null;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
SESSION_TOKEN = readFileSync(TOKEN_FILE, 'utf8').trim();
|
|
85
|
+
console.log('[daemon] Session token loaded');
|
|
86
|
+
} catch {
|
|
87
|
+
console.error('[daemon] WARNING: No session token found. Daemon will reject all requests.');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Validate request authentication and origin
|
|
92
|
+
* Returns null if valid, or an error string if rejected
|
|
93
|
+
*/
|
|
94
|
+
function validateRequest(req) {
|
|
95
|
+
// Layer 1: Host header validation (blocks DNS rebinding)
|
|
96
|
+
const host = req.headers.host || '';
|
|
97
|
+
if (!host.match(/^(localhost|127\.0\.0\.1)(:\d+)?$/)) {
|
|
98
|
+
return 'Invalid host header';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Layer 2: Session token (blocks unauthorized local processes)
|
|
102
|
+
const token = req.headers['x-daemon-token'];
|
|
103
|
+
if (!SESSION_TOKEN) {
|
|
104
|
+
return 'No session token configured';
|
|
105
|
+
}
|
|
106
|
+
if (token !== SESSION_TOKEN) {
|
|
107
|
+
return 'Invalid or missing token';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return null; // Valid
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ============ IDLE TIMEOUT ============
|
|
114
|
+
|
|
115
|
+
let lastActivityTime = Date.now();
|
|
116
|
+
let idleTimer = null;
|
|
117
|
+
|
|
118
|
+
function resetIdleTimer() {
|
|
119
|
+
lastActivityTime = Date.now();
|
|
120
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
121
|
+
idleTimer = setTimeout(() => {
|
|
122
|
+
const idleSecs = Math.round((Date.now() - lastActivityTime) / 1000);
|
|
123
|
+
console.log(`[daemon] Idle for ${idleSecs}s — auto-shutting down`);
|
|
124
|
+
shutdown();
|
|
125
|
+
}, IDLE_TIMEOUT_MS);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Start the idle timer immediately
|
|
129
|
+
resetIdleTimer()
|
|
130
|
+
|
|
131
|
+
// CDP Client (Yolo Mode)
|
|
132
|
+
let cdpClient = null;
|
|
133
|
+
let isCdpConnecting = false;
|
|
134
|
+
let lastHealthCheck = 0;
|
|
135
|
+
let lastHealthResult = false;
|
|
136
|
+
const HEALTH_CACHE_MS = 30000; // Cache health for 30 seconds (reduces overhead)
|
|
137
|
+
|
|
138
|
+
// Plugin Client (Safe Mode)
|
|
139
|
+
let pluginWs = null;
|
|
140
|
+
let pluginPendingRequests = new Map();
|
|
141
|
+
let pluginMsgId = 0;
|
|
142
|
+
|
|
143
|
+
// ============ CDP MODE (YOLO) ============
|
|
144
|
+
|
|
145
|
+
async function isCdpHealthy(forceCheck = false) {
|
|
146
|
+
// Quick WebSocket state check (no network call)
|
|
147
|
+
if (!cdpClient || !cdpClient.ws) return false;
|
|
148
|
+
if (cdpClient.ws.readyState !== 1) return false;
|
|
149
|
+
|
|
150
|
+
// Use cached result if recent (avoids constant eval calls)
|
|
151
|
+
const now = Date.now();
|
|
152
|
+
if (!forceCheck && now - lastHealthCheck < HEALTH_CACHE_MS) {
|
|
153
|
+
return lastHealthResult;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const result = await Promise.race([
|
|
158
|
+
cdpClient.eval('1'), // Simple eval, just check connection works
|
|
159
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 2000))
|
|
160
|
+
]);
|
|
161
|
+
lastHealthCheck = now;
|
|
162
|
+
lastHealthResult = result === 1;
|
|
163
|
+
return lastHealthResult;
|
|
164
|
+
} catch {
|
|
165
|
+
lastHealthCheck = now;
|
|
166
|
+
lastHealthResult = false;
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function getCdpClient() {
|
|
172
|
+
// Fast path: if we have a client with open WebSocket, use it
|
|
173
|
+
if (cdpClient && cdpClient.ws && cdpClient.ws.readyState === 1) {
|
|
174
|
+
return cdpClient;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// WebSocket closed, need to reconnect
|
|
178
|
+
if (cdpClient) {
|
|
179
|
+
console.log('[daemon] CDP WebSocket closed, reconnecting...');
|
|
180
|
+
try { cdpClient.close(); } catch {}
|
|
181
|
+
cdpClient = null;
|
|
182
|
+
lastHealthResult = false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Wait if another connection attempt is in progress
|
|
186
|
+
if (isCdpConnecting) {
|
|
187
|
+
while (isCdpConnecting) {
|
|
188
|
+
await new Promise(r => setTimeout(r, 100));
|
|
189
|
+
}
|
|
190
|
+
return cdpClient;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
isCdpConnecting = true;
|
|
194
|
+
try {
|
|
195
|
+
const ClientClass = await getFigmaClient();
|
|
196
|
+
cdpClient = new ClientClass();
|
|
197
|
+
await cdpClient.connect();
|
|
198
|
+
lastHealthCheck = Date.now();
|
|
199
|
+
lastHealthResult = true;
|
|
200
|
+
console.log('[daemon] Connected to Figma via CDP (Yolo Mode)');
|
|
201
|
+
} finally {
|
|
202
|
+
isCdpConnecting = false;
|
|
203
|
+
}
|
|
204
|
+
return cdpClient;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function evalViaCdp(code) {
|
|
208
|
+
const client = await getCdpClient();
|
|
209
|
+
return client.eval(code);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ============ PLUGIN MODE (SAFE) ============
|
|
213
|
+
|
|
214
|
+
function isPluginConnected() {
|
|
215
|
+
return pluginWs && pluginWs.readyState === WebSocket.OPEN;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function evalViaPlugin(code, retryCount = 0) {
|
|
219
|
+
if (!isPluginConnected()) {
|
|
220
|
+
throw new Error('Plugin not connected. Start the Figma CLI Bridge plugin in Figma.');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return new Promise((resolve, reject) => {
|
|
224
|
+
const id = ++pluginMsgId;
|
|
225
|
+
const timeout = setTimeout(() => {
|
|
226
|
+
pluginPendingRequests.delete(id);
|
|
227
|
+
reject(new Error('Plugin execution timeout (25s)'));
|
|
228
|
+
}, 25000); // 25s timeout to match plugin-side timeout
|
|
229
|
+
|
|
230
|
+
pluginPendingRequests.set(id, { resolve, reject, timeout, code, retryCount });
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
pluginWs.send(JSON.stringify({
|
|
234
|
+
action: 'eval',
|
|
235
|
+
id: id,
|
|
236
|
+
code: code
|
|
237
|
+
}));
|
|
238
|
+
} catch (sendError) {
|
|
239
|
+
clearTimeout(timeout);
|
|
240
|
+
pluginPendingRequests.delete(id);
|
|
241
|
+
reject(new Error(`Plugin send error: ${sendError.message}`));
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Batch eval via plugin (execute multiple codes, return all results)
|
|
247
|
+
async function evalBatchViaPlugin(codes) {
|
|
248
|
+
if (!isPluginConnected()) {
|
|
249
|
+
throw new Error('Plugin not connected. Start the Figma CLI Bridge plugin in Figma.');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return new Promise((resolve, reject) => {
|
|
253
|
+
const id = ++pluginMsgId;
|
|
254
|
+
const timeout = setTimeout(() => {
|
|
255
|
+
pluginPendingRequests.delete(id);
|
|
256
|
+
reject(new Error('Plugin batch execution timeout (60s)'));
|
|
257
|
+
}, 60000); // 60s for batches
|
|
258
|
+
|
|
259
|
+
pluginPendingRequests.set(id, { resolve, reject, timeout, isBatch: true });
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
pluginWs.send(JSON.stringify({
|
|
263
|
+
action: 'eval-batch',
|
|
264
|
+
id: id,
|
|
265
|
+
codes: codes
|
|
266
|
+
}));
|
|
267
|
+
} catch (sendError) {
|
|
268
|
+
clearTimeout(timeout);
|
|
269
|
+
pluginPendingRequests.delete(id);
|
|
270
|
+
reject(new Error(`Plugin batch send error: ${sendError.message}`));
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ============ UNIFIED EVAL ============
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Wrap code to handle top-level return statements
|
|
279
|
+
* CDP's Runtime.evaluate doesn't support bare return, so wrap in IIFE if needed
|
|
280
|
+
*/
|
|
281
|
+
function wrapCodeIfNeeded(code) {
|
|
282
|
+
const trimmed = code.trim();
|
|
283
|
+
|
|
284
|
+
// Already wrapped in IIFE or async IIFE
|
|
285
|
+
if (trimmed.startsWith('(async') || trimmed.startsWith('(function') || trimmed.startsWith('(() =>')) {
|
|
286
|
+
return code;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Check if code has top-level return (not inside a function)
|
|
290
|
+
// Simple heuristic: if 'return' appears before the first '{' or at start
|
|
291
|
+
const hasTopLevelReturn = /^\s*return\b/.test(trimmed) ||
|
|
292
|
+
/;\s*return\b/.test(trimmed) ||
|
|
293
|
+
/^\s*const\s+\w+\s*=.*;\s*return\b/m.test(trimmed);
|
|
294
|
+
|
|
295
|
+
if (hasTopLevelReturn) {
|
|
296
|
+
// Wrap in async IIFE to support both sync and async code
|
|
297
|
+
return `(async () => { ${code} })()`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return code;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function executeEval(code) {
|
|
304
|
+
// Auto mode: prefer plugin if connected, fallback to CDP
|
|
305
|
+
if (MODE === 'auto') {
|
|
306
|
+
if (isPluginConnected()) {
|
|
307
|
+
// Plugin handles its own wrapping, send code as-is
|
|
308
|
+
return evalViaPlugin(code);
|
|
309
|
+
}
|
|
310
|
+
// CDP needs wrapping for top-level return statements
|
|
311
|
+
return evalViaCdp(wrapCodeIfNeeded(code));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Explicit mode
|
|
315
|
+
if (MODE === 'plugin') {
|
|
316
|
+
// Plugin handles its own wrapping
|
|
317
|
+
return evalViaPlugin(code);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// CDP mode
|
|
321
|
+
return evalViaCdp(wrapCodeIfNeeded(code));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function getMode() {
|
|
325
|
+
if (MODE === 'plugin') return 'safe';
|
|
326
|
+
if (MODE === 'cdp') return 'yolo';
|
|
327
|
+
// Auto: return what's actually connected
|
|
328
|
+
if (isPluginConnected()) return 'safe';
|
|
329
|
+
if (cdpClient) return 'yolo';
|
|
330
|
+
return 'disconnected';
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ============ HTTP SERVER ============
|
|
334
|
+
|
|
335
|
+
async function handleRequest(req, res) {
|
|
336
|
+
// Reset idle timer on every request
|
|
337
|
+
resetIdleTimer();
|
|
338
|
+
|
|
339
|
+
// SECURITY: No CORS headers (blocks all cross-origin browser requests)
|
|
340
|
+
|
|
341
|
+
// Block preflight requests (browsers send OPTIONS before cross-origin POST)
|
|
342
|
+
if (req.method === 'OPTIONS') {
|
|
343
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
344
|
+
res.end(JSON.stringify({ error: 'CORS preflight rejected' }));
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// SECURITY: Validate authentication on all routes
|
|
349
|
+
const authError = validateRequest(req);
|
|
350
|
+
if (authError) {
|
|
351
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
352
|
+
res.end(JSON.stringify({ error: 'Unauthorized: ' + authError }));
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Health check
|
|
357
|
+
if (req.url === '/health') {
|
|
358
|
+
const mode = getMode();
|
|
359
|
+
const pluginConnected = isPluginConnected();
|
|
360
|
+
const cdpHealthy = await isCdpHealthy();
|
|
361
|
+
|
|
362
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
363
|
+
res.end(JSON.stringify({
|
|
364
|
+
status: (pluginConnected || cdpHealthy) ? 'ok' : 'disconnected',
|
|
365
|
+
mode: mode,
|
|
366
|
+
plugin: pluginConnected,
|
|
367
|
+
cdp: cdpHealthy,
|
|
368
|
+
idleTimeoutMs: IDLE_TIMEOUT_MS
|
|
369
|
+
}));
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Force reconnect (CDP only)
|
|
374
|
+
if (req.url === '/reconnect') {
|
|
375
|
+
try {
|
|
376
|
+
if (cdpClient) {
|
|
377
|
+
try { cdpClient.close(); } catch {}
|
|
378
|
+
cdpClient = null;
|
|
379
|
+
}
|
|
380
|
+
await getCdpClient();
|
|
381
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
382
|
+
res.end(JSON.stringify({ status: 'reconnected', mode: 'yolo' }));
|
|
383
|
+
} catch (error) {
|
|
384
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
385
|
+
res.end(JSON.stringify({ error: error.message }));
|
|
386
|
+
}
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Execute command
|
|
391
|
+
if (req.url === '/exec' && req.method === 'POST') {
|
|
392
|
+
let body = '';
|
|
393
|
+
let bodyBytes = 0;
|
|
394
|
+
req.on('data', chunk => {
|
|
395
|
+
bodyBytes += chunk.length;
|
|
396
|
+
if (bodyBytes > MAX_BODY_BYTES) {
|
|
397
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
398
|
+
res.end(JSON.stringify({ error: 'Request body too large' }));
|
|
399
|
+
req.destroy();
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
body += chunk;
|
|
403
|
+
});
|
|
404
|
+
req.on('end', async () => {
|
|
405
|
+
const MAX_RETRIES = 2;
|
|
406
|
+
let lastError;
|
|
407
|
+
|
|
408
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
409
|
+
try {
|
|
410
|
+
const { action, code, jsx, jsxArray, gap, vertical } = JSON.parse(body);
|
|
411
|
+
let result;
|
|
412
|
+
|
|
413
|
+
const execWithTimeout = async (fn, timeoutMs = 30000) => {
|
|
414
|
+
return Promise.race([
|
|
415
|
+
fn(),
|
|
416
|
+
new Promise((_, reject) =>
|
|
417
|
+
setTimeout(() => reject(new Error(`Execution timeout (${timeoutMs/1000}s)`)), timeoutMs)
|
|
418
|
+
)
|
|
419
|
+
]);
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
switch (action) {
|
|
423
|
+
case 'eval':
|
|
424
|
+
result = await execWithTimeout(() => executeEval(code));
|
|
425
|
+
break;
|
|
426
|
+
case 'render': {
|
|
427
|
+
// Parse JSX to code, then execute via unified eval (works with both CDP and Plugin)
|
|
428
|
+
const ClientClass = await getFigmaClient();
|
|
429
|
+
const parser = new ClientClass();
|
|
430
|
+
const renderCode = await parser.parseJSX(jsx);
|
|
431
|
+
result = await execWithTimeout(() => executeEval(renderCode), 90000); // 90s for renders with icons
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
case 'render-batch': {
|
|
435
|
+
// Single eval for ALL frames (10x faster than loop)
|
|
436
|
+
const ClientClass = await getFigmaClient();
|
|
437
|
+
const batchParser = new ClientClass();
|
|
438
|
+
const batchCode = batchParser.parseJSXBatch(jsxArray, {
|
|
439
|
+
gap: gap || 40,
|
|
440
|
+
vertical: vertical || false
|
|
441
|
+
});
|
|
442
|
+
result = await execWithTimeout(() => executeEval(batchCode), 60000); // 60s for batches
|
|
443
|
+
break;
|
|
444
|
+
}
|
|
445
|
+
default:
|
|
446
|
+
throw new Error(`Unknown action: ${action}`);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
450
|
+
res.end(JSON.stringify({ result, mode: getMode() }));
|
|
451
|
+
return;
|
|
452
|
+
} catch (error) {
|
|
453
|
+
lastError = error;
|
|
454
|
+
console.log(`[daemon] Attempt ${attempt + 1} failed: ${error.message}`);
|
|
455
|
+
|
|
456
|
+
// For Safe Mode: wait briefly for potential reconnect
|
|
457
|
+
if (attempt < MAX_RETRIES && MODE === 'plugin') {
|
|
458
|
+
console.log('[daemon] Safe Mode: waiting for plugin reconnect...');
|
|
459
|
+
// Wait up to 2s for plugin to reconnect
|
|
460
|
+
for (let i = 0; i < 10; i++) {
|
|
461
|
+
await new Promise(r => setTimeout(r, 200));
|
|
462
|
+
if (isPluginConnected()) {
|
|
463
|
+
console.log('[daemon] Plugin reconnected, retrying...');
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// For Yolo Mode: force reconnect
|
|
470
|
+
if (attempt < MAX_RETRIES && MODE !== 'plugin' && !isPluginConnected()) {
|
|
471
|
+
console.log('[daemon] Reconnecting CDP before retry...');
|
|
472
|
+
try { cdpClient.close(); } catch {}
|
|
473
|
+
cdpClient = null;
|
|
474
|
+
await new Promise(r => setTimeout(r, 200));
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
480
|
+
res.end(JSON.stringify({ error: lastError.message }));
|
|
481
|
+
});
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
res.writeHead(404);
|
|
486
|
+
res.end('Not found');
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// ============ START SERVERS ============
|
|
490
|
+
|
|
491
|
+
const httpServer = createServer(handleRequest);
|
|
492
|
+
|
|
493
|
+
// ============ SECURITY: WebSocket nonce handshake ============
|
|
494
|
+
// Prevents rogue local processes from connecting as the plugin.
|
|
495
|
+
// Daemon sends a one-time challenge nonce on connect; plugin must echo it
|
|
496
|
+
// back in the hello message within HANDSHAKE_TIMEOUT_MS or gets closed.
|
|
497
|
+
|
|
498
|
+
const HANDSHAKE_TIMEOUT_MS = 5000;
|
|
499
|
+
const MAX_BODY_BYTES = 1 * 1024 * 1024; // 1 MB request body cap
|
|
500
|
+
|
|
501
|
+
// WebSocket: 1 MB max payload, reject oversized messages before parsing
|
|
502
|
+
const wss = new WebSocketServer({ server: httpServer, path: '/plugin', maxPayload: MAX_BODY_BYTES });
|
|
503
|
+
|
|
504
|
+
// ── Rate limiting per WebSocket connection ──
|
|
505
|
+
// Max 30 eval messages per 10-second window
|
|
506
|
+
const RATE_WINDOW_MS = 10_000;
|
|
507
|
+
const RATE_MAX = 30;
|
|
508
|
+
|
|
509
|
+
function makeRateLimiter() {
|
|
510
|
+
let count = 0;
|
|
511
|
+
let windowStart = Date.now();
|
|
512
|
+
return function isAllowed() {
|
|
513
|
+
const now = Date.now();
|
|
514
|
+
if (now - windowStart > RATE_WINDOW_MS) { count = 0; windowStart = now; }
|
|
515
|
+
if (count >= RATE_MAX) return false;
|
|
516
|
+
count++;
|
|
517
|
+
return true;
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
wss.on('connection', (ws, req) => {
|
|
522
|
+
// ── Layer 1: Origin header check ──
|
|
523
|
+
// Figma plugins send no Origin or a figma:// origin.
|
|
524
|
+
// A browser tab trying to connect would send an http(s):// origin — reject it.
|
|
525
|
+
const origin = req.headers['origin'] || '';
|
|
526
|
+
if (origin && !origin.startsWith('figma://') && !origin.startsWith('null')) {
|
|
527
|
+
console.warn(`[daemon] WebSocket rejected: bad origin "${origin}"`);
|
|
528
|
+
ws.close(1008, 'Invalid origin');
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ── Layer 2: Nonce handshake ──
|
|
533
|
+
// Daemon sends a random challenge; plugin must echo the same nonce in hello.
|
|
534
|
+
const nonce = randomBytes(16).toString('hex');
|
|
535
|
+
let authenticated = false;
|
|
536
|
+
|
|
537
|
+
const handshakeTimer = setTimeout(() => {
|
|
538
|
+
if (!authenticated) {
|
|
539
|
+
console.warn('[daemon] WebSocket handshake timeout — closing unauthenticated connection');
|
|
540
|
+
ws.close(1008, 'Handshake timeout');
|
|
541
|
+
}
|
|
542
|
+
}, HANDSHAKE_TIMEOUT_MS);
|
|
543
|
+
|
|
544
|
+
ws.send(JSON.stringify({ type: 'challenge', nonce }));
|
|
545
|
+
|
|
546
|
+
// ── Layer 3: Rate limiter (per connection) ──
|
|
547
|
+
const checkRate = makeRateLimiter();
|
|
548
|
+
|
|
549
|
+
console.log('[daemon] Plugin connecting (Safe Mode) — awaiting handshake');
|
|
550
|
+
resetIdleTimer();
|
|
551
|
+
|
|
552
|
+
ws.on('message', (data) => {
|
|
553
|
+
resetIdleTimer();
|
|
554
|
+
|
|
555
|
+
// ── Layer 4: Message size guard (belt-and-suspenders over maxPayload) ──
|
|
556
|
+
if (data.length > MAX_BODY_BYTES) {
|
|
557
|
+
console.warn('[daemon] WebSocket message too large, dropping');
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
try {
|
|
562
|
+
const msg = JSON.parse(data.toString());
|
|
563
|
+
|
|
564
|
+
// ── Handshake: must be first message ──
|
|
565
|
+
if (!authenticated) {
|
|
566
|
+
if (msg.type === 'hello' && typeof msg.nonce === 'string' && msg.nonce === nonce) {
|
|
567
|
+
authenticated = true;
|
|
568
|
+
clearTimeout(handshakeTimer);
|
|
569
|
+
pluginWs = ws;
|
|
570
|
+
console.log(`[daemon] Plugin authenticated (v${msg.version || 'unknown'})`);
|
|
571
|
+
} else {
|
|
572
|
+
console.warn('[daemon] WebSocket bad handshake — closing');
|
|
573
|
+
ws.close(1008, 'Authentication failed');
|
|
574
|
+
}
|
|
575
|
+
return; // hello is consumed; don't process further
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// ── Rate limit all post-auth messages ──
|
|
579
|
+
if (!checkRate()) {
|
|
580
|
+
console.warn('[daemon] WebSocket rate limit exceeded — dropping message');
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (msg.type === 'result') {
|
|
585
|
+
const pending = pluginPendingRequests.get(msg.id);
|
|
586
|
+
if (pending) {
|
|
587
|
+
clearTimeout(pending.timeout);
|
|
588
|
+
pluginPendingRequests.delete(msg.id);
|
|
589
|
+
|
|
590
|
+
if (msg.error) {
|
|
591
|
+
pending.reject(new Error(msg.error));
|
|
592
|
+
} else {
|
|
593
|
+
pending.resolve(msg.result);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Batch result from plugin
|
|
599
|
+
if (msg.type === 'batch-result') {
|
|
600
|
+
const pending = pluginPendingRequests.get(msg.id);
|
|
601
|
+
if (pending) {
|
|
602
|
+
clearTimeout(pending.timeout);
|
|
603
|
+
pluginPendingRequests.delete(msg.id);
|
|
604
|
+
pending.resolve(msg.results);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Keepalive ping from plugin
|
|
609
|
+
if (msg.type === 'ping') {
|
|
610
|
+
ws.send(JSON.stringify({ type: 'pong' }));
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (msg.type === 'pong') {
|
|
614
|
+
// Health check response
|
|
615
|
+
}
|
|
616
|
+
} catch (e) {
|
|
617
|
+
console.error('[daemon] Plugin message error:', e);
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
ws.on('close', () => {
|
|
622
|
+
console.log('[daemon] Plugin disconnected');
|
|
623
|
+
pluginWs = null;
|
|
624
|
+
|
|
625
|
+
// Reject all pending requests
|
|
626
|
+
for (const [id, pending] of pluginPendingRequests) {
|
|
627
|
+
clearTimeout(pending.timeout);
|
|
628
|
+
pending.reject(new Error('Plugin disconnected'));
|
|
629
|
+
}
|
|
630
|
+
pluginPendingRequests.clear();
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
ws.on('error', (error) => {
|
|
634
|
+
console.error('[daemon] Plugin WebSocket error:', error.message);
|
|
635
|
+
});
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
httpServer.listen(PORT, '127.0.0.1', () => {
|
|
639
|
+
console.log(`[daemon] Figma CLI daemon running on port ${PORT}`);
|
|
640
|
+
console.log(`[daemon] Mode: ${MODE === 'auto' ? 'auto (plugin preferred, CDP fallback)' : MODE}`);
|
|
641
|
+
console.log(`[daemon] Idle timeout: ${IDLE_TIMEOUT_MS / 1000}s`);
|
|
642
|
+
console.log(`[daemon] Auth: token required`);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
// Graceful shutdown
|
|
646
|
+
process.on('SIGTERM', shutdown);
|
|
647
|
+
process.on('SIGINT', shutdown);
|
|
648
|
+
|
|
649
|
+
function shutdown() {
|
|
650
|
+
console.log('[daemon] Shutting down...');
|
|
651
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
652
|
+
if (cdpClient) cdpClient.close();
|
|
653
|
+
if (pluginWs) pluginWs.close();
|
|
654
|
+
httpServer.close(() => process.exit(0));
|
|
655
|
+
// Force exit after 3 seconds if graceful shutdown hangs
|
|
656
|
+
setTimeout(() => process.exit(0), 3000);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// In auto/cdp mode, pre-connect to Figma
|
|
660
|
+
if (MODE !== 'plugin') {
|
|
661
|
+
getCdpClient().catch(err => {
|
|
662
|
+
console.log('[daemon] CDP not available, waiting for plugin connection...');
|
|
663
|
+
});
|
|
664
|
+
}
|