256 lines
7.5 KiB
JavaScript
256 lines
7.5 KiB
JavaScript
import http from "node:http";
|
|
import { readFile } from "node:fs/promises";
|
|
import { extname, join, normalize } from "node:path";
|
|
import { randomUUID } from "node:crypto";
|
|
|
|
const port = Number(process.env.PORT || 3000);
|
|
const host = process.env.HOST || "127.0.0.1";
|
|
const publicDir = join(process.cwd(), "public");
|
|
const rooms = new Map();
|
|
const watchStates = new Map();
|
|
const presentationStates = new Map();
|
|
const defaultRoomId = "main";
|
|
|
|
const mimeTypes = {
|
|
".html": "text/html; charset=utf-8",
|
|
".css": "text/css; charset=utf-8",
|
|
".js": "text/javascript; charset=utf-8",
|
|
".json": "application/json; charset=utf-8",
|
|
".svg": "image/svg+xml"
|
|
};
|
|
|
|
function sendJson(res, status, body) {
|
|
res.writeHead(status, {
|
|
"content-type": "application/json; charset=utf-8",
|
|
"cache-control": "no-store"
|
|
});
|
|
res.end(JSON.stringify(body));
|
|
}
|
|
|
|
function getRoom(roomId) {
|
|
if (!rooms.has(roomId)) {
|
|
rooms.set(roomId, new Map());
|
|
}
|
|
return rooms.get(roomId);
|
|
}
|
|
|
|
function normalizeName(name) {
|
|
const normalized = String(name || "").trim().replace(/\s+/g, " ");
|
|
return normalized.slice(0, 48) || "Guest";
|
|
}
|
|
|
|
function normalizePlaylist(playlist) {
|
|
if (!Array.isArray(playlist)) return [];
|
|
|
|
const seen = new Set();
|
|
return playlist
|
|
.map((item) => ({
|
|
videoId: String(item?.videoId || "").trim(),
|
|
title: String(item?.title || "").trim(),
|
|
position: Math.max(0, Number(item?.position) || 0),
|
|
duration: Math.max(0, Number(item?.duration) || 0)
|
|
}))
|
|
.filter((item) => {
|
|
if (!/^[a-zA-Z0-9_-]{11}$/.test(item.videoId) || seen.has(item.videoId)) {
|
|
return false;
|
|
}
|
|
seen.add(item.videoId);
|
|
item.title ||= `YouTube video ${item.videoId}`;
|
|
return true;
|
|
})
|
|
.slice(0, 50);
|
|
}
|
|
|
|
function broadcast(roomId, senderId, event) {
|
|
const room = rooms.get(roomId);
|
|
if (!room) return;
|
|
|
|
for (const [peerId, peer] of room) {
|
|
if (peerId === senderId) continue;
|
|
peer.res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
}
|
|
}
|
|
|
|
function removePeer(roomId, peerId) {
|
|
const room = rooms.get(roomId);
|
|
if (!room) return;
|
|
|
|
room.delete(peerId);
|
|
broadcast(roomId, peerId, { type: "peer-left", peerId });
|
|
if (presentationStates.get(roomId)?.peerId === peerId) {
|
|
presentationStates.delete(roomId);
|
|
broadcast(roomId, peerId, { type: "presentation-state", from: peerId, sharing: false });
|
|
}
|
|
|
|
if (room.size === 0) {
|
|
rooms.delete(roomId);
|
|
watchStates.delete(roomId);
|
|
presentationStates.delete(roomId);
|
|
}
|
|
}
|
|
|
|
async function parseJson(req) {
|
|
const chunks = [];
|
|
for await (const chunk of req) chunks.push(chunk);
|
|
const raw = Buffer.concat(chunks).toString("utf8");
|
|
return raw ? JSON.parse(raw) : {};
|
|
}
|
|
|
|
async function serveStatic(req, res) {
|
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
const requestedPath = url.pathname === "/" ? "/index.html" : url.pathname;
|
|
const safePath = normalize(decodeURIComponent(requestedPath)).replace(/^(\.\.[/\\])+/, "");
|
|
const filePath = join(publicDir, safePath);
|
|
|
|
if (!filePath.startsWith(publicDir)) {
|
|
res.writeHead(403);
|
|
res.end("Forbidden");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const data = await readFile(filePath);
|
|
res.writeHead(200, {
|
|
"content-type": mimeTypes[extname(filePath)] || "application/octet-stream",
|
|
"cache-control": "no-store"
|
|
});
|
|
res.end(req.method === "HEAD" ? undefined : data);
|
|
} catch {
|
|
res.writeHead(404);
|
|
res.end("Not found");
|
|
}
|
|
}
|
|
|
|
const server = http.createServer(async (req, res) => {
|
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
|
|
try {
|
|
if (req.method === "GET" && url.pathname.startsWith("/events/")) {
|
|
const roomId = url.pathname.split("/").at(-1) || defaultRoomId;
|
|
const displayName = normalizeName(url.searchParams.get("name"));
|
|
const peerId = randomUUID();
|
|
const room = getRoom(roomId);
|
|
const existingPeers = [...room].map(([id, peer]) => ({ id, name: peer.name }));
|
|
|
|
res.writeHead(200, {
|
|
"content-type": "text/event-stream; charset=utf-8",
|
|
"cache-control": "no-store, no-transform",
|
|
connection: "keep-alive",
|
|
"x-accel-buffering": "no"
|
|
});
|
|
|
|
room.set(peerId, { res, name: displayName });
|
|
res.write(
|
|
`data: ${JSON.stringify({
|
|
type: "welcome",
|
|
peerId,
|
|
name: displayName,
|
|
peers: existingPeers,
|
|
watchState: watchStates.get(roomId) || null,
|
|
presentationState: presentationStates.get(roomId) || null
|
|
})}\n\n`
|
|
);
|
|
broadcast(roomId, peerId, { type: "peer-joined", peerId, name: displayName });
|
|
|
|
const heartbeat = setInterval(() => {
|
|
res.write(": heartbeat\n\n");
|
|
}, 15000);
|
|
|
|
req.on("close", () => {
|
|
clearInterval(heartbeat);
|
|
removePeer(roomId, peerId);
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (req.method === "POST" && url.pathname.startsWith("/room-event/")) {
|
|
const roomId = url.pathname.split("/").at(-1) || defaultRoomId;
|
|
const payload = await parseJson(req);
|
|
|
|
if (!payload.from || payload.type !== "presentation-state") {
|
|
sendJson(res, 400, { error: "Room events require from and type=presentation-state." });
|
|
return;
|
|
}
|
|
|
|
const event = {
|
|
type: "presentation-state",
|
|
from: payload.from,
|
|
name: normalizeName(payload.name),
|
|
sharing: Boolean(payload.sharing),
|
|
updatedAt: Date.now()
|
|
};
|
|
|
|
if (event.sharing) {
|
|
presentationStates.set(roomId, { ...event, peerId: payload.from });
|
|
} else if (presentationStates.get(roomId)?.peerId === payload.from) {
|
|
presentationStates.delete(roomId);
|
|
}
|
|
|
|
broadcast(roomId, payload.from, event);
|
|
sendJson(res, 202, { ok: true });
|
|
return;
|
|
}
|
|
|
|
if (req.method === "POST" && url.pathname.startsWith("/signal/")) {
|
|
const roomId = url.pathname.split("/").at(-1);
|
|
const payload = await parseJson(req);
|
|
|
|
if (!payload.from || !payload.to || !payload.type) {
|
|
sendJson(res, 400, { error: "Signal messages require from, to, and type." });
|
|
return;
|
|
}
|
|
|
|
const room = rooms.get(roomId);
|
|
const target = room?.get(payload.to);
|
|
if (target) {
|
|
target.res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
|
}
|
|
|
|
sendJson(res, 202, { ok: true });
|
|
return;
|
|
}
|
|
|
|
if (req.method === "POST" && url.pathname.startsWith("/watch/")) {
|
|
const roomId = url.pathname.split("/").at(-1) || defaultRoomId;
|
|
const payload = await parseJson(req);
|
|
|
|
if (!payload.from || payload.type !== "watch-state") {
|
|
sendJson(res, 400, { error: "Watch messages require from and type=watch-state." });
|
|
return;
|
|
}
|
|
|
|
const playlist = normalizePlaylist(payload.playlist);
|
|
const videoId = String(payload.videoId || "").trim();
|
|
const watchState = {
|
|
type: "watch-state",
|
|
from: payload.from,
|
|
name: normalizeName(payload.name),
|
|
videoId: playlist.some((item) => item.videoId === videoId) ? videoId : "",
|
|
playlist,
|
|
position: Number(payload.position) || 0,
|
|
playing: Boolean(payload.playing),
|
|
updatedAt: Number(payload.updatedAt) || Date.now()
|
|
};
|
|
|
|
watchStates.set(roomId, watchState);
|
|
broadcast(roomId, payload.from, watchState);
|
|
sendJson(res, 202, { ok: true });
|
|
return;
|
|
}
|
|
|
|
if (req.method === "GET" || req.method === "HEAD") {
|
|
await serveStatic(req, res);
|
|
return;
|
|
}
|
|
|
|
res.writeHead(405);
|
|
res.end("Method not allowed");
|
|
} catch (error) {
|
|
sendJson(res, 500, { error: error.message });
|
|
}
|
|
});
|
|
|
|
server.listen(port, host, () => {
|
|
console.log(`kkkmeet is running at http://${host}:${port}`);
|
|
});
|