Overview
The WebSocket relay is a lightweight Bun-powered server that acts as a message router between MCP servers and Figma plugins. It runs on port 3055 by default and provides channel-based message isolation.
Why a Relay Server?
The relay solves several architectural challenges:
MCP servers communicate via stdio with AI agents, but need WebSocket connectivity to reach Figma plugins. The relay bridges this gap.
Multiple users can run MCP servers simultaneously on the same machine. The relay uses channel isolation to prevent message conflicts.
The relay handles WebSocket lifecycle events (connect, disconnect, reconnect) and maintains client state across channels.
The relay efficiently broadcasts messages to all clients in a channel except the sender , preventing echo loops.
Server Architecture
Core Components
import { Server , ServerWebSocket } from "bun" ;
// Store clients by channel
const channels = new Map < string , Set < ServerWebSocket < any >>>();
const server = Bun . serve ({
port: 3055 ,
websocket: {
open: handleConnection ,
message: handleMessage ,
close: handleClose
}
});
console . log ( `WebSocket server running on port ${ server . port } ` );
Data Structures
The relay uses a Map of Sets to organize clients by channel:
// Type definition
type Channels = Map < string , Set < ServerWebSocket < any >>>;
// Example state:
channels = Map {
"my-project" => Set { ws1 , ws2 },
"design-team" => Set { ws3 },
"prototype" => Set { ws4 , ws5 , ws6 }
}
This structure provides O(1) channel lookups and O(n) broadcast operations within a channel.
Connection Lifecycle
1. Client Connection
When a client connects, they receive a welcome message:
function handleConnection ( ws : ServerWebSocket < any >) {
console . log ( "New client connected" );
// Send welcome message
ws . send ( JSON . stringify ({
type: "system" ,
message: "Please join a channel to start chatting"
}));
// Note: Client is NOT added to any channel yet
}
Clients cannot send or receive messages until they join a channel.
2. Joining a Channel
Clients send a join message to enter a channel:
{
"type" : "join" ,
"channel" : "my-project"
}
The relay processes the join request:
if ( data . type === "join" ) {
const channelName = data . channel ;
if ( ! channelName || typeof channelName !== "string" ) {
ws . send ( JSON . stringify ({
type: "error" ,
message: "Channel name is required"
}));
return ;
}
// Create channel if it doesn't exist
if ( ! channels . has ( channelName )) {
channels . set ( channelName , new Set ());
}
// Add client to channel
const channelClients = channels . get ( channelName ) ! ;
channelClients . add ( ws );
console . log ( `✓ Client joined channel " ${ channelName } " ( ${ channelClients . size } total clients)` );
// Notify client of successful join
ws . send ( JSON . stringify ({
type: "system" ,
message: `Joined channel: ${ channelName } ` ,
channel: channelName
}));
// Notify other clients in channel
channelClients . forEach (( client ) => {
if ( client !== ws && client . readyState === WebSocket . OPEN ) {
client . send ( JSON . stringify ({
type: "system" ,
message: "A new user has joined the channel" ,
channel: channelName
}));
}
});
}
3. Sending Messages
Once in a channel, clients can exchange messages:
{
"type" : "message" ,
"channel" : "my-project" ,
"message" : {
"id" : "abc-123" ,
"command" : "create_frame" ,
"params" : { /* ... */ }
}
}
The relay broadcasts to channel members:
if ( data . type === "message" ) {
const channelName = data . channel ;
if ( ! channelName || typeof channelName !== "string" ) {
ws . send ( JSON . stringify ({
type: "error" ,
message: "Channel name is required"
}));
return ;
}
const channelClients = channels . get ( channelName );
if ( ! channelClients || ! channelClients . has ( ws )) {
ws . send ( JSON . stringify ({
type: "error" ,
message: "You must join the channel first"
}));
return ;
}
// Broadcast to all OTHER clients (not the sender)
let broadcastCount = 0 ;
channelClients . forEach (( client ) => {
if ( client !== ws && client . readyState === WebSocket . OPEN ) {
broadcastCount ++ ;
const broadcastMessage = {
type: "broadcast" ,
message: data . message ,
sender: "peer" ,
channel: channelName
};
console . log ( `=== Broadcasting to peer # ${ broadcastCount } ===` );
console . log ( JSON . stringify ( broadcastMessage , null , 2 ));
client . send ( JSON . stringify ( broadcastMessage ));
}
});
if ( broadcastCount === 0 ) {
console . log ( `⚠️ No other clients in channel " ${ channelName } " to receive message!` );
} else {
console . log ( `✓ Broadcast to ${ broadcastCount } peer(s) in channel " ${ channelName } "` );
}
}
The relay excludes the sender from broadcasts to prevent echo loops and ensure clean request-response flow.
4. Client Disconnection
When a client disconnects, they’re removed from all channels:
function handleClose ( ws : ServerWebSocket < any >) {
console . log ( "Client disconnected" );
// Remove client from all channels
channels . forEach (( clients , channelName ) => {
if ( clients . has ( ws )) {
clients . delete ( ws );
// Notify remaining clients
clients . forEach (( client ) => {
if ( client . readyState === WebSocket . OPEN ) {
client . send ( JSON . stringify ({
type: "system" ,
message: "A user has left the channel" ,
channel: channelName
}));
}
});
}
});
}
Message Format
Join Message
interface JoinMessage {
type : "join" ;
channel : string ;
}
Regular Message
interface RegularMessage {
type : "message" ;
channel : string ;
message : {
id : string ;
command ?: string ;
params ?: any ;
result ?: any ;
error ?: string ;
};
}
Broadcast Message (relayed)
interface BroadcastMessage {
type : "broadcast" ;
message : any ;
sender : "peer" ;
channel : string ;
}
System Message
interface SystemMessage {
type : "system" ;
message : string ;
channel ?: string ;
}
Error Message
interface ErrorMessage {
type : "error" ;
message : string ;
}
Configuration
Port Configuration
The relay port is configurable via environment variable:
# Default port
bun socket
# Runs on port 3055
# Custom port
PORT = 8080 bun socket
# Runs on port 8080
Read from environment in code:
const server = Bun . serve ({
port: process . env . PORT || 3055 ,
// ...
});
Host Configuration
For WSL or remote access, uncomment the hostname option:
const server = Bun . serve ({
port: 3055 ,
hostname: "0.0.0.0" , // Allow external connections
// ...
});
Binding to 0.0.0.0 exposes the relay to your network. Only use this in trusted environments.
The relay includes CORS headers for browser-based clients:
fetch ( req : Request , server : Server ) {
// Handle CORS preflight
if ( req . method === "OPTIONS" ) {
return new Response ( null , {
headers: {
"Access-Control-Allow-Origin" : "*" ,
"Access-Control-Allow-Methods" : "GET, POST, OPTIONS" ,
"Access-Control-Allow-Headers" : "Content-Type, Authorization"
}
});
}
// Handle WebSocket upgrade
const success = server . upgrade ( req , {
headers: {
"Access-Control-Allow-Origin" : "*"
}
});
if ( success ) {
return ; // Upgraded to WebSocket
}
return new Response ( "WebSocket server running" , {
headers: {
"Access-Control-Allow-Origin" : "*"
}
});
}
Logging and Debugging
The relay provides detailed console logging:
message ( ws : ServerWebSocket < any > , message : string | Buffer ) {
try {
const data = JSON . parse ( message as string );
console . log ( ` \n === Received message from client ===` );
console . log ( `Type: ${ data . type } , Channel: ${ data . channel || 'N/A' } ` );
if ( data . message ?. command ) {
console . log ( `Command: ${ data . message . command } , ID: ${ data . id } ` );
} else if ( data . message ?. result ) {
console . log ( `Response: ID: ${ data . id } , Has Result: ${ !! data . message . result } ` );
}
console . log ( `Full message:` , JSON . stringify ( data , null , 2 ));
// ... handle message ...
} catch ( err ) {
console . error ( "Error handling message:" , err );
}
}
Example Log Output
WebSocket server running on port 3055
New client connected
=== Received message from client ===
Type: join, Channel: my-project
Full message: {
"type": "join",
"channel": "my-project"
}
✓ Client joined channel "my-project" (1 total clients)
=== Received message from client ===
Type: message, Channel: my-project
Command: create_frame, ID: abc-123
Full message: {
"type": "message",
"channel": "my-project",
"message": {
"id": "abc-123",
"command": "create_frame",
"params": { /* ... */ }
}
}
=== Broadcasting to peer #1 ===
{
"type": "broadcast",
"message": { /* ... */ },
"sender": "peer",
"channel": "my-project"
}
✓ Broadcast to 1 peer(s) in channel "my-project"
Performance Characteristics
Benchmarks
Channel lookup : O(1) using Map
Client broadcast : O(n) where n = clients in channel
Join/leave : O(1) for Set operations
Memory : ~100 bytes per client per channel
Scalability
The relay is designed for local development , not high-scale production:
Supported : 1-10 clients per channel, 1-10 active channels
Memory : Grows linearly with client count
CPU : Minimal (less than 1% on modern hardware)
Network : No compression or batching optimizations
For production deployments, consider using a battle-tested WebSocket server like Socket.IO or Centrifugo.
Starting the Relay
Development Mode
# Start relay in one terminal
bun socket
# Output:
# WebSocket server running on port 3055
Production Mode
For production, use a process manager like PM2:
# Install PM2
npm install -g pm2
# Start relay with PM2
pm2 start "bun socket" --name figma-relay
# Monitor
pm2 logs figma-relay
# Stop
pm2 stop figma-relay
Docker Deployment
Example Dockerfile for containerized deployment:
FROM oven/bun:1
WORKDIR /app
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile
COPY src/socket.ts ./src/
EXPOSE 3055
CMD [ "bun" , "socket" ]
Build and run:
docker build -t figma-relay .
docker run -p 3055:3055 figma-relay
Error Handling
The relay handles common error scenarios:
Invalid JSON
message ( ws , message ) {
try {
const data = JSON . parse ( message as string );
// ...
} catch ( err ) {
console . error ( "Error handling message:" , err );
ws . send ( JSON . stringify ({
type: "error" ,
message: "Invalid JSON message"
}));
}
}
Missing Channel Name
if ( ! channelName || typeof channelName !== "string" ) {
ws . send ( JSON . stringify ({
type: "error" ,
message: "Channel name is required"
}));
return ;
}
Not in Channel
if ( ! channelClients || ! channelClients . has ( ws )) {
ws . send ( JSON . stringify ({
type: "error" ,
message: "You must join the channel first"
}));
return ;
}
Troubleshooting
”Connection refused” on port 3055
Cause : Relay server is not running.
Solution :
“No other clients in channel”
Cause : Only one client in the channel.
Solution : Connect both MCP server and Figma plugin to the same channel:
# Terminal 1: Start relay
bun socket
# Terminal 2: In MCP server log
[INFO] Joined channel: my-project
# Figma Plugin UI: Join same channel
Channel: my-project ✓
Messages not routing
Cause : Clients in different channels.
Solution : Verify channel names match exactly (case-sensitive):
# ✗ Wrong - different channels
MCP Server: "My-Project"
Figma Plugin: "my-project"
# ✓ Correct - same channel
MCP Server: "my-project"
Figma Plugin: "my-project"
Next Steps
System Architecture Understand the full three-component pipeline
Channel Communication Learn about channel-based isolation