Files
ttrpg-initiative-tracker/server/index.js
T
david raistrick 835a4663f7 Docker build cache: .dockerignore, cache mount, layer reorder, restart script
- .dockerignore: exclude node_modules/.git/tmp/data/logs
- Dockerfile: BuildKit cache mount for npm, --omit=dev at install, drop
  npm prune step
- Reorder layers: shared/src/frontend copy before build, server copy +
  better-sqlite3 rebuild after. Server/shared changes no longer invalidate
  frontend build layer unless shared (FE dep) changes
- Remove duplicate shared COPY in runtime stage (bundled in FE build)
- docker/restart.sh: stop + rebuild + start in one script
2026-07-06 23:22:19 -04:00

207 lines
7.9 KiB
JavaScript

// server/index.js — generic KV document store over HTTP + WebSocket.
// firebase mirror: doc-tree model. Thin REST, path-based WS push.
// Adapter (src/storage/server.js) = passthrough, no shape translation.
// TEST: cache layer rebuild check.
'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, allowDevEndpoints = false } = {}) {
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<ws>.
// 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<ws>
const collSubscribers = new Map(); // collPath -> Set<ws>
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, whereField, whereOp, whereValue, orderBy, dir, limit, offset } = req.query;
if (!p) return res.status(400).json({ error: 'path required' });
const opts = {};
if (whereField) opts.where = { field: whereField, op: whereOp || '==', value: whereValue };
if (orderBy) opts.orderBy = orderBy;
if (dir) opts.dir = dir;
if (limit) opts.limit = limit;
if (offset) opts.offset = offset;
res.json(store.getCollection(p, opts));
});
app.get('/api/collection/count', (req, res) => {
const { path: p } = req.query;
if (!p) return res.status(400).json({ error: 'path required' });
res.json({ count: store.countCollection(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 });
});
// DELETE /api/collection?path=...&whereField=...&whereValue=...
// Bulk delete whole collection or filtered. No fetch. SQL DELETE.
// DEV ONLY: requires ALLOW_DEV_ENDPOINTS=1 env on server.
app.delete('/api/collection', (req, res) => {
if (!allowDevEndpoints && process.env.ALLOW_DEV_ENDPOINTS !== '1') {
return res.status(403).json({ error: 'bulk delete disabled (dev only)' });
}
const { path: p, whereField, whereOp, whereValue } = req.query;
if (!p) return res.status(400).json({ error: 'path required' });
const opts = {};
if (whereField) opts.where = { field: whereField, op: whereOp || '==', value: whereValue };
const deleted = store.deleteCollection(p, opts);
res.json({ ok: true, deleted });
});
// 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 });
});
// POST /api/undo body: { logPath, undo } where undo={encounterPath, updates, redo}
// Transactional: apply updates + flip undone in one SQLite tx.
// If `redo` true, applies redo patch instead + flips undone:false.
app.post('/api/undo', (req, res) => {
const { logPath, undo } = req.body || {};
if (!logPath || !undo || !undo.encounterPath) {
return res.status(400).json({ error: 'logPath + undo.encounterPath required' });
}
if (!store.transactionalUndo) {
return res.status(501).json({ error: 'transactionalUndo not supported by store' });
}
try {
const isRedo = !!req.body.redo;
const updates = isRedo ? (undo.redo || undo.updates) : undo.updates;
const result = store.transactionalUndo({
logPath,
encounterPath: undo.encounterPath,
updates,
undoneFlag: !isRedo,
});
res.json({ ok: true, ...result });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// --- 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.clients.forEach(c => { try { c.terminate(); } catch {} });
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 };