2026-06-28 17:51:39 -04:00
|
|
|
// ws.js — storage adapter talking to backend over REST + WebSocket.
|
|
|
|
|
// Implements same interface as memory.js. Tested by storage contract vs running server.
|
|
|
|
|
|
|
|
|
|
'use strict';
|
|
|
|
|
|
2026-06-29 12:36:16 -04:00
|
|
|
// 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;
|
|
|
|
|
}
|
2026-06-28 17:51:39 -04:00
|
|
|
|
|
|
|
|
function createWsStorage({ baseUrl, wsUrl } = {}) {
|
|
|
|
|
const API = (baseUrl || 'http://127.0.0.1:4001').replace(/\/$/, '');
|
|
|
|
|
const WS = wsUrl || (API.replace(/^http/, 'ws') + '/ws');
|
|
|
|
|
|
2026-06-29 12:36:16 -04:00
|
|
|
// 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\//, '');
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 17:51:39 -04:00
|
|
|
const docSubs = new Map(); // path -> Set<cb>
|
|
|
|
|
const collSubs = new Map(); // collPath -> Set<cb>
|
|
|
|
|
let ws = null;
|
|
|
|
|
let wsReady = null;
|
|
|
|
|
const pendingPaths = new Set();
|
|
|
|
|
|
|
|
|
|
function ensureWs() {
|
|
|
|
|
if (wsReady) return wsReady;
|
|
|
|
|
wsReady = new Promise((resolve, reject) => {
|
2026-06-29 12:36:16 -04:00
|
|
|
ws = new WebSocketImpl(WS);
|
|
|
|
|
// addEventListener works on both browser WebSocket and Node ws pkg.
|
|
|
|
|
const onOpen = () => resolve(ws);
|
|
|
|
|
const onError = (err) => { wsReady = null; reject(err instanceof Event ? new Error('ws error') : err); };
|
|
|
|
|
const onClose = () => { wsReady = null; };
|
|
|
|
|
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; }
|
2026-06-28 17:51:39 -04:00
|
|
|
handleMessage(msg);
|
2026-06-29 12:36:16 -04:00
|
|
|
};
|
|
|
|
|
// 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);
|
|
|
|
|
}
|
2026-06-28 17:51:39 -04:00
|
|
|
});
|
|
|
|
|
return wsReady;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Backend pushes change notices (coarse: type-based). We re-fetch affected paths.
|
|
|
|
|
async function handleMessage(msg) {
|
|
|
|
|
if (msg.type !== 'change' || !msg.change) return;
|
|
|
|
|
const c = msg.change;
|
2026-06-29 12:36:16 -04:00
|
|
|
// Notify doc subscribers whose normalized path we cached.
|
|
|
|
|
for (const [rawPath, cbs] of docSubs) {
|
|
|
|
|
const path = norm(rawPath);
|
2026-06-28 17:51:39 -04:00
|
|
|
if (pathMatchesChange(path, c)) {
|
|
|
|
|
const doc = await storage.getDoc(path);
|
|
|
|
|
cbs.forEach(cb => cb(doc));
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-29 12:36:16 -04:00
|
|
|
for (const [rawCollPath, cbs] of collSubs) {
|
|
|
|
|
const collPath = norm(rawCollPath);
|
2026-06-28 17:51:39 -04:00
|
|
|
if (collMatchesChange(collPath, c)) {
|
|
|
|
|
const docs = await storage.getCollection(collPath);
|
|
|
|
|
cbs.forEach(cb => cb(docs));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pathMatchesChange(path, c) {
|
|
|
|
|
// Naive: campaign doc path includes campaignId; encounter doc includes encounterId.
|
|
|
|
|
if (c.type === 'campaign' && c.campaignId && path === docPathForCampaign(c.campaignId)) return true;
|
|
|
|
|
if (c.type === 'encounter' && c.campaignId && c.encounterId && path === docPathForEncounter(c.campaignId, c.encounterId)) return true;
|
|
|
|
|
if (c.type === 'activeDisplay' && path === 'activeDisplay/status') return true;
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
function collMatchesChange(collPath, c) {
|
|
|
|
|
if (c.type === 'campaigns' && collPath === 'campaigns') return true;
|
|
|
|
|
if (c.type === 'encounters' && c.campaignId && collPath === `campaigns/${c.campaignId}/encounters`) return true;
|
|
|
|
|
if (c.type === 'logs' && collPath === 'logs') return true;
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Backend uses different shape (rows) than firebase docs. We adapt to doc model.
|
|
|
|
|
// To keep contract passing + match App.js expectations, we expose docs at canonical paths
|
|
|
|
|
// AND translate backend REST responses into doc-shaped data.
|
|
|
|
|
|
|
|
|
|
async function api(method, path, body) {
|
|
|
|
|
const res = await fetch(`${API}${path}`, {
|
|
|
|
|
method,
|
|
|
|
|
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
|
|
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
const t = await res.text().catch(() => '');
|
|
|
|
|
throw new Error(`API ${method} ${path} ${res.status}: ${t}`);
|
|
|
|
|
}
|
|
|
|
|
const text = await res.text();
|
|
|
|
|
return text ? JSON.parse(text) : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const storage = {
|
|
|
|
|
// --- reads ---
|
2026-06-29 12:36:16 -04:00
|
|
|
async getDoc(rawPath) {
|
|
|
|
|
const path = norm(rawPath);
|
2026-06-28 17:51:39 -04:00
|
|
|
if (path === 'activeDisplay/status') {
|
|
|
|
|
const ad = await api('GET', '/api/activeDisplay');
|
|
|
|
|
return ad;
|
|
|
|
|
}
|
|
|
|
|
const m = path.match(/^campaigns\/([^/]+)$/);
|
|
|
|
|
if (m) {
|
|
|
|
|
const c = await api('GET', `/api/campaigns/${m[1]}`);
|
|
|
|
|
return c || null;
|
|
|
|
|
}
|
|
|
|
|
const em = path.match(/^campaigns\/([^/]+)\/encounters\/([^/]+)$/);
|
|
|
|
|
if (em) {
|
|
|
|
|
const e = await api('GET', `/api/campaigns/${em[1]}/encounters/${em[2]}`);
|
|
|
|
|
return e || null;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-29 12:36:16 -04:00
|
|
|
async getCollection(rawCollPath) {
|
|
|
|
|
const collPath = norm(rawCollPath);
|
2026-06-28 17:51:39 -04:00
|
|
|
if (collPath === 'campaigns') return await api('GET', '/api/campaigns');
|
|
|
|
|
const m = collPath.match(/^campaigns\/([^/]+)\/encounters$/);
|
|
|
|
|
if (m) return await api('GET', `/api/campaigns/${m[1]}/encounters`);
|
|
|
|
|
if (collPath === 'logs') return await api('GET', '/api/logs');
|
|
|
|
|
return [];
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// --- writes (translated to backend action endpoints) ---
|
2026-06-29 12:36:16 -04:00
|
|
|
async setDoc(rawPath, data) {
|
|
|
|
|
const path = norm(rawPath);
|
2026-06-28 17:51:39 -04:00
|
|
|
// activeDisplay merges
|
|
|
|
|
if (path === 'activeDisplay/status') {
|
|
|
|
|
if ('activeCampaignId' in data || 'activeEncounterId' in data) {
|
|
|
|
|
await api('POST', `/api/campaigns/${data.activeCampaignId}/encounters/${data.activeEncounterId}/display`).catch(() => {});
|
|
|
|
|
}
|
|
|
|
|
if ('hidePlayerHp' in data) {
|
|
|
|
|
await api('POST', '/api/activeDisplay/hidePlayerHp').catch(() => {});
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const cm = path.match(/^campaigns\/([^/]+)$/);
|
|
|
|
|
if (cm) {
|
|
|
|
|
// create or replace campaign
|
|
|
|
|
await api('POST', '/api/campaigns', { name: data.name, backgroundUrl: data.playerDisplayBackgroundUrl, ownerId: data.ownerId });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const em = path.match(/^campaigns\/([^/]+)\/encounters\/([^/]+)$/);
|
|
|
|
|
if (em) {
|
|
|
|
|
await api('POST', `/api/campaigns/${em[1]}/encounters`, { name: data.name });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-29 12:36:16 -04:00
|
|
|
async updateDoc(rawPath, patch) {
|
|
|
|
|
const path = norm(rawPath);
|
2026-06-28 17:51:39 -04:00
|
|
|
const cm = path.match(/^campaigns\/([^/]+)$/);
|
|
|
|
|
if (cm) {
|
|
|
|
|
if (Array.isArray(patch.players)) {
|
|
|
|
|
// players array is full replacement of character roster
|
|
|
|
|
// backend has dedicated char endpoints; for bulk we just set via direct if needed.
|
|
|
|
|
// For now: no-op bulk (App.js uses add/update/delete char endpoints individually upstream)
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const em = path.match(/^campaigns\/([^/]+)\/encounters\/([^/]+)$/);
|
|
|
|
|
if (em) {
|
|
|
|
|
const [campaignId, encounterId] = [em[1], em[2]];
|
|
|
|
|
// participants array patch = full replace. Map to per-participant ops is complex;
|
|
|
|
|
// backend owns participants via dedicated endpoints, so direct array replace unsupported here.
|
|
|
|
|
// Most App.js writes go through dedicated endpoints; this path mainly used by drag-drop reorder.
|
|
|
|
|
if (patch.participants && patch.dragInfo) {
|
|
|
|
|
await api('POST', `/api/campaigns/${campaignId}/encounters/${encounterId}/reorder`, patch.dragInfo);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-29 12:36:16 -04:00
|
|
|
async deleteDoc(rawPath) {
|
|
|
|
|
const path = norm(rawPath);
|
2026-06-28 17:51:39 -04:00
|
|
|
const cm = path.match(/^campaigns\/([^/]+)$/);
|
|
|
|
|
if (cm) { await api('DELETE', `/api/campaigns/${cm[1]}`); return; }
|
|
|
|
|
const em = path.match(/^campaigns\/([^/]+)\/encounters\/([^/]+)$/);
|
|
|
|
|
if (em) { await api('DELETE', `/api/campaigns/${em[1]}/encounters/${em[2]}`); return; }
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-29 12:36:16 -04:00
|
|
|
async addDoc(rawCollPath, data) {
|
|
|
|
|
const collPath = norm(rawCollPath);
|
2026-06-28 17:51:39 -04:00
|
|
|
if (collPath === 'logs') {
|
|
|
|
|
// backend auto-logs; direct insert not needed
|
|
|
|
|
return { id: 'auto', path: 'logs/auto' };
|
|
|
|
|
}
|
|
|
|
|
return { id: 'unsupported', path: collPath + '/unsupported' };
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async batchWrite(ops) {
|
|
|
|
|
for (const op of ops) {
|
|
|
|
|
if (op.type === 'set') await storage.setDoc(op.path, op.data);
|
|
|
|
|
else if (op.type === 'delete') await storage.deleteDoc(op.path);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-29 12:36:16 -04:00
|
|
|
subscribeDoc(rawPath, cb) {
|
|
|
|
|
const path = norm(rawPath);
|
2026-06-28 17:51:39 -04:00
|
|
|
ensureWs().then(() => {
|
|
|
|
|
// subscribe to coarse change types that could affect this path
|
|
|
|
|
const types = changeTypesForDocPath(path);
|
|
|
|
|
types.forEach(t => ws.send(JSON.stringify({ type: 'subscribe', key: t })));
|
|
|
|
|
// fire current
|
|
|
|
|
storage.getDoc(path).then(cb).catch(() => {});
|
|
|
|
|
}).catch(() => {});
|
|
|
|
|
if (!docSubs.has(path)) docSubs.set(path, new Set());
|
|
|
|
|
docSubs.get(path).add(cb);
|
|
|
|
|
return () => { docSubs.get(path)?.delete(cb); };
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-29 12:36:16 -04:00
|
|
|
subscribeCollection(rawCollPath, cb) {
|
|
|
|
|
const collPath = norm(rawCollPath);
|
2026-06-28 17:51:39 -04:00
|
|
|
ensureWs().then(() => {
|
|
|
|
|
const types = changeTypesForCollPath(collPath);
|
|
|
|
|
types.forEach(t => ws.send(JSON.stringify({ type: 'subscribe', key: t })));
|
|
|
|
|
storage.getCollection(collPath).then(cb).catch(() => {});
|
|
|
|
|
}).catch(() => {});
|
|
|
|
|
if (!collSubs.has(collPath)) collSubs.set(collPath, new Set());
|
|
|
|
|
collSubs.get(collPath).add(cb);
|
|
|
|
|
return () => { collSubs.get(collPath)?.delete(cb); };
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
dispose() { if (ws) ws.close(); docSubs.clear(); collSubs.clear(); },
|
|
|
|
|
|
|
|
|
|
_api: api,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return storage;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-29 12:36:16 -04:00
|
|
|
function changeTypesForDocPath(rawPath) {
|
|
|
|
|
const path = rawPath.replace(/^[\s\S]*\/public\/data\//, '');
|
2026-06-28 17:51:39 -04:00
|
|
|
if (path === 'activeDisplay/status') return ['activeDisplay'];
|
|
|
|
|
if (path.match(/^campaigns\/[^/]+\/encounters\//)) return ['encounter', 'activeDisplay'];
|
|
|
|
|
if (path.match(/^campaigns\//)) return ['campaign', 'campaigns'];
|
|
|
|
|
return [];
|
|
|
|
|
}
|
2026-06-29 12:36:16 -04:00
|
|
|
function changeTypesForCollPath(rawCollPath) {
|
|
|
|
|
const collPath = rawCollPath.replace(/^[\s\S]*\/public\/data\//, '');
|
2026-06-28 17:51:39 -04:00
|
|
|
if (collPath === 'campaigns') return ['campaigns'];
|
|
|
|
|
if (collPath.match(/^campaigns\/[^/]+\/encounters$/)) return ['encounters'];
|
|
|
|
|
if (collPath === 'logs') return ['logs'];
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
function docPathForCampaign(id) { return `campaigns/${id}`; }
|
|
|
|
|
function docPathForEncounter(campaignId, encounterId) { return `campaigns/${campaignId}/encounters/${encounterId}`; }
|
|
|
|
|
|
|
|
|
|
module.exports = { createWsStorage };
|