@conference-kit/signaling-server 0.0.1 → 0.0.2
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 +51 -0
- package/package.json +1 -1
- package/src/index.ts +192 -5
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# @conference-kit/signaling-server
|
|
2
|
+
|
|
3
|
+
A lightweight Bun WebSocket signaling server for conferencing: presence, mesh signaling, broadcasts, waiting rooms, and host controls for admits/rejects and raised hands.
|
|
4
|
+
|
|
5
|
+
## Install & run
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install
|
|
9
|
+
npm run dev # bun run src/index.ts
|
|
10
|
+
# or build
|
|
11
|
+
npm run build # outputs dist/index.js
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Defaults: binds `ws://0.0.0.0:8787`.
|
|
15
|
+
|
|
16
|
+
## Connecting clients
|
|
17
|
+
|
|
18
|
+
Clients connect via WebSocket query params:
|
|
19
|
+
|
|
20
|
+
- `peerId` (required): unique ID for the peer.
|
|
21
|
+
- `room` (optional): room namespace for presence and broadcasts.
|
|
22
|
+
- `host=1` (optional): mark this peer as host (receives waiting list + control signals).
|
|
23
|
+
- `waitingRoom=1` (optional): non-hosts wait for host admission when enabled.
|
|
24
|
+
|
|
25
|
+
## Messages
|
|
26
|
+
|
|
27
|
+
- `presence`: broadcast to room on join/leave with `peers` roster.
|
|
28
|
+
- `signal`: relay payloads between peers (`{type:"signal", to, data}` inbound → `{type:"signal", from, data}` outbound).
|
|
29
|
+
- `broadcast`: room-wide app messages (`{type:"broadcast", data}` inbound → `{type:"broadcast", from, room, data}` outbound).
|
|
30
|
+
- `control`: server-mediated actions:
|
|
31
|
+
- hosts send `{action:"admit", data:{peerId}}` or `{action:"reject", data:{peerId}}`.
|
|
32
|
+
- everyone can raise/lower hands: `{action:"raise-hand"}` or `{action:"hand-lowered"}` routed to hosts.
|
|
33
|
+
- server notifies hosts with `{action:"waiting-list", data:{waiting:string[]}}` and notifies peers with `{action:"waiting"|"admitted"|"rejected"}`.
|
|
34
|
+
|
|
35
|
+
## Waiting room flow
|
|
36
|
+
|
|
37
|
+
1. Non-host connects with `waitingRoom=1`; server enqueues and sends them `waiting`.
|
|
38
|
+
2. Hosts in the same room receive `waiting-list` snapshots.
|
|
39
|
+
3. Host sends `admit` or `reject`.
|
|
40
|
+
4. Admit: peer is subscribed to the room, gets a `presence` join, and the host list updates. Reject: peer receives `rejected` and the socket closes.
|
|
41
|
+
|
|
42
|
+
## Topology
|
|
43
|
+
|
|
44
|
+
- Presence and broadcasts are room-scoped (`room:<name>` topics).
|
|
45
|
+
- Signals are peer-scoped (`peer:<peerId>` topics).
|
|
46
|
+
- State is kept in memory maps (`roomMembers`, `waitingRooms`, `clients`).
|
|
47
|
+
|
|
48
|
+
## Customizing
|
|
49
|
+
|
|
50
|
+
- Update `HOST`/`PORT` in `src/index.ts` if you need a different bind.
|
|
51
|
+
- Extend the `control` switch to add new server-mediated actions as needed.
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ type ServerWebSocket<T> = {
|
|
|
8
8
|
unsubscribe(topic: string): void;
|
|
9
9
|
publish(topic: string, data: string): void;
|
|
10
10
|
send(data: string): void;
|
|
11
|
+
close(code?: number): void;
|
|
11
12
|
};
|
|
12
13
|
|
|
13
14
|
declare const Bun: {
|
|
@@ -26,7 +27,13 @@ declare const Bun: {
|
|
|
26
27
|
}) => { port: number };
|
|
27
28
|
};
|
|
28
29
|
|
|
29
|
-
type ClientMeta = {
|
|
30
|
+
type ClientMeta = {
|
|
31
|
+
peerId: string;
|
|
32
|
+
room?: string | null;
|
|
33
|
+
isHost?: boolean;
|
|
34
|
+
waitingRoom?: boolean;
|
|
35
|
+
admitted?: boolean;
|
|
36
|
+
};
|
|
30
37
|
|
|
31
38
|
type SignalPayload = {
|
|
32
39
|
type: "signal";
|
|
@@ -39,12 +46,25 @@ type BroadcastPayload = {
|
|
|
39
46
|
data: unknown;
|
|
40
47
|
};
|
|
41
48
|
|
|
42
|
-
type
|
|
49
|
+
type ControlPayload = {
|
|
50
|
+
type: "control";
|
|
51
|
+
action: string;
|
|
52
|
+
data?: unknown;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
type IncomingMessage = SignalPayload | BroadcastPayload | ControlPayload;
|
|
43
56
|
|
|
44
57
|
type OutgoingMessage =
|
|
45
58
|
| { type: "signal"; from: string; data: unknown }
|
|
46
59
|
| { type: "broadcast"; from: string; room?: string | null; data: unknown }
|
|
47
60
|
| PresenceMessage
|
|
61
|
+
| {
|
|
62
|
+
type: "control";
|
|
63
|
+
from: string;
|
|
64
|
+
room?: string | null;
|
|
65
|
+
action: string;
|
|
66
|
+
data?: unknown;
|
|
67
|
+
}
|
|
48
68
|
| { type: "error"; message: string };
|
|
49
69
|
|
|
50
70
|
type PresenceMessage = {
|
|
@@ -58,7 +78,12 @@ type PresenceMessage = {
|
|
|
58
78
|
function parseMessage(raw: string): IncomingMessage | null {
|
|
59
79
|
try {
|
|
60
80
|
const parsed = JSON.parse(raw);
|
|
61
|
-
if (
|
|
81
|
+
if (
|
|
82
|
+
parsed &&
|
|
83
|
+
(parsed.type === "signal" ||
|
|
84
|
+
parsed.type === "broadcast" ||
|
|
85
|
+
parsed.type === "control")
|
|
86
|
+
)
|
|
62
87
|
return parsed;
|
|
63
88
|
return null;
|
|
64
89
|
} catch (error) {
|
|
@@ -71,6 +96,45 @@ const HOST = "0.0.0.0";
|
|
|
71
96
|
const PORT = 8787;
|
|
72
97
|
|
|
73
98
|
const roomMembers = new Map<string, Set<string>>();
|
|
99
|
+
const waitingRooms = new Map<string, Set<string>>();
|
|
100
|
+
const clients = new Map<string, ServerWebSocket<ClientMeta>>();
|
|
101
|
+
|
|
102
|
+
function getHosts(room?: string | null) {
|
|
103
|
+
if (!room) return [] as ServerWebSocket<ClientMeta>[];
|
|
104
|
+
const hosts: ServerWebSocket<ClientMeta>[] = [];
|
|
105
|
+
for (const ws of clients.values()) {
|
|
106
|
+
if (ws.data.room === room && ws.data.isHost) hosts.push(ws);
|
|
107
|
+
}
|
|
108
|
+
return hosts;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function sendControl(
|
|
112
|
+
target: ServerWebSocket<ClientMeta>,
|
|
113
|
+
payload: {
|
|
114
|
+
action: string;
|
|
115
|
+
data?: unknown;
|
|
116
|
+
room?: string | null;
|
|
117
|
+
from?: string;
|
|
118
|
+
}
|
|
119
|
+
) {
|
|
120
|
+
const message: OutgoingMessage = {
|
|
121
|
+
type: "control",
|
|
122
|
+
from: payload.from ?? "server",
|
|
123
|
+
room: payload.room,
|
|
124
|
+
action: payload.action,
|
|
125
|
+
data: payload.data,
|
|
126
|
+
};
|
|
127
|
+
target.send(JSON.stringify(message));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function broadcastToHosts(
|
|
131
|
+
room: string | null | undefined,
|
|
132
|
+
payload: { action: string; data?: unknown }
|
|
133
|
+
) {
|
|
134
|
+
getHosts(room).forEach((hostWs) =>
|
|
135
|
+
sendControl(hostWs, { action: payload.action, data: payload.data, room })
|
|
136
|
+
);
|
|
137
|
+
}
|
|
74
138
|
|
|
75
139
|
// Signaling server with simple room presence
|
|
76
140
|
const liveServer = Bun.serve<ClientMeta>({
|
|
@@ -80,12 +144,22 @@ const liveServer = Bun.serve<ClientMeta>({
|
|
|
80
144
|
const url = new URL(req.url);
|
|
81
145
|
const peerId = url.searchParams.get("peerId");
|
|
82
146
|
const room = url.searchParams.get("room");
|
|
147
|
+
const isHost = url.searchParams.get("host") === "1";
|
|
148
|
+
const waitingRoom = url.searchParams.get("waitingRoom") === "1";
|
|
83
149
|
|
|
84
150
|
if (!peerId) {
|
|
85
151
|
return new Response("peerId query param is required", { status: 400 });
|
|
86
152
|
}
|
|
87
153
|
|
|
88
|
-
const upgraded = bunServer.upgrade(req, {
|
|
154
|
+
const upgraded = bunServer.upgrade(req, {
|
|
155
|
+
data: {
|
|
156
|
+
peerId,
|
|
157
|
+
room,
|
|
158
|
+
isHost,
|
|
159
|
+
waitingRoom,
|
|
160
|
+
admitted: isHost ? true : !waitingRoom,
|
|
161
|
+
},
|
|
162
|
+
});
|
|
89
163
|
if (!upgraded) {
|
|
90
164
|
return new Response("WebSocket upgrade failed", { status: 500 });
|
|
91
165
|
}
|
|
@@ -95,8 +169,26 @@ const liveServer = Bun.serve<ClientMeta>({
|
|
|
95
169
|
websocket: {
|
|
96
170
|
open(ws: ServerWebSocket<ClientMeta>) {
|
|
97
171
|
const { peerId, room } = ws.data;
|
|
172
|
+
clients.set(peerId, ws);
|
|
98
173
|
ws.subscribe(`peer:${peerId}`);
|
|
99
174
|
if (room) {
|
|
175
|
+
if (ws.data.waitingRoom && !ws.data.isHost) {
|
|
176
|
+
const waiters = waitingRooms.get(room) ?? new Set<string>();
|
|
177
|
+
waiters.add(peerId);
|
|
178
|
+
waitingRooms.set(room, waiters);
|
|
179
|
+
broadcastToHosts(room, {
|
|
180
|
+
action: "waiting-list",
|
|
181
|
+
data: { waiting: Array.from(waiters) },
|
|
182
|
+
});
|
|
183
|
+
sendControl(ws, {
|
|
184
|
+
action: "waiting",
|
|
185
|
+
room,
|
|
186
|
+
data: { position: waiters.size },
|
|
187
|
+
});
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
ws.data.admitted = true;
|
|
100
192
|
ws.subscribe(`room:${room}`);
|
|
101
193
|
const set = roomMembers.get(room) ?? new Set<string>();
|
|
102
194
|
set.add(peerId);
|
|
@@ -108,9 +200,18 @@ const liveServer = Bun.serve<ClientMeta>({
|
|
|
108
200
|
peers: Array.from(set),
|
|
109
201
|
action: "join",
|
|
110
202
|
};
|
|
111
|
-
// Publish to everyone in the room and also send directly so the new peer gets an immediate roster snapshot.
|
|
112
203
|
ws.publish(`room:${room}`, JSON.stringify(presence));
|
|
113
204
|
ws.send(JSON.stringify(presence));
|
|
205
|
+
|
|
206
|
+
// send waiting list snapshot to host
|
|
207
|
+
const pending = waitingRooms.get(room);
|
|
208
|
+
if (ws.data.isHost && pending && pending.size) {
|
|
209
|
+
sendControl(ws, {
|
|
210
|
+
action: "waiting-list",
|
|
211
|
+
room,
|
|
212
|
+
data: { waiting: Array.from(pending) },
|
|
213
|
+
});
|
|
214
|
+
}
|
|
114
215
|
}
|
|
115
216
|
},
|
|
116
217
|
message(
|
|
@@ -151,11 +252,97 @@ const liveServer = Bun.serve<ClientMeta>({
|
|
|
151
252
|
}
|
|
152
253
|
return;
|
|
153
254
|
}
|
|
255
|
+
|
|
256
|
+
if (parsed.type === "control") {
|
|
257
|
+
const room = ws.data.room;
|
|
258
|
+
if (parsed.action === "admit" && ws.data.isHost && room) {
|
|
259
|
+
const targetId = (parsed.data as any)?.peerId as string | undefined;
|
|
260
|
+
if (!targetId) return;
|
|
261
|
+
const waiters = waitingRooms.get(room);
|
|
262
|
+
const targetWs = targetId ? clients.get(targetId) : undefined;
|
|
263
|
+
if (waiters && waiters.has(targetId) && targetWs) {
|
|
264
|
+
waiters.delete(targetId);
|
|
265
|
+
waitingRooms.set(room, waiters);
|
|
266
|
+
targetWs.data.admitted = true;
|
|
267
|
+
targetWs.subscribe(`room:${room}`);
|
|
268
|
+
const set = roomMembers.get(room) ?? new Set<string>();
|
|
269
|
+
set.add(targetId);
|
|
270
|
+
roomMembers.set(room, set);
|
|
271
|
+
const presence: PresenceMessage = {
|
|
272
|
+
type: "presence",
|
|
273
|
+
room,
|
|
274
|
+
peerId: targetId,
|
|
275
|
+
peers: Array.from(set),
|
|
276
|
+
action: "join",
|
|
277
|
+
};
|
|
278
|
+
targetWs.publish(`room:${room}`, JSON.stringify(presence));
|
|
279
|
+
targetWs.send(JSON.stringify(presence));
|
|
280
|
+
broadcastToHosts(room, {
|
|
281
|
+
action: "waiting-list",
|
|
282
|
+
data: { waiting: Array.from(waiters) },
|
|
283
|
+
});
|
|
284
|
+
sendControl(targetWs, {
|
|
285
|
+
action: "admitted",
|
|
286
|
+
room,
|
|
287
|
+
from: ws.data.peerId,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (parsed.action === "reject" && ws.data.isHost && room) {
|
|
294
|
+
const targetId = (parsed.data as any)?.peerId as string | undefined;
|
|
295
|
+
if (!targetId) return;
|
|
296
|
+
const waiters = waitingRooms.get(room);
|
|
297
|
+
const targetWs = targetId ? clients.get(targetId) : undefined;
|
|
298
|
+
if (waiters && waiters.has(targetId) && targetWs) {
|
|
299
|
+
waiters.delete(targetId);
|
|
300
|
+
waitingRooms.set(room, waiters);
|
|
301
|
+
sendControl(targetWs, {
|
|
302
|
+
action: "rejected",
|
|
303
|
+
room,
|
|
304
|
+
from: ws.data.peerId,
|
|
305
|
+
});
|
|
306
|
+
targetWs.close();
|
|
307
|
+
broadcastToHosts(room, {
|
|
308
|
+
action: "waiting-list",
|
|
309
|
+
data: { waiting: Array.from(waiters) },
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (parsed.action === "raise-hand" && room) {
|
|
316
|
+
broadcastToHosts(room, {
|
|
317
|
+
action: "raise-hand",
|
|
318
|
+
data: { peerId: ws.data.peerId },
|
|
319
|
+
});
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (parsed.action === "hand-lowered" && room) {
|
|
324
|
+
broadcastToHosts(room, {
|
|
325
|
+
action: "hand-lowered",
|
|
326
|
+
data: { peerId: ws.data.peerId },
|
|
327
|
+
});
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
154
331
|
},
|
|
155
332
|
close(ws: ServerWebSocket<ClientMeta>) {
|
|
156
333
|
const { peerId, room } = ws.data;
|
|
157
334
|
ws.unsubscribe(`peer:${peerId}`);
|
|
335
|
+
clients.delete(peerId);
|
|
158
336
|
if (room) {
|
|
337
|
+
const waiters = waitingRooms.get(room);
|
|
338
|
+
if (waiters && waiters.delete(peerId)) {
|
|
339
|
+
waitingRooms.set(room, waiters);
|
|
340
|
+
broadcastToHosts(room, {
|
|
341
|
+
action: "waiting-list",
|
|
342
|
+
data: { waiting: Array.from(waiters) },
|
|
343
|
+
});
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
159
346
|
ws.unsubscribe(`room:${room}`);
|
|
160
347
|
const set = roomMembers.get(room);
|
|
161
348
|
if (set) {
|