Skip to content

Commit cba1add

Browse files
committed
feat: add broadcast server — HTTP endpoints, socket relay, state management, and integration tests
1 parent 35e40ce commit cba1add

File tree

2 files changed

+240
-0
lines changed

2 files changed

+240
-0
lines changed

server/server.js

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,13 @@ const channelLimiter = rateLimit({
364364
message: { error: "Too many requests, try again later" }
365365
});
366366

367+
const broadcastLimiter = rateLimit({
368+
windowMs: 60 * 1000,
369+
max: 5,
370+
skip: () => process.env.NODE_ENV === 'test',
371+
message: { error: "Too many broadcast attempts, slow down" }
372+
});
373+
367374
/* LOGIN POST */
368375
app.post("/login", loginLimiter, (req, res) => {
369376
if (!config.auth.passphrase) return res.redirect("/");
@@ -812,6 +819,60 @@ app.get("/health", (req, res) => {
812819
});
813820
});
814821

822+
/* BROADCAST*/
823+
/* GET /broadcast/status — returns current broadcast or null */
824+
app.get("/broadcast/status", (req, res) => {
825+
if (!currentBroadcast) return res.json({ live: false });
826+
res.json({
827+
live: true,
828+
uploader: currentBroadcast.uploader,
829+
channel: currentBroadcast.channel,
830+
startedAt: currentBroadcast.startedAt
831+
});
832+
});
833+
834+
/* POST /broadcast/start */
835+
app.post("/broadcast/start", broadcastLimiter, (req, res) => {
836+
if (currentBroadcast) {
837+
return res.status(409).json({
838+
error: "Broadcast already in progress",
839+
uploader: currentBroadcast.uploader
840+
});
841+
}
842+
843+
const { uploader, channel } = req.body;
844+
if (!uploader || !channel) {
845+
return res.status(400).json({ error: "uploader and channel required" });
846+
}
847+
848+
currentBroadcast = {
849+
uploader,
850+
channel,
851+
startedAt: Date.now(),
852+
lastFrame: null
853+
};
854+
855+
io.emit("broadcast-started", {
856+
uploader: currentBroadcast.uploader,
857+
channel: currentBroadcast.channel,
858+
startedAt: currentBroadcast.startedAt
859+
});
860+
861+
console.log(`Broadcast started by ${uploader}`);
862+
res.json({ ok: true, ...currentBroadcast });
863+
});
864+
865+
/* POST /broadcast/end */
866+
app.post("/broadcast/end", (req, res) => {
867+
if (!currentBroadcast) return res.status(400).json({ error: "No broadcast in progress" });
868+
869+
const uploader = currentBroadcast.uploader;
870+
currentBroadcast = null;
871+
872+
io.emit("broadcast-ended", { uploader });
873+
console.log(`Broadcast ended by ${uploader}`);
874+
res.json({ ok: true });
875+
});
815876

