Rework backend #1

Merged
robert merged 86 commits from rework-backend into main 2026-07-01 19:29:34 -04:00
Showing only changes of commit e743d40e8d - Show all commits
+61 -22
View File
@@ -3,12 +3,26 @@
'use strict'; 'use strict';
const { WebSocket } = require('ws'); // Use native browser WebSocket when available (production). Fallback to the
// `ws` npm package in Node/jest where global WebSocket is absent.
let WebSocketImpl;
if (typeof WebSocket !== 'undefined') {
WebSocketImpl = WebSocket;
} else {
// require inside else so webpack ignores it in browser bundle
WebSocketImpl = require('ws').WebSocket;
}
function createWsStorage({ baseUrl, wsUrl } = {}) { function createWsStorage({ baseUrl, wsUrl } = {}) {
const API = (baseUrl || 'http://127.0.0.1:4001').replace(/\/$/, ''); const API = (baseUrl || 'http://127.0.0.1:4001').replace(/\/$/, '');
const WS = wsUrl || (API.replace(/^http/, 'ws') + '/ws'); const WS = wsUrl || (API.replace(/^http/, 'ws') + '/ws');
// App.js passes firebase-prefixed paths: artifacts/{APP_ID}/public/data/campaigns/...
// Backend uses canonical: campaigns/... Strip the prefix so all matchers work.
function norm(p) {
return p.replace(/^[\s\S]*\/public\/data\//, '');
}
const docSubs = new Map(); // path -> Set<cb> const docSubs = new Map(); // path -> Set<cb>
const collSubs = new Map(); // collPath -> Set<cb> const collSubs = new Map(); // collPath -> Set<cb>
let ws = null; let ws = null;
@@ -18,14 +32,28 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
function ensureWs() { function ensureWs() {
if (wsReady) return wsReady; if (wsReady) return wsReady;
wsReady = new Promise((resolve, reject) => { wsReady = new Promise((resolve, reject) => {
ws = new WebSocket(WS); ws = new WebSocketImpl(WS);
ws.on('open', () => resolve(ws)); // addEventListener works on both browser WebSocket and Node ws pkg.
ws.on('error', (err) => { wsReady = null; reject(err); }); const onOpen = () => resolve(ws);
ws.on('close', () => { wsReady = null; }); const onError = (err) => { wsReady = null; reject(err instanceof Event ? new Error('ws error') : err); };
ws.on('message', (raw) => { const onClose = () => { wsReady = null; };
let msg; try { msg = JSON.parse(raw.toString()); } catch { return; } const onMessage = (ev) => {
const raw = typeof ev === 'string' ? ev : (ev.data !== undefined ? ev.data : ev);
let msg; try { msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString()); } catch { return; }
handleMessage(msg); handleMessage(msg);
}); };
// browser-style property handlers
ws.onopen = onOpen;
ws.onerror = onError;
ws.onclose = onClose;
ws.onmessage = onMessage;
// Node ws-style addEventListener fallback (noop in browser if absent)
if (typeof ws.addEventListener === 'function') {
ws.addEventListener('open', onOpen);
ws.addEventListener('error', onError);
ws.addEventListener('close', onClose);
ws.addEventListener('message', onMessage);
}
}); });
return wsReady; return wsReady;
} }
@@ -34,14 +62,16 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
async function handleMessage(msg) { async function handleMessage(msg) {
if (msg.type !== 'change' || !msg.change) return; if (msg.type !== 'change' || !msg.change) return;
const c = msg.change; const c = msg.change;
// Notify doc subscribers whose path we cached. // Notify doc subscribers whose normalized path we cached.
for (const [path, cbs] of docSubs) { for (const [rawPath, cbs] of docSubs) {
const path = norm(rawPath);
if (pathMatchesChange(path, c)) { if (pathMatchesChange(path, c)) {
const doc = await storage.getDoc(path); const doc = await storage.getDoc(path);
cbs.forEach(cb => cb(doc)); cbs.forEach(cb => cb(doc));
} }
} }
for (const [collPath, cbs] of collSubs) { for (const [rawCollPath, cbs] of collSubs) {
const collPath = norm(rawCollPath);
if (collMatchesChange(collPath, c)) { if (collMatchesChange(collPath, c)) {
const docs = await storage.getCollection(collPath); const docs = await storage.getCollection(collPath);
cbs.forEach(cb => cb(docs)); cbs.forEach(cb => cb(docs));
@@ -83,8 +113,8 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
const storage = { const storage = {
// --- reads --- // --- reads ---
async getDoc(path) { async getDoc(rawPath) {
// canonical paths used by App.js const path = norm(rawPath);
if (path === 'activeDisplay/status') { if (path === 'activeDisplay/status') {
const ad = await api('GET', '/api/activeDisplay'); const ad = await api('GET', '/api/activeDisplay');
return ad; return ad;
@@ -102,7 +132,8 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
return null; return null;
}, },
async getCollection(collPath) { async getCollection(rawCollPath) {
const collPath = norm(rawCollPath);
if (collPath === 'campaigns') return await api('GET', '/api/campaigns'); if (collPath === 'campaigns') return await api('GET', '/api/campaigns');
const m = collPath.match(/^campaigns\/([^/]+)\/encounters$/); const m = collPath.match(/^campaigns\/([^/]+)\/encounters$/);
if (m) return await api('GET', `/api/campaigns/${m[1]}/encounters`); if (m) return await api('GET', `/api/campaigns/${m[1]}/encounters`);
@@ -111,7 +142,8 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
}, },
// --- writes (translated to backend action endpoints) --- // --- writes (translated to backend action endpoints) ---
async setDoc(path, data) { async setDoc(rawPath, data) {
const path = norm(rawPath);
// activeDisplay merges // activeDisplay merges
if (path === 'activeDisplay/status') { if (path === 'activeDisplay/status') {
if ('activeCampaignId' in data || 'activeEncounterId' in data) { if ('activeCampaignId' in data || 'activeEncounterId' in data) {
@@ -135,7 +167,8 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
} }
}, },
async updateDoc(path, patch) { async updateDoc(rawPath, patch) {
const path = norm(rawPath);
const cm = path.match(/^campaigns\/([^/]+)$/); const cm = path.match(/^campaigns\/([^/]+)$/);
if (cm) { if (cm) {
if (Array.isArray(patch.players)) { if (Array.isArray(patch.players)) {
@@ -159,14 +192,16 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
} }
}, },
async deleteDoc(path) { async deleteDoc(rawPath) {
const path = norm(rawPath);
const cm = path.match(/^campaigns\/([^/]+)$/); const cm = path.match(/^campaigns\/([^/]+)$/);
if (cm) { await api('DELETE', `/api/campaigns/${cm[1]}`); return; } if (cm) { await api('DELETE', `/api/campaigns/${cm[1]}`); return; }
const em = path.match(/^campaigns\/([^/]+)\/encounters\/([^/]+)$/); const em = path.match(/^campaigns\/([^/]+)\/encounters\/([^/]+)$/);
if (em) { await api('DELETE', `/api/campaigns/${em[1]}/encounters/${em[2]}`); return; } if (em) { await api('DELETE', `/api/campaigns/${em[1]}/encounters/${em[2]}`); return; }
}, },
async addDoc(collPath, data) { async addDoc(rawCollPath, data) {
const collPath = norm(rawCollPath);
if (collPath === 'logs') { if (collPath === 'logs') {
// backend auto-logs; direct insert not needed // backend auto-logs; direct insert not needed
return { id: 'auto', path: 'logs/auto' }; return { id: 'auto', path: 'logs/auto' };
@@ -181,7 +216,8 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
} }
}, },
subscribeDoc(path, cb) { subscribeDoc(rawPath, cb) {
const path = norm(rawPath);
ensureWs().then(() => { ensureWs().then(() => {
// subscribe to coarse change types that could affect this path // subscribe to coarse change types that could affect this path
const types = changeTypesForDocPath(path); const types = changeTypesForDocPath(path);
@@ -194,7 +230,8 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
return () => { docSubs.get(path)?.delete(cb); }; return () => { docSubs.get(path)?.delete(cb); };
}, },
subscribeCollection(collPath, cb) { subscribeCollection(rawCollPath, cb) {
const collPath = norm(rawCollPath);
ensureWs().then(() => { ensureWs().then(() => {
const types = changeTypesForCollPath(collPath); const types = changeTypesForCollPath(collPath);
types.forEach(t => ws.send(JSON.stringify({ type: 'subscribe', key: t }))); types.forEach(t => ws.send(JSON.stringify({ type: 'subscribe', key: t })));
@@ -213,13 +250,15 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
return storage; return storage;
} }
function changeTypesForDocPath(path) { function changeTypesForDocPath(rawPath) {
const path = rawPath.replace(/^[\s\S]*\/public\/data\//, '');
if (path === 'activeDisplay/status') return ['activeDisplay']; if (path === 'activeDisplay/status') return ['activeDisplay'];
if (path.match(/^campaigns\/[^/]+\/encounters\//)) return ['encounter', 'activeDisplay']; if (path.match(/^campaigns\/[^/]+\/encounters\//)) return ['encounter', 'activeDisplay'];
if (path.match(/^campaigns\//)) return ['campaign', 'campaigns']; if (path.match(/^campaigns\//)) return ['campaign', 'campaigns'];
return []; return [];
} }
function changeTypesForCollPath(collPath) { function changeTypesForCollPath(rawCollPath) {
const collPath = rawCollPath.replace(/^[\s\S]*\/public\/data\//, '');
if (collPath === 'campaigns') return ['campaigns']; if (collPath === 'campaigns') return ['campaigns'];
if (collPath.match(/^campaigns\/[^/]+\/encounters$/)) return ['encounters']; if (collPath.match(/^campaigns\/[^/]+\/encounters$/)) return ['encounters'];
if (collPath === 'logs') return ['logs']; if (collPath === 'logs') return ['logs'];