@seed-design/mcp 1.2.1 → 1.3.1
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 +1 -28
- package/bin/index.mjs +701 -363
- package/dist/index.d.ts +46 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +537 -0
- package/package.json +13 -5
- package/src/bin/index.ts +147 -207
- package/src/bin/websocket-server.ts +196 -0
- package/src/figma-rest-client.ts +55 -0
- package/src/index.ts +4 -0
- package/src/tools-helpers.ts +113 -0
- package/src/tools.ts +455 -266
package/src/bin/index.ts
CHANGED
|
@@ -3,244 +3,186 @@
|
|
|
3
3
|
import { cac } from "cac";
|
|
4
4
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { version } from "../../package.json" with { type: "json" };
|
|
6
7
|
import { logger } from "../logger";
|
|
7
|
-
import {
|
|
8
|
+
import { loadConfig, type McpConfig } from "../config";
|
|
9
|
+
import { createFigmaRestClient, type FigmaRestClient } from "../figma-rest-client";
|
|
10
|
+
import { createFigmaWebSocketClient, type FigmaWebSocketClient } from "../websocket";
|
|
8
11
|
import { registerEditingTools, registerTools } from "../tools";
|
|
12
|
+
import type { ToolMode } from "../tools-helpers";
|
|
9
13
|
import { registerPrompts } from "../prompts";
|
|
10
|
-
import {
|
|
11
|
-
import type { ServerWebSocket } from "bun";
|
|
12
|
-
import { loadConfig, type McpConfig } from "../config";
|
|
14
|
+
import { startWebSocketServer } from "./websocket-server";
|
|
13
15
|
|
|
14
|
-
//
|
|
15
|
-
const cli = cac("@seed-design/mcp");
|
|
16
|
+
// Helper Functions
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
function handleWebSocketConnection(ws: ServerWebSocket<any>) {
|
|
21
|
-
console.log("New client connected");
|
|
22
|
-
|
|
23
|
-
ws.send(
|
|
24
|
-
JSON.stringify({
|
|
25
|
-
type: "system",
|
|
26
|
-
message: "Please join a channel to start chatting",
|
|
27
|
-
}),
|
|
28
|
-
);
|
|
29
|
-
|
|
30
|
-
ws.close = () => {
|
|
31
|
-
console.log("Client disconnected");
|
|
32
|
-
channels.forEach((clients, channelName) => {
|
|
33
|
-
if (clients.has(ws)) {
|
|
34
|
-
clients.delete(ws);
|
|
35
|
-
clients.forEach((client) => {
|
|
36
|
-
if (client.readyState === WebSocket.OPEN) {
|
|
37
|
-
client.send(
|
|
38
|
-
JSON.stringify({
|
|
39
|
-
type: "system",
|
|
40
|
-
message: "A user has left the channel",
|
|
41
|
-
channel: channelName,
|
|
42
|
-
}),
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
});
|
|
48
|
-
};
|
|
18
|
+
function getFigmaAccessToken(): string | undefined {
|
|
19
|
+
return process.env["FIGMA_PERSONAL_ACCESS_TOKEN"]?.trim();
|
|
49
20
|
}
|
|
50
21
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
22
|
+
function createFigmaClient(
|
|
23
|
+
serverUrl: string | undefined,
|
|
24
|
+
mode: ToolMode,
|
|
25
|
+
): FigmaWebSocketClient | null {
|
|
26
|
+
const pat = getFigmaAccessToken();
|
|
27
|
+
const resolvedUrl = serverUrl ?? "localhost";
|
|
28
|
+
|
|
29
|
+
switch (mode) {
|
|
30
|
+
case "rest": {
|
|
31
|
+
if (!pat) {
|
|
32
|
+
logger.warn(
|
|
33
|
+
"REST mode requires FIGMA_PERSONAL_ACCESS_TOKEN. Running without Figma client.",
|
|
34
|
+
);
|
|
35
|
+
} else {
|
|
36
|
+
logger.info("REST mode enabled. Using REST API only.");
|
|
66
37
|
}
|
|
67
38
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
headers: {
|
|
71
|
-
"Access-Control-Allow-Origin": "*",
|
|
72
|
-
},
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
if (success) return;
|
|
76
|
-
|
|
77
|
-
// Return response for non-WebSocket requests
|
|
78
|
-
return new Response("WebSocket server running", {
|
|
79
|
-
headers: {
|
|
80
|
-
"Access-Control-Allow-Origin": "*",
|
|
81
|
-
},
|
|
82
|
-
});
|
|
83
|
-
},
|
|
84
|
-
websocket: {
|
|
85
|
-
open: handleWebSocketConnection,
|
|
86
|
-
message(ws, message) {
|
|
87
|
-
try {
|
|
88
|
-
console.log("Received message from client:", message);
|
|
89
|
-
const data = JSON.parse(message as string);
|
|
90
|
-
|
|
91
|
-
if (data.type === "join") {
|
|
92
|
-
const channelName = data.channel;
|
|
93
|
-
if (!channelName || typeof channelName !== "string") {
|
|
94
|
-
ws.send(JSON.stringify({ type: "error", message: "Channel name is required" }));
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Create channel if it doesn't exist
|
|
99
|
-
if (!channels.has(channelName)) {
|
|
100
|
-
channels.set(channelName, new Set());
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Add client to channel
|
|
104
|
-
const channelClients = channels.get(channelName)!;
|
|
105
|
-
channelClients.add(ws);
|
|
106
|
-
|
|
107
|
-
// Notify client they joined successfully
|
|
108
|
-
ws.send(
|
|
109
|
-
JSON.stringify({
|
|
110
|
-
type: "system",
|
|
111
|
-
message: `Joined channel: ${channelName}`,
|
|
112
|
-
channel: channelName,
|
|
113
|
-
}),
|
|
114
|
-
);
|
|
115
|
-
|
|
116
|
-
console.log("Sending message to client:", data.id);
|
|
117
|
-
|
|
118
|
-
ws.send(
|
|
119
|
-
JSON.stringify({
|
|
120
|
-
type: "system",
|
|
121
|
-
message: {
|
|
122
|
-
id: data.id,
|
|
123
|
-
result: "Connected to channel: " + channelName,
|
|
124
|
-
},
|
|
125
|
-
channel: channelName,
|
|
126
|
-
}),
|
|
127
|
-
);
|
|
128
|
-
|
|
129
|
-
// Notify other clients in channel
|
|
130
|
-
channelClients.forEach((client) => {
|
|
131
|
-
if (client !== ws && client.readyState === WebSocket.OPEN) {
|
|
132
|
-
client.send(
|
|
133
|
-
JSON.stringify({
|
|
134
|
-
type: "system",
|
|
135
|
-
message: "A new user has joined the channel",
|
|
136
|
-
channel: channelName,
|
|
137
|
-
}),
|
|
138
|
-
);
|
|
139
|
-
}
|
|
140
|
-
});
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// Handle regular messages
|
|
145
|
-
if (data.type === "message") {
|
|
146
|
-
const channelName = data.channel;
|
|
147
|
-
if (!channelName || typeof channelName !== "string") {
|
|
148
|
-
ws.send(JSON.stringify({ type: "error", message: "Channel name is required" }));
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const channelClients = channels.get(channelName);
|
|
153
|
-
if (!channelClients || !channelClients.has(ws)) {
|
|
154
|
-
ws.send(
|
|
155
|
-
JSON.stringify({ type: "error", message: "You must join the channel first" }),
|
|
156
|
-
);
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Broadcast to all clients in the channel
|
|
161
|
-
channelClients.forEach((client) => {
|
|
162
|
-
if (client.readyState === WebSocket.OPEN) {
|
|
163
|
-
console.log("Broadcasting message to client:", data.message);
|
|
164
|
-
client.send(
|
|
165
|
-
JSON.stringify({
|
|
166
|
-
type: "broadcast",
|
|
167
|
-
message: data.message,
|
|
168
|
-
sender: client === ws ? "You" : "User",
|
|
169
|
-
channel: channelName,
|
|
170
|
-
}),
|
|
171
|
-
);
|
|
172
|
-
}
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
|
-
} catch (err) {
|
|
176
|
-
console.error("Error handling message:", err);
|
|
177
|
-
}
|
|
178
|
-
},
|
|
179
|
-
close(ws) {
|
|
180
|
-
// Remove client from their channel
|
|
181
|
-
channels.forEach((clients) => {
|
|
182
|
-
clients.delete(ws);
|
|
183
|
-
});
|
|
184
|
-
},
|
|
185
|
-
},
|
|
186
|
-
});
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
187
41
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
42
|
+
case "websocket": {
|
|
43
|
+
logger.info(`WebSocket mode enabled. Client connecting to: ${resolvedUrl}`);
|
|
44
|
+
|
|
45
|
+
return createFigmaWebSocketClient(resolvedUrl);
|
|
46
|
+
}
|
|
191
47
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
if (configData) {
|
|
198
|
-
logger.info(`Loaded configuration from: ${configPath}`);
|
|
199
|
-
|
|
200
|
-
// Log component transformers if present
|
|
201
|
-
if (configData.extend?.componentHandlers) {
|
|
202
|
-
const handlers = configData.extend.componentHandlers;
|
|
203
|
-
if (handlers.length > 0) {
|
|
204
|
-
logger.info(`Found ${handlers.length} custom component handlers`);
|
|
205
|
-
}
|
|
48
|
+
case "all": {
|
|
49
|
+
if (pat) {
|
|
50
|
+
logger.info(
|
|
51
|
+
"FIGMA_PERSONAL_ACCESS_TOKEN found. REST API enabled for figmaUrl/fileKey requests.",
|
|
52
|
+
);
|
|
206
53
|
}
|
|
54
|
+
|
|
55
|
+
logger.info(`WebSocket client connecting to: ${resolvedUrl}`);
|
|
56
|
+
|
|
57
|
+
return createFigmaWebSocketClient(resolvedUrl);
|
|
207
58
|
}
|
|
208
59
|
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function createRestClient(mode: ToolMode): FigmaRestClient | null {
|
|
63
|
+
if (mode === "websocket") {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const pat = getFigmaAccessToken();
|
|
68
|
+
if (!pat) {
|
|
69
|
+
if (mode === "rest") {
|
|
70
|
+
logger.warn(
|
|
71
|
+
"REST mode requires FIGMA_PERSONAL_ACCESS_TOKEN. REST API will not be available.",
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
logger.info("Initializing REST API client with PAT from environment");
|
|
79
|
+
|
|
80
|
+
return createFigmaRestClient(pat);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function loadMcpConfig(configPath?: string): Promise<McpConfig | null> {
|
|
84
|
+
if (!configPath) return null;
|
|
85
|
+
|
|
86
|
+
const config = await loadConfig(configPath);
|
|
87
|
+
if (!config) return null;
|
|
88
|
+
|
|
89
|
+
logger.info(`Loaded configuration from: ${configPath}`);
|
|
90
|
+
|
|
91
|
+
if (config.extend?.componentHandlers?.length) {
|
|
92
|
+
logger.info(`Found ${config.extend.componentHandlers.length} custom component handlers`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return config;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function connectFigmaClient(figmaClient: FigmaWebSocketClient | null): void {
|
|
99
|
+
if (!figmaClient) return;
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
figmaClient.connectToFigma();
|
|
103
|
+
} catch (error) {
|
|
104
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
105
|
+
logger.warn(`Could not connect to Figma initially: ${message}`);
|
|
106
|
+
|
|
107
|
+
if (getFigmaAccessToken()) {
|
|
108
|
+
logger.info("REST API fallback available via FIGMA_PERSONAL_ACCESS_TOKEN");
|
|
109
|
+
} else {
|
|
110
|
+
logger.warn("Will try to connect when the first command is sent");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// MCP Server
|
|
116
|
+
|
|
117
|
+
interface McpServerOptions {
|
|
118
|
+
serverUrl?: string;
|
|
119
|
+
experimental?: boolean;
|
|
120
|
+
configPath?: string;
|
|
121
|
+
mode?: ToolMode;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function startMcpServer(options: McpServerOptions = {}): Promise<void> {
|
|
125
|
+
const { serverUrl, experimental, configPath, mode = "all" } = options;
|
|
126
|
+
|
|
127
|
+
const config = await loadMcpConfig(configPath);
|
|
128
|
+
const figmaClient = createFigmaClient(serverUrl, mode);
|
|
129
|
+
const restClient = createRestClient(mode);
|
|
209
130
|
|
|
210
|
-
const figmaClient = createFigmaWebSocketClient(serverUrl);
|
|
211
131
|
const server = new McpServer({
|
|
212
132
|
name: "SEED Design MCP",
|
|
213
133
|
version,
|
|
214
134
|
});
|
|
215
135
|
|
|
216
|
-
registerTools(server, figmaClient,
|
|
217
|
-
if (experimental) {
|
|
218
|
-
registerEditingTools(server, figmaClient);
|
|
219
|
-
}
|
|
136
|
+
registerTools(server, figmaClient, restClient, config, mode);
|
|
220
137
|
registerPrompts(server);
|
|
221
138
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
139
|
+
if (experimental) {
|
|
140
|
+
if (mode === "rest") {
|
|
141
|
+
logger.warn("Experimental editing tools not available in REST mode. Skipping.");
|
|
142
|
+
} else if (figmaClient) {
|
|
143
|
+
registerEditingTools(server, figmaClient);
|
|
144
|
+
} else {
|
|
145
|
+
logger.warn("Experimental editing tools require WebSocket connection. Skipping.");
|
|
146
|
+
}
|
|
229
147
|
}
|
|
230
148
|
|
|
149
|
+
connectFigmaClient(figmaClient);
|
|
150
|
+
|
|
231
151
|
const transport = new StdioServerTransport();
|
|
232
152
|
await server.connect(transport);
|
|
233
|
-
|
|
153
|
+
|
|
154
|
+
logger.info(`FigmaMCP server running on stdio (mode: ${mode})`);
|
|
234
155
|
}
|
|
235
156
|
|
|
236
|
-
//
|
|
157
|
+
// CLI
|
|
158
|
+
|
|
159
|
+
const cli = cac("@seed-design/mcp");
|
|
160
|
+
|
|
237
161
|
cli
|
|
238
162
|
.command("", "Start the MCP server")
|
|
239
|
-
.option(
|
|
163
|
+
.option(
|
|
164
|
+
"--server <server>",
|
|
165
|
+
"WebSocket server URL. If not provided and FIGMA_PERSONAL_ACCESS_TOKEN is set, REST API mode will be used.",
|
|
166
|
+
)
|
|
240
167
|
.option("--experimental", "Enable experimental features", { default: false })
|
|
241
168
|
.option("--config <config>", "Path to configuration file (.js, .mjs, .ts, .mts)")
|
|
169
|
+
.option(
|
|
170
|
+
"--mode <mode>",
|
|
171
|
+
"Tool registration mode: 'rest' (REST API tools only), 'websocket' (WebSocket tools only), or 'all' (default)",
|
|
172
|
+
)
|
|
242
173
|
.action(async (options) => {
|
|
243
|
-
|
|
174
|
+
const mode = options.mode as ToolMode | undefined;
|
|
175
|
+
if (mode && !["rest", "websocket", "all"].includes(mode)) {
|
|
176
|
+
console.error(`Invalid mode: ${mode}. Use 'rest', 'websocket', or 'all'.`);
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
await startMcpServer({
|
|
181
|
+
serverUrl: options.server,
|
|
182
|
+
experimental: options.experimental,
|
|
183
|
+
configPath: options.config,
|
|
184
|
+
mode,
|
|
185
|
+
});
|
|
244
186
|
});
|
|
245
187
|
|
|
246
188
|
cli
|
|
@@ -252,6 +194,4 @@ cli
|
|
|
252
194
|
|
|
253
195
|
cli.help();
|
|
254
196
|
cli.version(version);
|
|
255
|
-
|
|
256
|
-
// Parse CLI args
|
|
257
197
|
cli.parse();
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import type { ServerWebSocket } from "bun";
|
|
2
|
+
|
|
3
|
+
interface JoinMessage {
|
|
4
|
+
type: "join";
|
|
5
|
+
channel: string;
|
|
6
|
+
id?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface ChatMessage {
|
|
10
|
+
type: "message";
|
|
11
|
+
channel: string;
|
|
12
|
+
message: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type WebSocketMessage = JoinMessage | ChatMessage;
|
|
16
|
+
|
|
17
|
+
const channels = new Map<string, Set<ServerWebSocket<unknown>>>();
|
|
18
|
+
|
|
19
|
+
function sendJson(ws: ServerWebSocket<unknown>, data: object): void {
|
|
20
|
+
ws.send(JSON.stringify(data));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function broadcastToChannel(
|
|
24
|
+
channelName: string,
|
|
25
|
+
message: object,
|
|
26
|
+
excludeWs?: ServerWebSocket<unknown>,
|
|
27
|
+
): void {
|
|
28
|
+
const clients = channels.get(channelName);
|
|
29
|
+
if (!clients) return;
|
|
30
|
+
|
|
31
|
+
for (const client of clients) {
|
|
32
|
+
if (client !== excludeWs && client.readyState === WebSocket.OPEN) {
|
|
33
|
+
sendJson(client, message);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function handleJoin(ws: ServerWebSocket<unknown>, data: JoinMessage): void {
|
|
39
|
+
const { channel: channelName, id } = data;
|
|
40
|
+
|
|
41
|
+
if (!channelName || typeof channelName !== "string") {
|
|
42
|
+
sendJson(ws, { type: "error", message: "Channel name is required" });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!channels.has(channelName)) {
|
|
47
|
+
channels.set(channelName, new Set());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const channelClients = channels.get(channelName);
|
|
51
|
+
|
|
52
|
+
if (!channelClients) {
|
|
53
|
+
sendJson(ws, { type: "error", message: "Failed to join channel" });
|
|
54
|
+
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
channelClients.add(ws);
|
|
59
|
+
|
|
60
|
+
// Notify client they joined successfully
|
|
61
|
+
sendJson(ws, {
|
|
62
|
+
type: "system",
|
|
63
|
+
message: `Joined channel: ${channelName}`,
|
|
64
|
+
channel: channelName,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Send connection confirmation with ID if provided
|
|
68
|
+
if (id) {
|
|
69
|
+
console.log("Sending message to client:", id);
|
|
70
|
+
|
|
71
|
+
sendJson(ws, {
|
|
72
|
+
type: "system",
|
|
73
|
+
message: { id, result: `Connected to channel: ${channelName}` },
|
|
74
|
+
channel: channelName,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Notify other clients in channel
|
|
79
|
+
broadcastToChannel(
|
|
80
|
+
channelName,
|
|
81
|
+
{ type: "system", message: "A new user has joined the channel", channel: channelName },
|
|
82
|
+
ws,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function handleMessage(ws: ServerWebSocket<unknown>, data: ChatMessage): void {
|
|
87
|
+
const { channel: channelName, message } = data;
|
|
88
|
+
|
|
89
|
+
if (!channelName || typeof channelName !== "string") {
|
|
90
|
+
sendJson(ws, { type: "error", message: "Channel name is required" });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const channelClients = channels.get(channelName);
|
|
95
|
+
if (!channelClients?.has(ws)) {
|
|
96
|
+
sendJson(ws, { type: "error", message: "You must join the channel first" });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Broadcast to all clients in the channel
|
|
101
|
+
for (const client of channelClients) {
|
|
102
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
103
|
+
console.log("Broadcasting message to client:", message);
|
|
104
|
+
sendJson(client, {
|
|
105
|
+
type: "broadcast",
|
|
106
|
+
message,
|
|
107
|
+
sender: client === ws ? "You" : "User",
|
|
108
|
+
channel: channelName,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// WebSocket Event Handlers
|
|
115
|
+
|
|
116
|
+
function handleConnection(ws: ServerWebSocket<unknown>): void {
|
|
117
|
+
console.log("New client connected");
|
|
118
|
+
sendJson(ws, { type: "system", message: "Please join a channel to start chatting" });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function handleWebSocketMessage(ws: ServerWebSocket<unknown>, rawMessage: string | Buffer): void {
|
|
122
|
+
try {
|
|
123
|
+
console.log("Received message from client:", rawMessage);
|
|
124
|
+
const data = JSON.parse(rawMessage as string) as WebSocketMessage;
|
|
125
|
+
|
|
126
|
+
switch (data.type) {
|
|
127
|
+
case "join":
|
|
128
|
+
handleJoin(ws, data);
|
|
129
|
+
break;
|
|
130
|
+
case "message":
|
|
131
|
+
handleMessage(ws, data);
|
|
132
|
+
break;
|
|
133
|
+
default:
|
|
134
|
+
console.warn(`Unknown message type: ${(data as { type: string }).type}`);
|
|
135
|
+
}
|
|
136
|
+
} catch (err) {
|
|
137
|
+
console.error("Error handling message:", err);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function handleClose(ws: ServerWebSocket<unknown>): void {
|
|
142
|
+
console.log("Client disconnected");
|
|
143
|
+
|
|
144
|
+
for (const [channelName, clients] of channels) {
|
|
145
|
+
if (clients.has(ws)) {
|
|
146
|
+
clients.delete(ws);
|
|
147
|
+
broadcastToChannel(channelName, {
|
|
148
|
+
type: "system",
|
|
149
|
+
message: "A user has left the channel",
|
|
150
|
+
channel: channelName,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Server
|
|
157
|
+
|
|
158
|
+
export async function startWebSocketServer(port: number) {
|
|
159
|
+
const server = Bun.serve({
|
|
160
|
+
port,
|
|
161
|
+
// uncomment this to allow connections in windows wsl
|
|
162
|
+
// hostname: "0.0.0.0",
|
|
163
|
+
fetch(req, server) {
|
|
164
|
+
// Handle CORS preflight
|
|
165
|
+
if (req.method === "OPTIONS") {
|
|
166
|
+
return new Response(null, {
|
|
167
|
+
headers: {
|
|
168
|
+
"Access-Control-Allow-Origin": "*",
|
|
169
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
170
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Handle WebSocket upgrade
|
|
176
|
+
if (server.upgrade(req, { headers: { "Access-Control-Allow-Origin": "*" }, data: {} })) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Return response for non-WebSocket requests
|
|
181
|
+
return new Response("WebSocket server running", {
|
|
182
|
+
headers: { "Access-Control-Allow-Origin": "*" },
|
|
183
|
+
});
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
websocket: {
|
|
187
|
+
open: handleConnection,
|
|
188
|
+
message: handleWebSocketMessage,
|
|
189
|
+
close: handleClose,
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
console.log(`WebSocket server running on port ${server.port}`);
|
|
194
|
+
|
|
195
|
+
return server;
|
|
196
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Api as FigmaApi } from "figma-api";
|
|
2
|
+
import type { GetFileNodesResponse } from "@figma/rest-api-spec";
|
|
3
|
+
|
|
4
|
+
export interface FigmaRestClient {
|
|
5
|
+
getFileNodes(fileKey: string, nodeIds: string[]): Promise<GetFileNodesResponse>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function createFigmaRestClient(personalAccessToken: string): FigmaRestClient {
|
|
9
|
+
const api = new FigmaApi({ personalAccessToken });
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
async getFileNodes(fileKey: string, nodeIds: string[]): Promise<GetFileNodesResponse> {
|
|
13
|
+
const response = await api.getFileNodes({ file_key: fileKey }, { ids: nodeIds.join(",") });
|
|
14
|
+
|
|
15
|
+
return response;
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* https://www.figma.com/:file_type/:file_key/:file_name?node-id=:id
|
|
22
|
+
*
|
|
23
|
+
* file_type:
|
|
24
|
+
* - design
|
|
25
|
+
* - file (legacy)
|
|
26
|
+
*
|
|
27
|
+
* Note: While node-id is separated by hyphens ('-') in the URL,
|
|
28
|
+
* it must be converted to colons (':') when making API calls.
|
|
29
|
+
* e.g. URL "node-id=794-1987" → API "794:1987"
|
|
30
|
+
*/
|
|
31
|
+
export function parseFigmaUrl(url: string): { fileKey: string; nodeId: string } {
|
|
32
|
+
const __url: URL = (() => {
|
|
33
|
+
try {
|
|
34
|
+
return new URL(url);
|
|
35
|
+
} catch {
|
|
36
|
+
throw new Error(`Invalid URL format: ${url}`);
|
|
37
|
+
}
|
|
38
|
+
})();
|
|
39
|
+
|
|
40
|
+
const pathMatch = __url.pathname.match(/^\/(design|file)\/([A-Za-z0-9]+)/);
|
|
41
|
+
|
|
42
|
+
const rawNodeId = __url.searchParams.get("node-id");
|
|
43
|
+
|
|
44
|
+
if (!pathMatch)
|
|
45
|
+
throw new Error(
|
|
46
|
+
"Invalid Figma URL: Expected format https://www.figma.com/design/{fileKey}/... or /file/{fileKey}/...",
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
if (!rawNodeId) throw new Error("Invalid Figma URL: Missing node-id query parameter");
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
fileKey: pathMatch[2],
|
|
53
|
+
nodeId: rawNodeId.replace(/-/g, ":"),
|
|
54
|
+
};
|
|
55
|
+
}
|
package/src/index.ts
ADDED