Minimal WebSocket relay server for real-time message forwarding between browsers and devices.
- Zero-config relay — no auth, no database, just connect and forward
- Broadcast & unicast — send to all clients or target a specific one by
clientId - Built-in debug console — multi-tab UI with dark terminal, connection presets, online clients panel, no build tools needed
- 30s heartbeat — automatic ping/pong keep-alive, compatible with Cloudflare proxy
- Self-contained tests — integration tests over real WebSocket connections using native Node.js
assert
pnpm install
pnpm dev
# Open http://localhost:3000pnpm monorepo with two packages:
Browser ──► WS ──► Fastify(:3000) ◄── WS ◄── Device
│
├─ Static file hosting + SPA fallback
└─ WebSocket relay plugin
packages/
├── shared/ # TypeScript types (WSMessage, ClientMessage, ServerMessage)
└── server/
├── src/
│ ├── index.ts # Entry: static files, /api/ping, graceful shutdown
│ └── plugins/websocket-relay.ts # Core relay: client map, ping/pong, broadcast/unicast
├── public/
│ ├── index.html # Debug console — HTML structure
│ ├── style.css # Styles (Indigo light theme, dark terminal)
│ └── app.js # Logic: multi-tab, presets, online clients
└── test/websocket-relay.selftest.js
Connect: ws://host/ws?clientId=xxx&role=web|device
| Type | Payload |
|---|---|
ping |
{} |
relay.send |
{ target: "broadcast" | "<clientId>", event, data } |
connections.list |
{} |
| Type | Payload |
|---|---|
pong |
{} |
relay.recv |
{ from: "<clientId>", event, data } |
connections.info |
{ clients: [{ clientId, role, connectedAt }] } |
connection.notify |
{ action: "join" | "leave", clientId, role, total, connectedAt? } |
- Duplicate
clientIdreplaces old connection (close code4000) - Missing
clientIdrejected (close code4002)
import WebSocket from 'ws';
const ws = new WebSocket('ws://localhost:3000/ws?clientId=device-001&role=device');
ws.on('open', () => {
// Send a message to all connected clients
ws.send(
JSON.stringify({
type: 'relay.send',
payload: { target: 'broadcast', event: 'sensor', data: { temp: 22.5 } },
timestamp: Date.now(),
}),
);
});
ws.on('message', (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.type === 'relay.recv') {
console.log(`From ${msg.payload.from}: ${msg.payload.event}`, msg.payload.data);
}
});The server can be exposed to the public internet via frp + reverse proxy. See docs/deploy-frp.md for a complete guide covering frps/frpc setup, OpenResty reverse proxy, and Cloudflare DNS configuration.
| Variable | Default | Description |
|---|---|---|
API_HOST |
0.0.0.0 |
Server listen host |
API_PORT |
3000 |
Server listen port |
LOG_LEVEL |
info |
Log level |
All variables have built-in defaults — no .env file required.
See CONTRIBUTING.md for development setup, code style, and pull request guidelines.
MIT © 2026 Maple