- Next.js 16 App Router site with the ANOUMA design system - Payload CMS (PostgreSQL) for offers, events, posts and page content - WebRTC video-call system with custom signaling server - SMTP email reminders and booking-request notifications - Customer accounts, calendar-based availability, and booking workflow
91 lines
2.8 KiB
TypeScript
91 lines
2.8 KiB
TypeScript
import type { IncomingMessage } from "node:http";
|
|
import type { WebSocket, WebSocketServer } from "ws";
|
|
import { verifyMeetingToken } from "./token";
|
|
import {
|
|
broadcast,
|
|
getParticipant,
|
|
isKicked,
|
|
joinRoom,
|
|
kickFromRoom,
|
|
leaveRoom,
|
|
type Participant,
|
|
} from "./rooms";
|
|
import type { ClientToServerMessage, ParticipantSummary } from "./protocol";
|
|
|
|
function participantSummary(p: Participant): ParticipantSummary {
|
|
return { participantId: p.participantId, name: p.name, role: p.role };
|
|
}
|
|
|
|
export function attachSignalingServer(wss: WebSocketServer) {
|
|
wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
|
|
const url = new URL(req.url ?? "", "http://internal");
|
|
const token = url.searchParams.get("token") ?? "";
|
|
const payload = verifyMeetingToken(token);
|
|
|
|
if (!payload) {
|
|
ws.close(4000, "invalid-token");
|
|
return;
|
|
}
|
|
if (isKicked(payload.eventSlug, payload.participantId)) {
|
|
ws.close(4001, "removed-by-host");
|
|
return;
|
|
}
|
|
|
|
const self: Participant = {
|
|
ws,
|
|
participantId: payload.participantId,
|
|
name: payload.name,
|
|
role: payload.role,
|
|
};
|
|
|
|
const existing = joinRoom(payload.eventSlug, self);
|
|
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "welcome",
|
|
participantId: self.participantId,
|
|
role: self.role,
|
|
participants: existing.map(participantSummary),
|
|
}),
|
|
);
|
|
|
|
broadcast(payload.eventSlug, { type: "peer-joined", ...participantSummary(self) }, {
|
|
exclude: self.participantId,
|
|
});
|
|
|
|
ws.on("message", (raw) => {
|
|
let message: ClientToServerMessage;
|
|
try {
|
|
message = JSON.parse(raw.toString());
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
if (message.type === "offer" || message.type === "answer" || message.type === "ice-candidate") {
|
|
const target = getParticipant(payload.eventSlug, message.to);
|
|
if (target && target.ws.readyState === target.ws.OPEN) {
|
|
target.ws.send(
|
|
JSON.stringify({ type: message.type, from: self.participantId, payload: message.payload }),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (message.type === "kick") {
|
|
if (self.role !== "host") return; // server-side authority, not client-claimed
|
|
const target = getParticipant(payload.eventSlug, message.targetParticipantId);
|
|
if (!target) return;
|
|
kickFromRoom(payload.eventSlug, target.participantId);
|
|
target.ws.send(JSON.stringify({ type: "kicked" }));
|
|
target.ws.close(4001, "removed-by-host");
|
|
broadcast(payload.eventSlug, { type: "peer-left", participantId: target.participantId });
|
|
}
|
|
});
|
|
|
|
ws.on("close", () => {
|
|
leaveRoom(payload.eventSlug, self.participantId);
|
|
broadcast(payload.eventSlug, { type: "peer-left", participantId: self.participantId });
|
|
});
|
|
});
|
|
}
|