File size: 6,963 Bytes
f589dab | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 | /**
* entrypoints/background.ts
*
* Persistent background service worker (Manifest V3).
* Owns the single WebSocket connection to the Fact Engine backend.
* Receives analysis batches from content scripts β forwards to backend.
* Receives analysis results from backend β routes to correct tab's content script.
*
* Reconnect strategy: exponential backoff starting at 1s, max 30s cap.
* The extension icon badge reflects WS state.
*/
import { defineBackground } from 'wxt/sandbox';
export default defineBackground({
type: 'module',
persistent: true,
main() {
const WS_URL = (import.meta.env.VITE_WS_URL || 'ws://localhost:7860');
const CLIENT_ID = getOrCreateClientId();
let ws: WebSocket | null = null;
let reconnectDelay = 1000;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let isConnected = false;
// Map: tab content scripts waiting for results
const pendingTabs = new Map<string, number>(); // claimHash β tabId
// ββ Badge helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββ
function setBadge(status: 'connected' | 'reconnecting' | 'offline') {
const colors = {
connected: '#22c55e',
reconnecting: '#eab308',
offline: '#6b7280',
};
const labels = { connected: 'β', reconnecting: 'β»', offline: 'β' };
chrome.action.setBadgeText({ text: labels[status] });
chrome.action.setBadgeBackgroundColor({ color: colors[status] });
// Notify all content scripts of status change
chrome.tabs.query({}, (tabs) => {
tabs.forEach((tab) => {
if (tab.id) {
chrome.tabs.sendMessage(tab.id, { type: 'ws_status', status }).catch(() => {});
}
});
});
}
// ββ WebSocket connection ββββββββββββββββββββββββββββββββββββββββββββββ
function connect() {
if (ws && ws.readyState === WebSocket.OPEN) return;
const url = `${WS_URL}/ws/${CLIENT_ID}`;
console.log('[FactEngine] Connecting to', url);
setBadge('reconnecting');
try {
ws = new WebSocket(url);
} catch (err) {
console.error('[FactEngine] WebSocket construction failed', err);
scheduleReconnect();
return;
}
ws.onopen = () => {
console.log('[FactEngine] WebSocket connected');
isConnected = true;
reconnectDelay = 1000; // reset backoff on successful connection
setBadge('connected');
};
ws.onmessage = (event: MessageEvent) => {
try {
const msg = JSON.parse(event.data as string);
handleBackendMessage(msg);
} catch (err) {
console.error('[FactEngine] Failed to parse backend message', err);
}
};
ws.onerror = (err) => {
console.error('[FactEngine] WebSocket error', err);
};
ws.onclose = () => {
console.log('[FactEngine] WebSocket closed');
isConnected = false;
ws = null;
setBadge('offline');
scheduleReconnect();
};
}
function scheduleReconnect() {
if (reconnectTimer) clearTimeout(reconnectTimer);
const jitter = Math.random() * 500;
reconnectTimer = setTimeout(() => {
connect();
reconnectDelay = Math.min(reconnectDelay * 2, 30_000); // cap at 30s
}, reconnectDelay + jitter);
}
function sendToBackend(payload: object) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
return true;
}
return false;
}
// ββ Handle messages from backend ββββββββββββββββββββββββββββββββββββββ
function handleBackendMessage(msg: Record<string, unknown>) {
if (msg.type === 'analysis_batch') {
const results = msg.results as Array<Record<string, unknown>>;
// Route each result to all active content script tabs
chrome.tabs.query({ active: true }, (tabs) => {
tabs.forEach((tab) => {
if (tab.id) {
chrome.tabs.sendMessage(tab.id, {
type: 'analysis_results',
results,
}).catch(() => {/* tab may not have content script */});
}
});
});
// Also broadcast to all tabs (for multi-tab usage)
chrome.tabs.query({}, (tabs) => {
tabs.forEach((tab) => {
if (tab.id) {
chrome.tabs.sendMessage(tab.id, {
type: 'analysis_results',
results,
}).catch(() => {});
}
});
});
}
}
// ββ Handle messages from content scripts ββββββββββββββββββββββββββββββ
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'analyze_claims') {
const { claims, platform } = msg;
const sent = sendToBackend({
client_id: CLIENT_ID,
claims,
platform,
timestamp: Date.now() / 1000,
});
sendResponse({ queued: sent });
return true;
}
if (msg.type === 'get_ws_status') {
sendResponse({ status: isConnected ? 'connected' : 'offline' });
return true;
}
if (msg.type === 'get_client_id') {
sendResponse({ clientId: CLIENT_ID });
return true;
}
if (msg.type === 'reconnect') {
if (!isConnected) connect();
sendResponse({ ok: true });
return true;
}
});
// ββ Init ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
connect();
// Keep alive: ping every 20s to prevent Manifest V3 service worker suspension
setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify({ type: 'ping', client_id: CLIENT_ID }));
} catch (_) {
connect();
}
} else if (!ws || ws.readyState === WebSocket.CLOSED) {
connect();
}
}, 20_000);
},
});
function getOrCreateClientId(): string {
// In background context, use a stable ID from chrome.storage.local
// Synchronous approximation; actual persistence handled by store
const stored = sessionStorage.getItem('fact_engine_client_id');
if (stored) return stored;
const arr = new Uint8Array(8);
crypto.getRandomValues(arr);
const id = 'ext-' + Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('');
sessionStorage.setItem('fact_engine_client_id', id);
return id;
}
|