// server/index.js — generic KV document store over HTTP + WebSocket. // firebase mirror: doc-tree model. Thin REST, path-based WS push. // Adapter (src/storage/ws.js) = passthrough, no shape translation. 'use strict'; const express = require('express'); const cors = require('cors'); const http = require('http'); const crypto = require('crypto'); const { WebSocketServer } = require('ws'); const { openDb, makeStore } = require('./db'); function createServer({ dbPath, port, corsOrigin } = {}) { const db = openDb(dbPath || './data/tracker.sqlite'); const app = express(); app.use(cors({ origin: corsOrigin || '*' })); app.use(express.json({ limit: '1mb' })); // WS subscribers: path -> Set. // Subscribers register a path (doc or collection). On change, notify: // - doc subscribers at the changed path // - collection subscribers at the changed doc's parent path const docSubscribers = new Map(); // path -> Set const collSubscribers = new Map(); // collPath -> Set function addSub(map, key, ws) { if (!map.has(key)) map.set(key, new Set()); map.get(key).add(ws); } function removeSub(map, key, ws) { const set = map.get(key); if (set) { set.delete(ws); if (set.size === 0) map.delete(key); } } function dropWs(ws) { for (const [key, set] of docSubscribers) { set.delete(ws); if (set.size === 0) docSubscribers.delete(key); } for (const [key, set] of collSubscribers) { set.delete(ws); if (set.size === 0) collSubscribers.delete(key); } } function broadcast(change) { const payload = JSON.stringify({ type: 'change', change }); // doc subscriber at exact path const docSet = docSubscribers.get(change.path); if (docSet) [...docSet].forEach(ws => ws.readyState === ws.OPEN && ws.send(payload)); // collection subscribers at parent path (collection contains this doc) if (change.parent) { const collSet = collSubscribers.get(change.parent); if (collSet) [...collSet].forEach(ws => ws.readyState === ws.OPEN && ws.send(payload)); } } const store = makeStore(db, broadcast); // --- generic REST --- app.get('/health', (req, res) => res.json({ ok: true })); // GET /api/doc?path=campaigns/abc/encounters/xyz app.get('/api/doc', (req, res) => { const { path: p } = req.query; if (!p) return res.status(400).json({ error: 'path required' }); res.json({ path: p, data: store.getDoc(p) }); }); // GET /api/collection?path=campaigns/abc/encounters app.get('/api/collection', (req, res) => { const { path: p } = req.query; if (!p) return res.status(400).json({ error: 'path required' }); res.json(store.getCollection(p)); }); // PUT /api/doc body: { path, data } (replace) app.put('/api/doc', (req, res) => { const { path: p, data } = req.body || {}; if (!p) return res.status(400).json({ error: 'path required' }); res.json({ path: p, data: store.setDoc(p, data) }); }); // PATCH /api/doc body: { path, patch } (shallow merge, create-on-miss) app.patch('/api/doc', (req, res) => { const { path: p, patch } = req.body || {}; if (!p) return res.status(400).json({ error: 'path required' }); res.json({ path: p, data: store.updateDoc(p, patch) }); }); // DELETE /api/doc?path=... app.delete('/api/doc', (req, res) => { const { path: p } = req.query; if (!p) return res.status(400).json({ error: 'path required' }); store.deleteDoc(p); res.json({ ok: true }); }); // POST /api/collection body: { path, data } (addDoc: auto-id under collection) app.post('/api/collection', (req, res) => { const { path: collPath, data } = req.body || {}; if (!collPath) return res.status(400).json({ error: 'path required' }); const id = crypto.randomUUID(); const docPath = `${collPath}/${id}`; res.json({ id, path: docPath, data: store.setDoc(docPath, data) }); }); // POST /api/batch body: { ops: [{type:'set'|'update'|'delete', path, data?}] } app.post('/api/batch', (req, res) => { const { ops } = req.body || {}; if (!Array.isArray(ops)) return res.status(400).json({ error: 'ops array required' }); store.batchWrite(ops); res.json({ ok: true }); }); // --- WebSocket: subscribe by path --- const server = http.createServer(app); const wss = new WebSocketServer({ server, path: '/ws' }); wss.on('connection', (ws) => { ws.on('message', (raw) => { let msg; try { msg = JSON.parse(raw.toString()); } catch { ws.send(JSON.stringify({ type: 'error', error: 'bad json' })); return; } if (msg.type === 'subscribe' && msg.path) { if (msg.kind === 'collection') addSub(collSubscribers, msg.path, ws); else addSub(docSubscribers, msg.path, ws); ws.send(JSON.stringify({ type: 'subscribed', path: msg.path, kind: msg.kind || 'doc' })); } else if (msg.type === 'unsubscribe' && msg.path) { if (msg.kind === 'collection') removeSub(collSubscribers, msg.path, ws); else removeSub(docSubscribers, msg.path, ws); } }); ws.on('close', () => dropWs(ws)); ws.on('error', () => {}); }); return { app, server, wss, store, db, close(done) { wss.close(); server.close(() => { db.close(); if (done) done(); }); }, }; } // Boot standalone if run directly. if (require.main === module) { const port = parseInt(process.env.PORT, 10) || 4001; const dbPath = process.env.DB_PATH || './data/tracker.sqlite'; const { server } = createServer({ dbPath, port }); server.listen(port, () => { console.log(`ttrpg backend listening on :${port} (db: ${dbPath})`); }); } module.exports = { createServer };