diff --git a/.gitignore b/.gitignore index 4c49bd7..2d7ec5c 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .env +node_modules/ diff --git a/docker-compose.yml b/docker-compose.yml index e6ace87..fdb95a4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,3 +22,26 @@ services: options: max-size: "10m" max-file: "3" + sova-web: + build: + context: ./web + container_name: sova-web + restart: unless-stopped + ports: + - "3000:3000" + environment: + - SOVA_HOST=sova-server + - SOVA_PORT=8080 + - SOVA_PASSWORD=${SOVA_PASSWORD} + depends_on: + - sova-server + deploy: + resources: + limits: + memory: 128M + cpus: '0.25' + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..4c68796 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,7 @@ +FROM node:22-slim +WORKDIR /app +COPY package.json . +RUN npm install --production +COPY server.js index.html . +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..2336aa1 --- /dev/null +++ b/web/index.html @@ -0,0 +1,110 @@ + + + + + + Sova Jam + + + + + + + +
+
+

Sova Jam

+

live coding session

+
+ +
+
+ Server + connecting... +
+
+ Tempo + -- +
+
+ Playback + -- +
+
+ +
+
+ Musicians + 0 +
+ +
+ +
+

How to join

+
    +
  1. Download Sova
  2. +
  3. Connect to this server's address on port 8080
  4. +
  5. Enter the session password
  6. +
  7. Start coding music
  8. +
+
+ + +
+ + + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..b01455b --- /dev/null +++ b/web/package.json @@ -0,0 +1,10 @@ +{ + "name": "sova-web", + "private": true, + "type": "module", + "dependencies": { + "@msgpack/msgpack": "^3.0.0", + "crc-32": "^1.2.2", + "ws": "^8.18.0" + } +} diff --git a/web/server.js b/web/server.js new file mode 100644 index 0000000..e2137f8 --- /dev/null +++ b/web/server.js @@ -0,0 +1,168 @@ +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, +}; + +// --- 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 { + // skip 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(); +});