// ws.js — thin storage adapter over generic KV backend (HTTP + WebSocket). // Passthrough: no shape translation. Backend = firebase mirror. // Implements same interface as memory.js. Tested by storage contract vs running server. 'use strict'; // Native browser WebSocket if present, else ws pkg (Node/jest). // Lazy load ws pkg so CRA prod build (ESM) doesn't choke on require(). let WebSocketImpl; if (typeof WebSocket !== 'undefined') { WebSocketImpl = WebSocket; } function createServerStorage({ baseUrl, realtimeUrl } = {}) { // Same-origin by default: empty baseUrl = relative fetch (Caddy/proxy). // Fallback to localhost for bare `npm start` dev without proxy. const API = (baseUrl || (typeof window !== 'undefined' && window.location ? '' : 'http://127.0.0.1:4001')).replace(/\/$/, ''); let WS; if (realtimeUrl) { WS = realtimeUrl; } else if (typeof window !== 'undefined' && window.location) { // derive from current origin (http→ws, https→wss), same host/port. const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; WS = `${proto}//${window.location.host}/ws`; } else { WS = 'ws://127.0.0.1:4001/ws'; } // App passes firebase-prefixed paths: artifacts/{APP_ID}/public/data/campaigns/... // Backend uses canonical paths. Strip prefix. function norm(p) { if (!p) return p; return p.replace(/^[\s\S]*\/public\/data\//, ''); } // Inject id (last path segment) into doc — matches main firebase truth: // { id: docSnap.id, ...snap.data() } // Backend stores docs WITHOUT id; client adds it so all adapters share shape. function withId(rawPath, data) { if (data === null || data === undefined) return data; const id = norm(rawPath).split('/').pop(); return { id, ...data }; } // Apply neutral {__type} query constraints client-side. Backend returns all // docs; adapter sorts/limits. Mirrors firebase mock applyConstraints. function applyConstraints(docs, constraints) { let out = [...docs]; for (const c of constraints || []) { if (!c || !c.__type) continue; if (c.__type === 'orderBy') { out.sort((a, b) => { const av = a[c.field]; const bv = b[c.field]; if (av === bv) return 0; const cmp = av > bv ? 1 : -1; return c.dir === 'desc' ? -cmp : cmp; }); } else if (c.__type === 'limit') { out = out.slice(0, c.n); } } return out; } const docSubs = new Map(); // path -> Set const collSubs = new Map(); // collPath -> Set const collConstraints = new Map(); // collPath -> constraints[] (per collection) let ws = null; let wsReady = null; let disposed = false; let reconnectTimer = null; let everConnected = false; const RECONNECT_DELAY = 500; function ensureWs() { if (wsReady) return wsReady; wsReady = new Promise((resolve, reject) => { (async () => { // Node/jest only: load ws pkg via dynamic import. Browser uses global // WebSocket. Avoids require() in CRA prod ESM bundle (webpack crash). let WsClass = WebSocketImpl; if (!WsClass) { const wsPkg = await import('ws'); WsClass = wsPkg.WebSocket; } ws = new WsClass(WS); const onOpen = () => { const isReconnect = everConnected; everConnected = true; // resubscribe all existing subscribers after (re)connect for (const p of docSubs.keys()) { ws.send(JSON.stringify({ type: 'subscribe', kind: 'doc', path: p })); } for (const p of collSubs.keys()) { ws.send(JSON.stringify({ type: 'subscribe', kind: 'collection', path: p })); } // On RECONNECT only: re-fetch current values — catches writes that // happened while disconnected (broadcast missed). Skip on first connect // (initial REST fetch in subscribeDoc/subscribeCollection already did). if (isReconnect) { for (const [p, cbs] of docSubs) { storage.getDoc(p).then(doc => { cbs.forEach(cb => cb(doc)); }).catch(() => {}); } for (const [p, cbs] of collSubs) { storage.getCollection(p).then(docs => { cbs.forEach(cb => cb(docs)); }).catch(() => {}); } } resolve(ws); }; const onError = (err) => { wsReady = null; reject(err instanceof Event ? new Error('ws error') : err); }; const onClose = () => { wsReady = null; ws = null; if (disposed) return; // auto-reconnect (BUG-8): try again after delay. ensureWs() re-arms. if (reconnectTimer) clearTimeout(reconnectTimer); reconnectTimer = setTimeout(() => { reconnectTimer = null; if (!disposed) ensureWs().catch(() => {}); }, RECONNECT_DELAY); if (reconnectTimer && typeof reconnectTimer.unref === 'function') reconnectTimer.unref(); }; 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); }; ws.onopen = onOpen; ws.onerror = onError; ws.onclose = onClose; ws.onmessage = onMessage; if (typeof ws.addEventListener === 'function') { ws.addEventListener('open', onOpen); ws.addEventListener('error', onError); ws.addEventListener('close', onClose); ws.addEventListener('message', onMessage); } })(); }); return wsReady; } // Backend pushes change notices keyed by path. Re-fetch affected subscribers. async function handleMessage(msg) { if (msg.type !== 'change' || !msg.change) return; const c = msg.change; // doc subscriber at exact changed path const docCbs = docSubs.get(c.path); if (docCbs) { const doc = await storage.getDoc(c.path); docCbs.forEach(cb => cb(doc)); } // collection subscribers at parent path (apply their stored constraints) if (c.parent) { const collCbs = collSubs.get(c.parent); if (collCbs) { const constraints = collConstraints.get(c.parent) || []; const docs = applyConstraints(await storage.getCollection(c.parent), constraints); collCbs.forEach(cb => cb(docs)); } } } async function api(method, path, query, body) { let url = `${API}${path}`; if (query) { const qs = new URLSearchParams(query).toString(); url += `?${qs}`; } const res = await fetch(url, { 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 = { async getDoc(rawPath) { const p = norm(rawPath); const res = await api('GET', '/api/doc', { path: p }); const data = res && res.data !== undefined ? res.data : null; return withId(rawPath, data); }, async setDoc(rawPath, data, opts = {}) { const p = norm(rawPath); // merge flag -> PATCH (shallow merge, create-on-miss). Matches firebase // setDoc(path, data, {merge:true}) semantics. if (opts.merge) { await api('PATCH', '/api/doc', null, { path: p, patch: data }); } else { await api('PUT', '/api/doc', null, { path: p, data }); } }, async updateDoc(rawPath, patch) { const p = norm(rawPath); await api('PATCH', '/api/doc', null, { path: p, patch }); }, async deleteDoc(rawPath) { const p = norm(rawPath); await api('DELETE', '/api/doc', { path: p }); }, async addDoc(rawCollPath, data) { const p = norm(rawCollPath); const res = await api('POST', '/api/collection', null, { path: p, data }); return { id: res.id, path: res.path }; }, async getCollection(rawCollPath) { const p = norm(rawCollPath); const docs = await api('GET', '/api/collection', { path: p }); // Backend returns array of { id, data } OR bare data[]; normalize to // { id, ...data } (firebase truth). if (!Array.isArray(docs)) return []; return docs.map(d => { if (d && typeof d === 'object' && 'id' in d && 'data' in d) { return { id: d.id, ...d.data }; } return { ...d }; }); }, async batchWrite(ops) { const normOps = ops.map(op => ({ ...op, path: norm(op.path) })); await api('POST', '/api/batch', null, { ops: normOps }); }, subscribeDoc(rawPath, cb, errCb) { const p = norm(rawPath); // Initial value via REST (independent of WS connect). getDoc injects id. storage.getDoc(p).then(cb).catch(() => {}); // WS only for subsequent change notifications. ensureWs().then(() => { ws.send(JSON.stringify({ type: 'subscribe', kind: 'doc', path: p })); }).catch(() => {}); if (!docSubs.has(p)) docSubs.set(p, new Set()); docSubs.get(p).add(cb); return () => { docSubs.get(p)?.delete(cb); }; }, subscribeCollection(rawCollPath, cb, queryConstraints = [], errCb) { const p = norm(rawCollPath); collConstraints.set(p, queryConstraints || []); // Initial value via REST (independent of WS connect). Apply constraints // client-side — backend returns all docs, adapter sorts/limits. storage.getCollection(p) .then(docs => cb(applyConstraints(docs, queryConstraints || []))) .catch(() => {}); // WS only for subsequent change notifications. ensureWs().then(() => { ws.send(JSON.stringify({ type: 'subscribe', kind: 'collection', path: p })); }).catch(() => {}); if (!collSubs.has(p)) collSubs.set(p, new Set()); collSubs.get(p).add(cb); return () => { collSubs.get(p)?.delete(cb); }; }, dispose(cb) { disposed = true; if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } if (ws) ws.close(); docSubs.clear(); collSubs.clear(); collConstraints.clear(); if (typeof cb === 'function') cb(); }, _api: api, _test: { getWs: () => ws, forceDrop: () => { if (ws) ws.close(); }, getReady: () => wsReady, docSubs, collSubs, }, }; return storage; } export { createServerStorage };