Skip to content

Commit cfb21f0

Browse files
committed
feat(signaling): add deployable signaling server for Fly.io
Standalone y-webrtc compatible signaling server for WebRTC peer discovery and device pairing. Configured for Fly.io with auto-sleep.
1 parent 077c511 commit cfb21f0

4 files changed

Lines changed: 176 additions & 0 deletions

File tree

signaling-server/Dockerfile

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
FROM node:20-slim
2+
3+
WORKDIR /app
4+
5+
COPY package*.json ./
6+
RUN npm install --omit=dev
7+
8+
COPY server.js ./
9+
10+
EXPOSE 8080
11+
12+
CMD ["node", "server.js"]

signaling-server/fly.toml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# fly.toml app configuration file generated for hypermark-signaling on 2026-01-24T10:45:55+02:00
2+
#
3+
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
4+
#
5+
6+
app = 'hypermark-signaling'
7+
primary_region = 'sjc'
8+
9+
[build]
10+
11+
[env]
12+
PORT = '8080'
13+
14+
[http_service]
15+
internal_port = 8080
16+
force_https = true
17+
auto_stop_machines = 'stop'
18+
auto_start_machines = true
19+
min_machines_running = 0
20+
21+
[[vm]]
22+
memory = '256mb'
23+
cpu_kind = 'shared'
24+
cpus = 1
25+
memory_mb = 256

signaling-server/package.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"name": "hypermark-signaling",
3+
"version": "1.0.0",
4+
"description": "WebRTC signaling server for Hypermark",
5+
"private": true,
6+
"type": "module",
7+
"scripts": {
8+
"start": "node server.js"
9+
},
10+
"dependencies": {
11+
"ws": "^8.18.0"
12+
},
13+
"engines": {
14+
"node": ">=18"
15+
}
16+
}

signaling-server/server.js

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* y-webrtc compatible signaling server
3+
* Handles pub/sub for WebRTC peer discovery and pairing
4+
*/
5+
6+
import { WebSocketServer } from 'ws'
7+
8+
const PORT = process.env.PORT || 4444
9+
const PING_INTERVAL = 30000
10+
11+
const wss = new WebSocketServer({ port: PORT })
12+
13+
// topic -> Set<WebSocket>
14+
const topics = new Map()
15+
16+
function send(ws, message) {
17+
if (ws.readyState === ws.OPEN) {
18+
ws.send(JSON.stringify(message))
19+
}
20+
}
21+
22+
function subscribe(ws, topicList) {
23+
for (const topic of topicList) {
24+
if (!topics.has(topic)) {
25+
topics.set(topic, new Set())
26+
}
27+
topics.get(topic).add(ws)
28+
29+
if (!ws.topics) ws.topics = new Set()
30+
ws.topics.add(topic)
31+
}
32+
}
33+
34+
function unsubscribe(ws, topicList) {
35+
for (const topic of topicList) {
36+
if (topics.has(topic)) {
37+
topics.get(topic).delete(ws)
38+
if (topics.get(topic).size === 0) {
39+
topics.delete(topic)
40+
}
41+
}
42+
if (ws.topics) {
43+
ws.topics.delete(topic)
44+
}
45+
}
46+
}
47+
48+
function publish(ws, topic, data) {
49+
if (!topics.has(topic)) return
50+
51+
const message = JSON.stringify({ topic, data })
52+
53+
for (const client of topics.get(topic)) {
54+
// Don't send back to sender
55+
if (client !== ws && client.readyState === client.OPEN) {
56+
client.send(message)
57+
}
58+
}
59+
}
60+
61+
function cleanup(ws) {
62+
if (ws.topics) {
63+
unsubscribe(ws, Array.from(ws.topics))
64+
}
65+
}
66+
67+
wss.on('connection', (ws) => {
68+
ws.isAlive = true
69+
70+
ws.on('pong', () => {
71+
ws.isAlive = true
72+
})
73+
74+
ws.on('message', (data) => {
75+
try {
76+
const message = JSON.parse(data.toString())
77+
78+
switch (message.type) {
79+
case 'subscribe':
80+
subscribe(ws, message.topics || [])
81+
break
82+
case 'unsubscribe':
83+
unsubscribe(ws, message.topics || [])
84+
break
85+
case 'publish':
86+
publish(ws, message.topic, message.data)
87+
break
88+
case 'ping':
89+
send(ws, { type: 'pong' })
90+
break
91+
}
92+
} catch (err) {
93+
console.error('Failed to parse message:', err)
94+
}
95+
})
96+
97+
ws.on('close', () => {
98+
cleanup(ws)
99+
})
100+
101+
ws.on('error', (err) => {
102+
console.error('WebSocket error:', err)
103+
cleanup(ws)
104+
})
105+
})
106+
107+
// Heartbeat to detect dead connections
108+
const interval = setInterval(() => {
109+
wss.clients.forEach((ws) => {
110+
if (ws.isAlive === false) {
111+
cleanup(ws)
112+
return ws.terminate()
113+
}
114+
ws.isAlive = false
115+
ws.ping()
116+
})
117+
}, PING_INTERVAL)
118+
119+
wss.on('close', () => {
120+
clearInterval(interval)
121+
})
122+
123+
console.log(`Signaling server running on port ${PORT}`)

0 commit comments

Comments
 (0)