Files
sova-jam/web/server.js
2026-03-21 09:26:42 +00:00

188 lines
4.8 KiB
JavaScript

import { createServer } from "node:http";
import { readFileSync } from "node:fs";
import { Socket } from "node:net";
import { encode, decode } from "@msgpack/msgpack";
import CRC32 from "crc-32";
import { WebSocketServer } from "ws";
const SOVA_HOST = process.env.SOVA_HOST || "localhost";
const SOVA_PORT = parseInt(process.env.SOVA_PORT || "8080");
const SOVA_PASSWORD = process.env.SOVA_PASSWORD || null;
const WEB_PORT = parseInt(process.env.WEB_PORT || "3000");
const PROTOCOL_VERSION = 0x02;
const indexHtml = readFileSync(new URL("./index.html", import.meta.url));
let state = {
peers: [],
tempo: 120,
isPlaying: false,
connected: false,
};
// --- Helpers ---
/** Recursively convert Map objects (from useMap decode) to plain objects */
function mapToObj(val) {
if (val instanceof Map) {
const obj = {};
for (const [k, v] of val) {
if (typeof k === "string" || typeof k === "number") obj[k] = mapToObj(v);
}
return obj;
}
if (Array.isArray(val)) return val.map(mapToObj);
return val;
}
// --- Wire protocol ---
function buildFrame(msg) {
const payload = encode(msg);
const crc = CRC32.buf(payload) >>> 0;
const len = payload.length;
const frame = Buffer.alloc(8 + len);
frame[0] = PROTOCOL_VERSION;
frame[1] = (len >> 16) & 0xff;
frame[2] = (len >> 8) & 0xff;
frame[3] = len & 0xff;
frame.writeUInt32BE(crc, 4);
Buffer.from(payload).copy(frame, 8);
return frame;
}
function parseFrames(buffer) {
const messages = [];
let offset = 0;
while (offset + 8 <= buffer.length) {
if (buffer[offset] !== PROTOCOL_VERSION) {
offset++;
continue;
}
const len = (buffer[offset + 1] << 16) | (buffer[offset + 2] << 8) | buffer[offset + 3];
if (len > 10 * 1024 * 1024) {
offset++;
continue;
}
if (offset + 8 + len > buffer.length) break;
const payload = buffer.subarray(offset + 8, offset + 8 + len);
const expectedCrc = buffer.readUInt32BE(offset + 4);
const actualCrc = CRC32.buf(payload) >>> 0;
if (expectedCrc !== actualCrc) {
offset++;
continue;
}
try {
messages.push(decode(payload));
} catch {
try {
messages.push(mapToObj(decode(payload, { useMap: true })));
} catch {
// skip truly malformed msgpack
}
}
offset += 8 + len;
}
return { messages, remaining: buffer.subarray(offset) };
}
// --- TCP client to sova-server ---
let tcp = null;
let reconnectTimer = null;
function connectToSova() {
if (tcp) return;
console.log(`Connecting to sova-server at ${SOVA_HOST}:${SOVA_PORT}`);
const sock = new Socket();
let recvBuf = Buffer.alloc(0);
sock.connect(SOVA_PORT, SOVA_HOST, () => {
console.log("Connected to sova-server");
const handshake = { SetName: { name: "Web Monitor", password: SOVA_PASSWORD } };
sock.write(buildFrame(handshake));
});
sock.on("data", (chunk) => {
recvBuf = Buffer.concat([recvBuf, chunk]);
const { messages, remaining } = parseFrames(recvBuf);
recvBuf = remaining;
for (const msg of messages) handleMessage(msg);
});
sock.on("close", () => {
console.log("Disconnected from sova-server");
tcp = null;
state.connected = false;
broadcast();
scheduleReconnect();
});
sock.on("error", (err) => {
console.error("TCP error:", err.message);
sock.destroy();
});
tcp = sock;
}
function scheduleReconnect() {
if (reconnectTimer) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connectToSova();
}, 3000);
}
function handleMessage(msg) {
if (msg.Hello) {
state.connected = true;
state.peers = msg.Hello.peers || [];
state.isPlaying = msg.Hello.is_playing || false;
if (msg.Hello.link_state) state.tempo = msg.Hello.link_state[0];
broadcast();
} else if (msg.PeersUpdated) {
state.peers = msg.PeersUpdated;
broadcast();
} else if (msg.PlaybackStateChanged != null) {
state.isPlaying = !!msg.PlaybackStateChanged;
broadcast();
} else if (msg.ClockState) {
state.tempo = msg.ClockState[0];
broadcast();
} else if (msg.ConnectionRefused) {
console.error("Connection refused:", msg.ConnectionRefused);
}
}
// --- HTTP + WebSocket ---
const server = createServer((req, res) => {
if (req.url === "/" || req.url === "/index.html") {
res.writeHead(200, { "Content-Type": "text/html" });
res.end(indexHtml);
} else {
res.writeHead(404);
res.end();
}
});
const wss = new WebSocketServer({ server });
const clients = new Set();
wss.on("connection", (ws) => {
clients.add(ws);
ws.send(JSON.stringify(state));
ws.on("close", () => clients.delete(ws));
});
function broadcast() {
const data = JSON.stringify(state);
for (const ws of clients) ws.send(data);
}
server.listen(WEB_PORT, () => {
console.log(`Web server listening on port ${WEB_PORT}`);
connectToSova();
});