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/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
+ }