@muloka/figma-console-mcp 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +5 -4
  2. package/dist/apps/design-system-dashboard/mcp-app.html +521 -0
  3. package/dist/apps/token-browser/mcp-app.html +410 -0
  4. package/dist/cloudflare/apps/design-system-dashboard/scoring/accessibility.js +277 -0
  5. package/dist/cloudflare/apps/design-system-dashboard/scoring/component-metadata.js +357 -0
  6. package/dist/cloudflare/apps/design-system-dashboard/scoring/consistency.js +341 -0
  7. package/dist/cloudflare/apps/design-system-dashboard/scoring/coverage.js +230 -0
  8. package/dist/cloudflare/apps/design-system-dashboard/scoring/engine.js +92 -0
  9. package/dist/cloudflare/apps/design-system-dashboard/scoring/naming-semantics.js +308 -0
  10. package/dist/cloudflare/apps/design-system-dashboard/scoring/token-architecture.js +349 -0
  11. package/dist/cloudflare/apps/design-system-dashboard/scoring/types.js +40 -0
  12. package/dist/cloudflare/apps/design-system-dashboard/server.js +159 -0
  13. package/dist/cloudflare/apps/token-browser/server.js +136 -0
  14. package/dist/cloudflare/browser-manager.js +157 -0
  15. package/dist/cloudflare/core/accessibility-tools.js +361 -0
  16. package/dist/cloudflare/core/annotation-tools.js +230 -0
  17. package/dist/cloudflare/core/cloud-websocket-connector.js +419 -0
  18. package/dist/cloudflare/core/cloud-websocket-relay.js +198 -0
  19. package/dist/cloudflare/core/comment-tools.js +292 -0
  20. package/dist/cloudflare/core/config.js +153 -0
  21. package/dist/cloudflare/core/console-monitor.js +427 -0
  22. package/dist/cloudflare/core/deep-component-tools.js +128 -0
  23. package/dist/cloudflare/core/design-code-tools.js +2793 -0
  24. package/dist/cloudflare/core/design-system-manifest.js +265 -0
  25. package/dist/cloudflare/core/design-system-tools.js +882 -0
  26. package/dist/cloudflare/core/diagnose-tool.js +100 -0
  27. package/dist/cloudflare/core/diff/changelog-formatter.js +275 -0
  28. package/dist/cloudflare/core/diff/diff-engine.js +334 -0
  29. package/dist/cloudflare/core/diff/property-compare.js +36 -0
  30. package/dist/cloudflare/core/diff/version-cache.js +74 -0
  31. package/dist/cloudflare/core/enrichment/enrichment-service.js +374 -0
  32. package/dist/cloudflare/core/enrichment/index.js +7 -0
  33. package/dist/cloudflare/core/enrichment/relationship-mapper.js +351 -0
  34. package/dist/cloudflare/core/enrichment/style-resolver.js +346 -0
  35. package/dist/cloudflare/core/figjam-tools.js +547 -0
  36. package/dist/cloudflare/core/figma-api.js +509 -0
  37. package/dist/cloudflare/core/figma-connector.js +7 -0
  38. package/dist/cloudflare/core/figma-reconstruction-spec.js +402 -0
  39. package/dist/cloudflare/core/figma-style-extractor.js +311 -0
  40. package/dist/cloudflare/core/figma-tools.js +3353 -0
  41. package/dist/cloudflare/core/identity.js +96 -0
  42. package/dist/cloudflare/core/library-tools.js +580 -0
  43. package/dist/cloudflare/core/logger.js +53 -0
  44. package/dist/cloudflare/core/port-discovery.js +935 -0
  45. package/dist/cloudflare/core/resolve-package-root.js +11 -0
  46. package/dist/cloudflare/core/slides-tools.js +714 -0
  47. package/dist/cloudflare/core/slot-tools.js +334 -0
  48. package/dist/cloudflare/core/snippet-injector.js +96 -0
  49. package/dist/cloudflare/core/tokens/alias-resolver.js +205 -0
  50. package/dist/cloudflare/core/tokens/config.js +294 -0
  51. package/dist/cloudflare/core/tokens/dialect.js +232 -0
  52. package/dist/cloudflare/core/tokens/figma-converter.js +323 -0
  53. package/dist/cloudflare/core/tokens/formatters/css-vars.js +338 -0
  54. package/dist/cloudflare/core/tokens/formatters/dtcg.js +376 -0
  55. package/dist/cloudflare/core/tokens/formatters/index.js +45 -0
  56. package/dist/cloudflare/core/tokens/formatters/json.js +205 -0
  57. package/dist/cloudflare/core/tokens/formatters/less.js +4 -0
  58. package/dist/cloudflare/core/tokens/formatters/scss.js +258 -0
  59. package/dist/cloudflare/core/tokens/formatters/stubs.js +13 -0
  60. package/dist/cloudflare/core/tokens/formatters/style-dictionary-v3.js +213 -0
  61. package/dist/cloudflare/core/tokens/formatters/tailwind-v3.js +237 -0
  62. package/dist/cloudflare/core/tokens/formatters/tailwind-v4.js +335 -0
  63. package/dist/cloudflare/core/tokens/formatters/tokens-studio.js +256 -0
  64. package/dist/cloudflare/core/tokens/formatters/ts-module.js +198 -0
  65. package/dist/cloudflare/core/tokens/index.js +16 -0
  66. package/dist/cloudflare/core/tokens/parsers/css-vars.js +4 -0
  67. package/dist/cloudflare/core/tokens/parsers/dtcg.js +280 -0
  68. package/dist/cloudflare/core/tokens/parsers/index.js +138 -0
  69. package/dist/cloudflare/core/tokens/parsers/json.js +7 -0
  70. package/dist/cloudflare/core/tokens/parsers/scss.js +4 -0
  71. package/dist/cloudflare/core/tokens/parsers/stubs.js +20 -0
  72. package/dist/cloudflare/core/tokens/parsers/style-dictionary-v3.js +4 -0
  73. package/dist/cloudflare/core/tokens/parsers/tailwind-v3.js +4 -0
  74. package/dist/cloudflare/core/tokens/parsers/tailwind-v4.js +4 -0
  75. package/dist/cloudflare/core/tokens/parsers/tokens-studio.js +4 -0
  76. package/dist/cloudflare/core/tokens/schemas.js +152 -0
  77. package/dist/cloudflare/core/tokens/transforms/color.js +12 -0
  78. package/dist/cloudflare/core/tokens/transforms/index.js +29 -0
  79. package/dist/cloudflare/core/tokens/transforms/size.js +7 -0
  80. package/dist/cloudflare/core/tokens/types.js +18 -0
  81. package/dist/cloudflare/core/tokens-tools.js +1788 -0
  82. package/dist/cloudflare/core/types/design-code.js +4 -0
  83. package/dist/cloudflare/core/types/enriched.js +5 -0
  84. package/dist/cloudflare/core/types/index.js +4 -0
  85. package/dist/cloudflare/core/variable-resolver.js +85 -0
  86. package/dist/cloudflare/core/version-tools.js +1199 -0
  87. package/dist/cloudflare/core/websocket-connector.js +436 -0
  88. package/dist/cloudflare/core/websocket-server.js +1009 -0
  89. package/dist/cloudflare/core/write-tools.js +2506 -0
  90. package/dist/cloudflare/index.js +3010 -0
  91. package/dist/core/accessibility-tools.d.ts.map +1 -1
  92. package/dist/core/accessibility-tools.js +7 -6
  93. package/dist/core/accessibility-tools.js.map +1 -1
  94. package/dist/core/http-request-guard.d.ts +20 -0
  95. package/dist/core/http-request-guard.d.ts.map +1 -0
  96. package/dist/core/http-request-guard.js +61 -0
  97. package/dist/core/http-request-guard.js.map +1 -0
  98. package/dist/core/tokens/schemas.d.ts +6 -6
  99. package/dist/local.d.ts.map +1 -1
  100. package/dist/local.js +4 -0
  101. package/dist/local.js.map +1 -1
  102. package/figma-desktop-bridge/ui.html +7 -0
  103. package/package.json +2 -2