816877
/* FAVICON */
817878
app.get("/favicon-dynamic.png", async (req, res) => {
@@ -867,6 +928,9 @@ app.get("/logo-dynamic.png", (req, res) => {
867928
// resets on server restart, no DB needed
868929
const seenBy = new Map();
869930

931+
// Broadcast state — null when IDLE, populated when LIVE
932+
let currentBroadcast = null;
933+
870934
let connectedUsers = 0;
871935

872936
io.on("connection", (socket) => {
@@ -889,6 +953,27 @@ io.on("connection", (socket) => {
889953
io.emit("seen-update", { id, count });
890954
});
891955

956+
// Broadcast — frame relay
957+
socket.on("broadcast-frame", (data) => {
958+
if (!currentBroadcast) return;
959+
currentBroadcast.lastFrame = data.frame;
960+
// relay to everyone except the sender
961+
socket.broadcast.emit("broadcast-frame", { frame: data.frame });
962+
});
963+
964+
// Broadcast — viewer joins, send last frame immediately
965+
socket.on("broadcast-join", () => {
966+
if (!currentBroadcast || !currentBroadcast.lastFrame) return;
967+
socket.emit("broadcast-frame", { frame: currentBroadcast.lastFrame });
968+
});
969+
970+
// Broadcast — raise hand, relay only to broadcaster
971+
socket.on("broadcast-reaction", ({ from }) => {
972+
if (!currentBroadcast) return;
973+
// find broadcaster's socket and emit only to them
974+
io.emit("broadcast-reaction-received", { from });
975+
});
976+
892977
socket.on("disconnect", () => {
893978
connectedUsers--;
894979
console.log(username + " disconnected | total:", connectedUsers);
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/**
2+
* tests/integration/broadcast.test.js
3+
*
4+
* Tests broadcast HTTP endpoints:
5+
* GET /broadcast/status
6+
* POST /broadcast/start
7+
* POST /broadcast/end
8+
*/
9+
10+
import { describe, it, expect, beforeEach } from 'vitest'
11+
import request from 'supertest'
12+
import { setup, resetDb, getApp } from '../helpers/setup.js'
13+
import { createRequire } from 'module'
14+
15+
const require = createRequire(import.meta.url)
16+
17+
setup()
18+
19+
beforeEach(async () => {
20+
await resetDb()
21+
// reset broadcast state between tests
22+
const mod = require('../../server/server.js')
23+
// access and clear currentBroadcast via the end endpoint
24+
// if a broadcast is live from a previous test, end it cleanly
25+
await request(getApp()).post('/broadcast/end')
26+
})
27+
28+
// ─────────────────────────────────────────────────────────────────────────────
29+
// GET /broadcast/status
30+
// ─────────────────────────────────────────────────────────────────────────────
31+
32+
describe('GET /broadcast/status', () => {
33+
it('returns live:false when no broadcast is active', async () => {
34+
const res = await request(getApp()).get('/broadcast/status')
35+
expect(res.status).toBe(200)
36+
expect(res.body.live).toBe(false)
37+
})
38+
39+
it('returns live:true with broadcast info when active', async () => {
40+
await request(getApp())
41+
.post('/broadcast/start')
42+
.send({ uploader: 'Alice', channel: 'general' })
43+
44+
const res = await request(getApp()).get('/broadcast/status')
45+
expect(res.status).toBe(200)
46+
expect(res.body.live).toBe(true)
47+
expect(res.body.uploader).toBe('Alice')
48+
expect(res.body.channel).toBe('general')
49+
expect(res.body.startedAt).toBeDefined()
50+
})
51+
})
52+
53+
// ─────────────────────────────────────────────────────────────────────────────
54+
// POST /broadcast/start
55+
// ─────────────────────────────────────────────────────────────────────────────
56+
57+
describe('POST /broadcast/start', () => {
58+
it('starts a broadcast and returns 200', async () => {
59+
const res = await request(getApp())
60+
.post('/broadcast/start')
61+
.send({ uploader: 'Alice', channel: 'general' })
62+
63+
expect(res.status).toBe(200)
64+
expect(res.body.ok).toBe(true)
65+
expect(res.body.uploader).toBe('Alice')
66+
expect(res.body.channel).toBe('general')
67+
})
68+
69+
it('returns 409 if a broadcast is already in progress', async () => {
70+
await request(getApp())
71+
.post('/broadcast/start')
72+
.send({ uploader: 'Alice', channel: 'general' })
73+
74+
const res = await request(getApp())
75+
.post('/broadcast/start')
76+
.send({ uploader: 'Bob', channel: 'general' })
77+
78+
expect(res.status).toBe(409)
79+
expect(res.body.error).toBeDefined()
80+
expect(res.body.uploader).toBe('Alice')
81+
})
82+
83+
it('returns 400 when uploader is missing', async () => {
84+
const res = await request(getApp())
85+
.post('/broadcast/start')
86+
.send({ channel: 'general' })
87+
88+
expect(res.status).toBe(400)
89+
})
90+
91+
it('returns 400 when channel is missing', async () => {
92+
const res = await request(getApp())
93+
.post('/broadcast/start')
94+
.send({ uploader: 'Alice' })
95+
96+
expect(res.status).toBe(400)
97+
})
98+
99+
it('status reflects live broadcast after start', async () => {
100+
await request(getApp())
101+
.post('/broadcast/start')
102+
.send({ uploader: 'Alice', channel: 'projects' })
103+
104+
const status = await request(getApp()).get('/broadcast/status')
105+
expect(status.body.live).toBe(true)
106+
expect(status.body.channel).toBe('projects')
107+
})
108+
})
109+
110+
// ─────────────────────────────────────────────────────────────────────────────
111+
// POST /broadcast/end
112+
// ─────────────────────────────────────────────────────────────────────────────
113+
114+
describe('POST /broadcast/end', () => {
115+
it('ends an active broadcast and returns 200', async () => {
116+
await request(getApp())
117+
.post('/broadcast/start')
118+
.send({ uploader: 'Alice', channel: 'general' })
119+
120+
const res = await request(getApp()).post('/broadcast/end')
121+
expect(res.status).toBe(200)
122+
expect(res.body.ok).toBe(true)
123+
})
124+
125+
it('status is live:false after broadcast ends', async () => {
126+
await request(getApp())
127+
.post('/broadcast/start')
128+
.send({ uploader: 'Alice', channel: 'general' })
129+
130+
await request(getApp()).post('/broadcast/end')
131+
132+
const status = await request(getApp()).get('/broadcast/status')
133+
expect(status.body.live).toBe(false)
134+
})
135+
136+
it('returns 400 when no broadcast is in progress', async () => {
137+
const res = await request(getApp()).post('/broadcast/end')
138+
expect(res.status).toBe(400)
139+
})
140+
141+
it('a new broadcast can start after the previous one ends', async () => {
142+
await request(getApp())
143+
.post('/broadcast/start')
144+
.send({ uploader: 'Alice', channel: 'general' })
145+
146+
await request(getApp()).post('/broadcast/end')
147+
148+
const res = await request(getApp())
149+
.post('/broadcast/start')
150+
.send({ uploader: 'Bob', channel: 'general' })
151+
152+
expect(res.status).toBe(200)
153+
expect(res.body.uploader).toBe('Bob')
154+
})
155+
})

0 commit comments

Comments
 (0)