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
+
+
+ - No musicians connected
+
+
+
+
+ How to join
+
+ - Download Sova
+ - Connect to
this server's address on port 8080
+ - Enter the session password
+ - Start coding music
+
+
+
+
+
+
+
+
+
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();
+});