From ed67535b1feaa12f901e820abbd3754523d2eac1 Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:36:16 -0400 Subject: [PATCH] Reapply "M2: fix ws adapter for browser WebSocket + firebase path prefix" This reverts commit 74b4c2c42d8c6029bf4390d2f42e6a7fa864ed56. --- src/storage/ws.js | 83 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 61 insertions(+), 22 deletions(-) diff --git a/src/storage/ws.js b/src/storage/ws.js index 070bec0..92b8b35 100644 --- a/src/storage/ws.js +++ b/src/storage/ws.js @@ -3,12 +3,26 @@ '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 } = {}) { const API = (baseUrl || 'http://127.0.0.1:4001').replace(/\/$/, ''); 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 const collSubs = new Map(); // collPath -> Set let ws = null; @@ -18,14 +32,28 @@ function createWsStorage({ baseUrl, wsUrl } = {}) { function ensureWs() { if (wsReady) return wsReady; wsReady = new Promise((resolve, reject) => { - ws = new WebSocket(WS); - ws.on('open', () => resolve(ws)); - ws.on('error', (err) => { wsReady = null; reject(err); }); - ws.on('close', () => { wsReady = null; }); - ws.on('message', (raw) => { - let msg; try { msg = JSON.parse(raw.toString()); } catch { return; } + 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; } 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; } @@ -34,14 +62,16 @@ function createWsStorage({ baseUrl, wsUrl } = {}) { async function handleMessage(msg) { if (msg.type !== 'change' || !msg.change) return; const c = msg.change; - // Notify doc subscribers whose path we cached. - for (const [path, cbs] of docSubs) { + // Notify doc subscribers whose normalized path we cached. + for (const [rawPath, cbs] of docSubs) { + const path = norm(rawPath); if (pathMatchesChange(path, c)) { const doc = await storage.getDoc(path); 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)) { const docs = await storage.getCollection(collPath); cbs.forEach(cb => cb(docs)); @@ -83,8 +113,8 @@ function createWsStorage({ baseUrl, wsUrl } = {}) { const storage = { // --- reads --- - async getDoc(path) { - // canonical paths used by App.js + async getDoc(rawPath) { + const path = norm(rawPath); if (path === 'activeDisplay/status') { const ad = await api('GET', '/api/activeDisplay'); return ad; @@ -102,7 +132,8 @@ function createWsStorage({ baseUrl, wsUrl } = {}) { return null; }, - async getCollection(collPath) { + async getCollection(rawCollPath) { + const collPath = norm(rawCollPath); 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`); @@ -111,7 +142,8 @@ function createWsStorage({ baseUrl, wsUrl } = {}) { }, // --- writes (translated to backend action endpoints) --- - async setDoc(path, data) { + async setDoc(rawPath, data) { + const path = norm(rawPath); // activeDisplay merges if (path === 'activeDisplay/status') { 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\/([^/]+)$/); if (cm) { 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\/([^/]+)$/); 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; } }, - async addDoc(collPath, data) { + async addDoc(rawCollPath, data) { + const collPath = norm(rawCollPath); if (collPath === 'logs') { // backend auto-logs; direct insert not needed 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(() => { // subscribe to coarse change types that could affect this path const types = changeTypesForDocPath(path); @@ -194,7 +230,8 @@ function createWsStorage({ baseUrl, wsUrl } = {}) { return () => { docSubs.get(path)?.delete(cb); }; }, - subscribeCollection(collPath, cb) { + subscribeCollection(rawCollPath, cb) { + const collPath = norm(rawCollPath); ensureWs().then(() => { const types = changeTypesForCollPath(collPath); types.forEach(t => ws.send(JSON.stringify({ type: 'subscribe', key: t }))); @@ -213,13 +250,15 @@ function createWsStorage({ baseUrl, wsUrl } = {}) { return storage; } -function changeTypesForDocPath(path) { +function changeTypesForDocPath(rawPath) { + const path = rawPath.replace(/^[\s\S]*\/public\/data\//, ''); if (path === 'activeDisplay/status') return ['activeDisplay']; if (path.match(/^campaigns\/[^/]+\/encounters\//)) return ['encounter', 'activeDisplay']; if (path.match(/^campaigns\//)) return ['campaign', 'campaigns']; return []; } -function changeTypesForCollPath(collPath) { +function changeTypesForCollPath(rawCollPath) { + const collPath = rawCollPath.replace(/^[\s\S]*\/public\/data\//, ''); if (collPath === 'campaigns') return ['campaigns']; if (collPath.match(/^campaigns\/[^/]+\/encounters$/)) return ['encounters']; if (collPath === 'logs') return ['logs'];