@@ -0,0 +1,419 @@
1
+ /**
2
+ * Cloud WebSocket Connector
3
+ *
4
+ * Implements IFigmaConnector by routing commands through the PluginRelayDO
5
+ * Durable Object. Each method maps to a command sent via fetch() RPC to
6
+ * the relay, which forwards it to the Figma Desktop Bridge plugin over
7
+ * WebSocket.
8
+ *
9
+ * Structurally mirrors WebSocketConnector — same methods, different transport.
10
+ */
11
+ export class CloudWebSocketConnector {
12
+ constructor(relayStub) {
13
+ this.relayStub = relayStub;
14
+ }
15
+ async initialize() {
16
+ const res = await this.relayStub.fetch('https://relay/relay/status');
17
+ const status = await res.json();
18
+ if (!status.connected) {
19
+ throw new Error('No plugin connected to cloud relay. User must pair the Desktop Bridge plugin first (use figma_pair_plugin tool).');
20
+ }
21
+ }
22
+ getTransportType() {
23
+ return 'websocket';
24
+ }
25
+ // ============================================================================
26
+ // Core execution
27
+ // ============================================================================
28
+ async executeInPluginContext(code) {
29
+ return this.sendCommand('EXECUTE_CODE', { code, timeout: 5000 }, 7000);
30
+ }
31
+ async getVariablesFromPluginUI(fileKey) {
32
+ return this.sendCommand('GET_VARIABLES_DATA', {}, 10000);
33
+ }
34
+ async getVariables(fileKey) {
35
+ // IMPORTANT: bare try/catch with top-level `return`, NO inner IIFE.
36
+ // See issue #68 + the matching note in websocket-connector.ts. The plugin
37
+ // (code.js) wraps every EXECUTE_CODE payload in its own async IIFE; nesting
38
+ // another swallows the inner return and silently drops the variables.
39
+ const code = `
40
+ try {
41
+ if (typeof figma === 'undefined') {
42
+ throw new Error('Figma API not available in this context');
43
+ }
44
+ const variables = await figma.variables.getLocalVariablesAsync();
45
+ const collections = await figma.variables.getLocalVariableCollectionsAsync();
46
+ return {
47
+ success: true,
48
+ timestamp: Date.now(),
49
+ fileMetadata: { fileName: figma.root.name, fileKey: figma.fileKey || null },
50
+ variables: variables.map(function(v) { return { id: v.id, name: v.name, key: v.key, resolvedType: v.resolvedType, valuesByMode: v.valuesByMode, variableCollectionId: v.variableCollectionId, scopes: v.scopes, description: v.description, hiddenFromPublishing: v.hiddenFromPublishing }; }),
51
+ variableCollections: collections.map(function(c) { return { id: c.id, name: c.name, key: c.key, modes: c.modes, defaultModeId: c.defaultModeId, variableIds: c.variableIds }; })
52
+ };
53
+ } catch (error) {
54
+ return { success: false, error: error.message };
55
+ }
56
+ `;
57
+ return this.sendCommand('EXECUTE_CODE', { code, timeout: 30000 }, 32000);
58
+ }
59
+ async executeCodeViaUI(code, timeoutMs = 5000) {
60
+ return this.sendCommand('EXECUTE_CODE', { code, timeout: timeoutMs }, timeoutMs + 2000);
61
+ }
62
+ // ============================================================================
63
+ // Variable operations
64
+ // ============================================================================
65
+ async updateVariable(variableId, modeId, value) {
66
+ return this.sendCommand('UPDATE_VARIABLE', { variableId, modeId, value });
67
+ }
68
+ async createVariable(name, collectionId, resolvedType, options) {
69
+ const params = { name, collectionId, resolvedType };
70
+ if (options) {
71
+ if (options.valuesByMode)
72
+ params.valuesByMode = options.valuesByMode;
73
+ if (options.description)
74
+ params.description = options.description;
75
+ if (options.scopes)
76
+ params.scopes = options.scopes;
77
+ }
78
+ return this.sendCommand('CREATE_VARIABLE', params);
79
+ }
80
+ async deleteVariable(variableId) {
81
+ return this.sendCommand('DELETE_VARIABLE', { variableId });
82
+ }
83
+ async refreshVariables() {
84
+ return this.sendCommand('REFRESH_VARIABLES', {}, 300000);
85
+ }
86
+ async renameVariable(variableId, newName) {
87
+ const result = await this.sendCommand('RENAME_VARIABLE', { variableId, newName });
88
+ if (!result.oldName && result.variable?.oldName)
89
+ result.oldName = result.variable.oldName;
90
+ return result;
91
+ }
92
+ async setVariableDescription(variableId, description) {
93
+ return this.sendCommand('SET_VARIABLE_DESCRIPTION', { variableId, description });
94
+ }
95
+ // ============================================================================
96
+ // Mode operations
97
+ // ============================================================================
98
+ async addMode(collectionId, modeName) {
99
+ return this.sendCommand('ADD_MODE', { collectionId, modeName });
100
+ }
101
+ async renameMode(collectionId, modeId, newName) {
102
+ const result = await this.sendCommand('RENAME_MODE', { collectionId, modeId, newName });
103
+ if (!result.oldName && result.collection?.oldName)
104
+ result.oldName = result.collection.oldName;
105
+ return result;
106
+ }
107
+ // ============================================================================
108
+ // Collection operations
109
+ // ============================================================================
110
+ async createVariableCollection(name, options) {
111
+ const params = { name };
112
+ if (options) {
113
+ if (options.initialModeName)
114
+ params.initialModeName = options.initialModeName;
115
+ if (options.additionalModes)
116
+ params.additionalModes = options.additionalModes;
117
+ }
118
+ return this.sendCommand('CREATE_VARIABLE_COLLECTION', params);
119
+ }
120
+ async deleteVariableCollection(collectionId) {
121
+ return this.sendCommand('DELETE_VARIABLE_COLLECTION', { collectionId });
122
+ }
123
+ // ============================================================================
124
+ // Component operations
125
+ // ============================================================================
126
+ async getComponentFromPluginUI(nodeId) {
127
+ return this.sendCommand('GET_COMPONENT', { nodeId }, 10000);
128
+ }
129
+ async getLocalComponents() {
130
+ return this.sendCommand('GET_LOCAL_COMPONENTS', {}, 300000);
131
+ }
132
+ async setNodeDescription(nodeId, description, descriptionMarkdown) {
133
+ return this.sendCommand('SET_NODE_DESCRIPTION', { nodeId, description, descriptionMarkdown });
134
+ }
135
+ // ============================================================================
136
+ // Annotation operations
137
+ // ============================================================================
138
+ async getAnnotations(nodeId, includeChildren, depth) {
139
+ return this.sendCommand('GET_ANNOTATIONS', { nodeId, includeChildren, depth }, 10000);
140
+ }
141
+ async setAnnotations(nodeId, annotations, mode) {
142
+ return this.sendCommand('SET_ANNOTATIONS', { nodeId, annotations, mode: mode || 'replace' });
143
+ }
144
+ async getAnnotationCategories() {
145
+ return this.sendCommand('GET_ANNOTATION_CATEGORIES', {}, 5000);
146
+ }
147
+ async deepGetComponent(nodeId, depth) {
148
+ return this.sendCommand('DEEP_GET_COMPONENT', { nodeId, depth: depth || 10 }, 30000);
149
+ }
150
+ async analyzeComponentSet(nodeId) {
151
+ return this.sendCommand('ANALYZE_COMPONENT_SET', { nodeId }, 30000);
152
+ }
153
+ async addComponentProperty(nodeId, propertyName, type, defaultValue, options) {
154
+ const params = { nodeId, propertyName, propertyType: type, defaultValue };
155
+ if (options?.preferredValues)
156
+ params.preferredValues = options.preferredValues;
157
+ if (options?.description)
158
+ params.description = options.description;
159
+ return this.sendCommand('ADD_COMPONENT_PROPERTY', params);
160
+ }
161
+ async editComponentProperty(nodeId, propertyName, newValue) {
162
+ return this.sendCommand('EDIT_COMPONENT_PROPERTY', { nodeId, propertyName, newValue });
163
+ }
164
+ async deleteComponentProperty(nodeId, propertyName) {
165
+ return this.sendCommand('DELETE_COMPONENT_PROPERTY', { nodeId, propertyName });
166
+ }
167
+ async instantiateComponent(componentKey, options) {
168
+ const params = { componentKey };
169
+ if (options) {
170
+ if (options.nodeId)
171
+ params.nodeId = options.nodeId;
172
+ if (options.position)
173
+ params.position = options.position;
174
+ if (options.size)
175
+ params.size = options.size;
176
+ if (options.overrides)
177
+ params.overrides = options.overrides;
178
+ if (options.variant)
179
+ params.variant = options.variant;
180
+ if (options.parentId)
181
+ params.parentId = options.parentId;
182
+ }
183
+ return this.sendCommand('INSTANTIATE_COMPONENT', params);
184
+ }
185
+ async createComponentSet(params) {
186
+ // Timeout scales with variant count — the plugin builds all variants in
187
+ // one uncancellable pass, so a fixed 30s ceiling on big matrices reports
188
+ // failure while the set still gets created (retry → duplicates).
189
+ // Mirrors componentSetTimeoutMs in websocket-connector.ts (not imported:
190
+ // that module pulls the Node 'ws' stack into the Workers bundle).
191
+ let variantCount = 1;
192
+ if (params.componentIds?.length) {
193
+ variantCount = params.componentIds.length;
194
+ }
195
+ else if (params.properties) {
196
+ for (const values of Object.values(params.properties)) {
197
+ variantCount *= Math.max(1, values?.length ?? 1);
198
+ }
199
+ }
200
+ const timeout = Math.min(120000, Math.max(30000, variantCount * 1200)) + 5000;
201
+ return this.sendCommand('CREATE_COMPONENT_SET', params, timeout);
202
+ }
203
+ // ============================================================================
204
+ // Slot operations
205
+ // ============================================================================
206
+ async createSlot(nodeId, options) {
207
+ return this.sendCommand('CREATE_SLOT', { nodeId, ...options });
208
+ }
209
+ async getSlots(nodeId) {
210
+ return this.sendCommand('GET_SLOTS', { nodeId });
211
+ }
212
+ async appendToSlot(params) {
213
+ return this.sendCommand('APPEND_TO_SLOT', params);
214
+ }
215
+ async resetSlot(params) {
216
+ return this.sendCommand('RESET_SLOT', params);
217
+ }
218
+ // ============================================================================
219
+ // Node manipulation
220
+ // ============================================================================
221
+ async resizeNode(nodeId, width, height, withConstraints = true) {
222
+ return this.sendCommand('RESIZE_NODE', { nodeId, width, height, withConstraints });
223
+ }
224
+ async moveNode(nodeId, x, y) {
225
+ return this.sendCommand('MOVE_NODE', { nodeId, x, y });
226
+ }
227
+ async setNodeFills(nodeId, fills) {
228
+ return this.sendCommand('SET_NODE_FILLS', { nodeId, fills });
229
+ }
230
+ async setNodeStrokes(nodeId, strokes, strokeWeight) {
231
+ const params = { nodeId, strokes };
232
+ if (strokeWeight !== undefined)
233
+ params.strokeWeight = strokeWeight;
234
+ return this.sendCommand('SET_NODE_STROKES', params);
235
+ }
236
+ async setNodeOpacity(nodeId, opacity) {
237
+ return this.sendCommand('SET_NODE_OPACITY', { nodeId, opacity });
238
+ }
239
+ async setNodeCornerRadius(nodeId, radius) {
240
+ return this.sendCommand('SET_NODE_CORNER_RADIUS', { nodeId, radius });
241
+ }
242
+ async cloneNode(nodeId) {
243
+ return this.sendCommand('CLONE_NODE', { nodeId });
244
+ }
245
+ async deleteNode(nodeId) {
246
+ return this.sendCommand('DELETE_NODE', { nodeId });
247
+ }
248
+ async renameNode(nodeId, newName) {
249
+ return this.sendCommand('RENAME_NODE', { nodeId, newName });
250
+ }
251
+ async setTextContent(nodeId, characters, options) {
252
+ const params = { nodeId, text: characters };
253
+ if (options) {
254
+ if (options.fontSize)
255
+ params.fontSize = options.fontSize;
256
+ if (options.fontWeight)
257
+ params.fontWeight = options.fontWeight;
258
+ if (options.fontFamily)
259
+ params.fontFamily = options.fontFamily;
260
+ if (options.fontStyle)
261
+ params.fontStyle = options.fontStyle;
262
+ }
263
+ return this.sendCommand('SET_TEXT_CONTENT', params);
264
+ }
265
+ async createChildNode(parentId, nodeType, properties) {
266
+ return this.sendCommand('CREATE_CHILD_NODE', { parentId, nodeType, properties: properties || {} });
267
+ }
268
+ // ============================================================================
269
+ // Screenshot & instance properties
270
+ // ============================================================================
271
+ async captureScreenshot(nodeId, options) {
272
+ const params = { nodeId };
273
+ if (options?.format)
274
+ params.format = options.format;
275
+ if (options?.scale)
276
+ params.scale = options.scale;
277
+ return this.sendCommand('CAPTURE_SCREENSHOT', params, 30000);
278
+ }
279
+ async setInstanceProperties(nodeId, properties) {
280
+ return this.sendCommand('SET_INSTANCE_PROPERTIES', { nodeId, properties });
281
+ }
282
+ // ============================================================================
283
+ // Image fill
284
+ // ============================================================================
285
+ async setImageFill(nodeIds, imageData, scaleMode = 'FILL') {
286
+ return this.sendCommand('SET_IMAGE_FILL', { nodeIds, imageData, scaleMode }, 60000);
287
+ }
288
+ // ============================================================================
289
+ // Design lint
290
+ // ============================================================================
291
+ async lintDesign(nodeId, rules, maxDepth, maxFindings) {
292
+ const params = {};
293
+ if (nodeId)
294
+ params.nodeId = nodeId;
295
+ if (rules)
296
+ params.rules = rules;
297
+ if (maxDepth !== undefined)
298
+ params.maxDepth = maxDepth;
299
+ if (maxFindings !== undefined)
300
+ params.maxFindings = maxFindings;
301
+ return this.sendCommand('LINT_DESIGN', params, 120000);
302
+ }
303
+ // ============================================================================
304
+ // Component accessibility audit
305
+ // ============================================================================
306
+ async auditComponentAccessibility(nodeId, targetSize) {
307
+ const params = {};
308
+ if (nodeId)
309
+ params.nodeId = nodeId;
310
+ if (targetSize !== undefined)
311
+ params.targetSize = targetSize;
312
+ return this.sendCommand('AUDIT_COMPONENT_ACCESSIBILITY', params, 120000);
313
+ }
314
+ // ============================================================================
315
+ // FigJam operations
316
+ // ============================================================================
317
+ async createSticky(params) {
318
+ return this.sendCommand('CREATE_STICKY', params);
319
+ }
320
+ async createStickies(params) {
321
+ return this.sendCommand('CREATE_STICKIES', params, 30000);
322
+ }
323
+ async createConnector(params) {
324
+ return this.sendCommand('CREATE_CONNECTOR', params);
325
+ }
326
+ async createShapeWithText(params) {
327
+ return this.sendCommand('CREATE_SHAPE_WITH_TEXT', params);
328
+ }
329
+ async createSection(params) {
330
+ return this.sendCommand('CREATE_SECTION', params);
331
+ }
332
+ async createTable(params) {
333
+ return this.sendCommand('CREATE_TABLE', params, 30000);
334
+ }
335
+ async createCodeBlock(params) {
336
+ return this.sendCommand('CREATE_CODE_BLOCK', params);
337
+ }
338
+ async getBoardContents(params) {
339
+ return this.sendCommand('GET_BOARD_CONTENTS', params, 30000);
340
+ }
341
+ async getConnections() {
342
+ return this.sendCommand('GET_CONNECTIONS', {}, 15000);
343
+ }
344
+ // ============================================================================
345
+ // Slides operations
346
+ // ============================================================================
347
+ async listSlides() {
348
+ return this.sendCommand('LIST_SLIDES', {}, 10000);
349
+ }
350
+ async getSlideContent(params) {
351
+ return this.sendCommand('GET_SLIDE_CONTENT', params, 10000);
352
+ }
353
+ async createSlide(params) {
354
+ return this.sendCommand('CREATE_SLIDE', params, 10000);
355
+ }
356
+ async deleteSlide(params) {
357
+ return this.sendCommand('DELETE_SLIDE', params, 5000);
358
+ }
359
+ async duplicateSlide(params) {
360
+ return this.sendCommand('DUPLICATE_SLIDE', params, 5000);
361
+ }
362
+ async getSlideGrid() {
363
+ return this.sendCommand('GET_SLIDE_GRID', {}, 10000);
364
+ }
365
+ async reorderSlides(params) {
366
+ return this.sendCommand('REORDER_SLIDES', params, 15000);
367
+ }
368
+ async setSlideTransition(params) {
369
+ return this.sendCommand('SET_SLIDE_TRANSITION', params, 5000);
370
+ }
371
+ async getSlideTransition(params) {
372
+ return this.sendCommand('GET_SLIDE_TRANSITION', params, 5000);
373
+ }
374
+ async setSlidesViewMode(params) {
375
+ return this.sendCommand('SET_SLIDES_VIEW_MODE', params, 5000);
376
+ }
377
+ async getFocusedSlide() {
378
+ return this.sendCommand('GET_FOCUSED_SLIDE', {}, 5000);
379
+ }
380
+ async focusSlide(params) {
381
+ return this.sendCommand('FOCUS_SLIDE', params, 5000);
382
+ }
383
+ async skipSlide(params) {
384
+ return this.sendCommand('SKIP_SLIDE', params, 5000);
385
+ }
386
+ async addTextToSlide(params) {
387
+ return this.sendCommand('ADD_TEXT_TO_SLIDE', params, 10000);
388
+ }
389
+ async addShapeToSlide(params) {
390
+ return this.sendCommand('ADD_SHAPE_TO_SLIDE', params, 5000);
391
+ }
392
+ async setSlideBackground(params) {
393
+ return this.sendCommand('SET_SLIDE_BACKGROUND', params, 5000);
394
+ }
395
+ async getTextStyles() {
396
+ return this.sendCommand('GET_TEXT_STYLES', {}, 5000);
397
+ }
398
+ // ============================================================================
399
+ // Cache management (no-op for cloud relay)
400
+ // ============================================================================
401
+ clearFrameCache() {
402
+ // No frame cache in cloud relay mode
403
+ }
404
+ // ============================================================================
405
+ // Transport — fetch-based RPC to the relay DO
406
+ // ============================================================================
407
+ async sendCommand(method, params = {}, timeoutMs = 15000) {
408
+ const res = await this.relayStub.fetch('https://relay/relay/command', {
409
+ method: 'POST',
410
+ headers: { 'Content-Type': 'application/json' },
411
+ body: JSON.stringify({ method, params, timeoutMs }),
412
+ });
413
+ const data = await res.json();
414
+ if (data.error) {
415
+ throw new Error(data.error);
416
+ }
417
+ return data.result;
418
+ }
419
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Cloud WebSocket Relay — Durable Object
3
+ *
4
+ * Bridges the Figma Desktop Bridge plugin to the cloud MCP server.
5
+ * The plugin connects via WebSocket (hibernation-aware); the MCP DO
6
+ * sends commands via fetch() RPC and receives responses.
7
+ *
8
+ * IMPORTANT: Uses hibernation-safe patterns throughout:
9
+ * - WebSocket retrieved via this.ctx.getWebSockets() (not class property)
10
+ * - File info persisted in DO storage (not in-memory)
11
+ * - Pending requests kept in-memory (safe: fetch keeps DO alive)
12
+ *
13
+ * Routes:
14
+ * /ws/connect — WebSocket upgrade from plugin (paired via code)
15
+ * /relay/command — RPC from MCP DO → plugin (holds response open)
16
+ * /relay/status — Connection & file info query
17
+ */
18
+ import { DurableObject } from 'cloudflare:workers';
19
+ /**
20
+ * Generate a 6-character alphanumeric pairing code (uppercase).
21
+ */
22
+ export function generatePairingCode() {
23
+ const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // No 0/O/1/I confusion
24
+ let code = '';
25
+ const arr = new Uint8Array(6);
26
+ crypto.getRandomValues(arr);
27
+ for (let i = 0; i < 6; i++) {
28
+ code += chars[arr[i] % chars.length];
29
+ }
30
+ return code;
31
+ }
32
+ export class PluginRelayDO extends DurableObject {
33
+ constructor() {
34
+ super(...arguments);
35
+ this.pendingRequests = new Map();
36
+ this.requestIdCounter = 0;
37
+ }
38
+ // ======================================================================
39
+ // Hibernation-safe WebSocket retrieval
40
+ // ======================================================================
41
+ /**
42
+ * Get the active plugin WebSocket. Uses ctx.getWebSockets() which
43
+ * survives DO hibernation (unlike a class property reference).
44
+ */
45
+ getPluginWs() {
46
+ const sockets = this.ctx.getWebSockets('plugin');
47
+ return sockets.length > 0 ? sockets[0] : null;
48
+ }
49
+ /**
50
+ * Incoming fetch handler — dispatches to routes.
51
+ */
52
+ async fetch(request) {
53
+ const url = new URL(request.url);
54
+ if (url.pathname === '/ws/connect') {
55
+ return this.handlePluginConnect(request);
56
+ }
57
+ if (url.pathname === '/relay/command') {
58
+ return this.handleRelayCommand(request);
59
+ }
60
+ if (url.pathname === '/relay/status') {
61
+ return this.handleRelayStatus();
62
+ }
63
+ return new Response('Not found', { status: 404 });
64
+ }
65
+ // ==========================================================================
66
+ // WebSocket — plugin connects here
67
+ // ==========================================================================
68
+ handlePluginConnect(request) {
69
+ const upgradeHeader = request.headers.get('Upgrade');
70
+ if (!upgradeHeader || upgradeHeader.toLowerCase() !== 'websocket') {
71
+ return new Response('Expected WebSocket upgrade', { status: 426 });
72
+ }
73
+ // Close any existing plugin connection (e.g., re-pairing)
74
+ const existing = this.getPluginWs();
75
+ if (existing) {
76
+ try {
77
+ existing.close(1000, 'Replaced by new connection');
78
+ }
79
+ catch { /* ignore */ }
80
+ }
81
+ const pair = new WebSocketPair();
82
+ const [client, server] = Object.values(pair);
83
+ // Accept with 'plugin' tag — this.ctx.getWebSockets('plugin') retrieves
84
+ // the socket even after the DO wakes from hibernation.
85
+ this.ctx.acceptWebSocket(server, ['plugin']);
86
+ return new Response(null, { status: 101, webSocket: client });
87
+ }
88
+ /**
89
+ * Hibernation callback — incoming message from plugin WebSocket.
90
+ */
91
+ async webSocketMessage(ws, message) {
92
+ if (typeof message !== 'string')
93
+ return;
94
+ try {
95
+ const data = JSON.parse(message);
96
+ // FILE_INFO identification from plugin — persist to DO storage
97
+ if (data.type === 'FILE_INFO' && data.data) {
98
+ const fileInfo = {
99
+ fileName: data.data.fileName,
100
+ fileKey: data.data.fileKey || null,
101
+ currentPage: data.data.currentPage,
102
+ currentPageId: data.data.currentPageId || null,
103
+ connectedAt: Date.now(),
104
+ };
105
+ await this.ctx.storage.put('fileInfo', fileInfo);
106
+ return;
107
+ }
108
+ // Event broadcasts from plugin (PAGE_CHANGE, etc.)
109
+ if (data.type === 'PAGE_CHANGE' && data.data) {
110
+ const fileInfo = await this.ctx.storage.get('fileInfo');
111
+ if (fileInfo) {
112
+ fileInfo.currentPage = data.data.pageName;
113
+ fileInfo.currentPageId = data.data.pageId || null;
114
+ await this.ctx.storage.put('fileInfo', fileInfo);
115
+ }
116
+ return;
117
+ }
118
+ // Response to a relayed command
119
+ if (data.id && this.pendingRequests.has(data.id)) {
120
+ const pending = this.pendingRequests.get(data.id);
121
+ clearTimeout(pending.timeoutId);
122
+ this.pendingRequests.delete(data.id);
123
+ const body = JSON.stringify(data.error
124
+ ? { error: data.error }
125
+ : { result: data.result });
126
+ pending.resolve(new Response(body, {
127
+ headers: { 'Content-Type': 'application/json' },
128
+ }));
129
+ }
130
+ }
131
+ catch {
132
+ // Malformed message — ignore
133
+ }
134
+ }
135
+ /**
136
+ * Hibernation callback — WebSocket closed.
137
+ */
138
+ webSocketClose(ws, code, reason, wasClean) {
139
+ this.handleDisconnect();
140
+ }
141
+ /**
142
+ * Hibernation callback — WebSocket error.
143
+ */
144
+ webSocketError(ws, error) {
145
+ this.handleDisconnect();
146
+ }
147
+ handleDisconnect() {
148
+ // Clear persisted file info
149
+ this.ctx.storage.delete('fileInfo');
150
+ // Reject all in-flight commands
151
+ for (const [id, pending] of this.pendingRequests) {
152
+ clearTimeout(pending.timeoutId);
153
+ pending.resolve(new Response(JSON.stringify({ error: 'Plugin disconnected' }), { status: 502, headers: { 'Content-Type': 'application/json' } }));
154
+ }
155
+ this.pendingRequests.clear();
156
+ }
157
+ // ==========================================================================
158
+ // Relay — MCP DO sends commands here
159
+ // ==========================================================================
160
+ async handleRelayCommand(request) {
161
+ const pluginWs = this.getPluginWs();
162
+ if (!pluginWs) {
163
+ return new Response(JSON.stringify({ error: 'No plugin connected. User must pair the Desktop Bridge plugin first.' }), { status: 502, headers: { 'Content-Type': 'application/json' } });
164
+ }
165
+ const body = await request.json();
166
+ const { method, params = {}, timeoutMs = 15000 } = body;
167
+ const id = `relay_${++this.requestIdCounter}_${Date.now()}`;
168
+ // Send command to plugin
169
+ try {
170
+ pluginWs.send(JSON.stringify({ id, method, params }));
171
+ }
172
+ catch {
173
+ return new Response(JSON.stringify({ error: 'Failed to send command to plugin — connection may be stale' }), { status: 502, headers: { 'Content-Type': 'application/json' } });
174
+ }
175
+ // Wait for plugin response (the DO stays alive because fetch is active)
176
+ return new Promise((resolve) => {
177
+ const timeoutId = setTimeout(() => {
178
+ if (this.pendingRequests.has(id)) {
179
+ this.pendingRequests.delete(id);
180
+ resolve(new Response(JSON.stringify({ error: `Command ${method} timed out after ${timeoutMs}ms` }), { status: 504, headers: { 'Content-Type': 'application/json' } }));
181
+ }
182
+ }, timeoutMs);
183
+ this.pendingRequests.set(id, { resolve, reject: () => { }, timeoutId });
184
+ });
185
+ }
186
+ // ==========================================================================
187
+ // Status
188
+ // ==========================================================================
189
+ async handleRelayStatus() {
190
+ const pluginWs = this.getPluginWs();
191
+ const fileInfo = await this.ctx.storage.get('fileInfo');
192
+ return new Response(JSON.stringify({
193
+ connected: pluginWs !== null,
194
+ fileInfo: fileInfo || null,
195
+ pendingCommands: this.pendingRequests.size,
196
+ }), { headers: { 'Content-Type': 'application/json' } });
197
+ }
198
+ }