adding examples
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
import { TabState } from './tab-state.js';
|
||||
import { backgroundCommands, getTabInfo, detectBrowser } from './background-commands.js';
|
||||
|
||||
export class TabManager {
|
||||
constructor() {
|
||||
this.tabs = new Map(); // tabId -> TabState
|
||||
this.listeners = new Set(); // State change listeners
|
||||
}
|
||||
|
||||
// Tab lifecycle
|
||||
getOrCreateTab(tabId) {
|
||||
if (!this.tabs.has(tabId)) {
|
||||
const tabState = new TabState(tabId);
|
||||
this.tabs.set(tabId, tabState);
|
||||
this.notifyListeners(tabId, 'created', tabState);
|
||||
}
|
||||
return this.tabs.get(tabId);
|
||||
}
|
||||
|
||||
getTab(tabId) {
|
||||
return this.tabs.get(tabId);
|
||||
}
|
||||
|
||||
hasTab(tabId) {
|
||||
return this.tabs.has(tabId);
|
||||
}
|
||||
|
||||
removeTab(tabId) {
|
||||
const tabState = this.tabs.get(tabId);
|
||||
if (tabState) {
|
||||
tabState.cleanup();
|
||||
this.tabs.delete(tabId);
|
||||
this.notifyListeners(tabId, 'removed', null);
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket connection management
|
||||
async connect(tabId) {
|
||||
const tabState = this.getOrCreateTab(tabId);
|
||||
|
||||
if (tabState.websocket && tabState.websocket.readyState === WebSocket.OPEN) {
|
||||
return { ok: true, message: 'Already connected' };
|
||||
}
|
||||
|
||||
// Get tab info from content script
|
||||
const tabInfo = await getTabInfo(tabId);
|
||||
tabState.updatePageMetadata(tabInfo);
|
||||
|
||||
// Set up connection
|
||||
tabState.connectionInfo.userDisconnected = false;
|
||||
|
||||
await this._createConnection(tabState);
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
disconnect(tabId) {
|
||||
const tabState = this.getTab(tabId);
|
||||
if (!tabState) {
|
||||
return { ok: false, error: 'Tab not found' };
|
||||
}
|
||||
|
||||
tabState.connectionInfo.setDisconnected(true);
|
||||
|
||||
// Clear keepalive interval
|
||||
if (tabState.keepaliveInterval) {
|
||||
clearInterval(tabState.keepaliveInterval);
|
||||
tabState.keepaliveInterval = null;
|
||||
}
|
||||
|
||||
if (tabState.websocket) {
|
||||
tabState.websocket.close();
|
||||
tabState.clearWebSocket();
|
||||
}
|
||||
|
||||
// Clear any pending reconnect
|
||||
if (tabState.connectionInfo.reconnectTimer) {
|
||||
clearTimeout(tabState.connectionInfo.reconnectTimer);
|
||||
tabState.connectionInfo.reconnectTimer = null;
|
||||
}
|
||||
|
||||
this.notifyListeners(tabId, 'stateChanged', tabState);
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// Private connection methods
|
||||
async _createConnection(tabState) {
|
||||
// First check if tab still exists
|
||||
try {
|
||||
await chrome.tabs.get(tabState.tabId);
|
||||
} catch (error) {
|
||||
console.log(`Tab ${tabState.tabId} no longer exists, aborting connection`);
|
||||
this.removeTab(tabState.tabId);
|
||||
return;
|
||||
}
|
||||
|
||||
const ws = new WebSocket(tabState.connectionInfo.url);
|
||||
tabState.setWebSocket(ws);
|
||||
|
||||
// Clear any existing keepalive interval
|
||||
if (tabState.keepaliveInterval) {
|
||||
clearInterval(tabState.keepaliveInterval);
|
||||
tabState.keepaliveInterval = null;
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
tabState.connectionInfo.setConnected();
|
||||
this.notifyListeners(tabState.tabId, 'stateChanged', tabState);
|
||||
|
||||
// Send registration message with Chrome tab ID and metadata
|
||||
const registerMessage = {
|
||||
type: 'register',
|
||||
requestedTabId: tabState.tabId.toString(), // Chrome tab ID
|
||||
browser: detectBrowser(), // Add browser type
|
||||
...tabState.pageMetadata
|
||||
};
|
||||
|
||||
this.sendMessage(tabState.tabId, registerMessage);
|
||||
|
||||
// Set up keepalive ping every 30 seconds
|
||||
tabState.keepaliveInterval = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() }));
|
||||
} catch (e) {
|
||||
console.error('Failed to send keepalive ping:', e);
|
||||
}
|
||||
}
|
||||
}, 30000);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
// Clear keepalive interval
|
||||
if (tabState.keepaliveInterval) {
|
||||
clearInterval(tabState.keepaliveInterval);
|
||||
tabState.keepaliveInterval = null;
|
||||
}
|
||||
|
||||
tabState.clearWebSocket();
|
||||
|
||||
if (!tabState.connectionInfo.userDisconnected) {
|
||||
// Schedule reconnect
|
||||
this._scheduleReconnect(tabState);
|
||||
} else {
|
||||
tabState.connectionInfo.setDisconnected(true);
|
||||
}
|
||||
|
||||
this.notifyListeners(tabState.tabId, 'stateChanged', tabState);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error(`WebSocket error for tab ${tabState.tabId}:`, error);
|
||||
tabState.connectionInfo.setError(error);
|
||||
// Don't clear WebSocket here - let onclose handle cleanup and reconnection
|
||||
this.notifyListeners(tabState.tabId, 'stateChanged', tabState);
|
||||
};
|
||||
|
||||
ws.onmessage = async (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
// Don't track pong messages
|
||||
if (data.type !== 'pong') {
|
||||
const message = tabState.addMessage('incoming', data);
|
||||
this.notifyListeners(tabState.tabId, 'messageReceived', tabState, message);
|
||||
}
|
||||
|
||||
// Handle commands
|
||||
if (data.type === 'command' && data.command && data.id) {
|
||||
await this._handleCommand(tabState, data);
|
||||
} else if (data.type === 'pong') {
|
||||
// Pong received - connection is healthy
|
||||
tabState.lastPongTime = Date.now();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse WebSocket message:', e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
_scheduleReconnect(tabState) {
|
||||
// Clear any existing reconnect timer
|
||||
if (tabState.connectionInfo.reconnectTimer) {
|
||||
clearTimeout(tabState.connectionInfo.reconnectTimer);
|
||||
tabState.connectionInfo.reconnectTimer = null;
|
||||
}
|
||||
|
||||
const attemptNumber = tabState.connectionInfo.reconnectAttempts;
|
||||
const backoffMs = this._getBackoffMs(attemptNumber);
|
||||
|
||||
console.log(`Scheduling reconnect for tab ${tabState.tabId} in ${backoffMs}ms (attempt ${attemptNumber + 1})`);
|
||||
|
||||
tabState.connectionInfo.setRetrying(attemptNumber + 1, backoffMs);
|
||||
this.notifyListeners(tabState.tabId, 'stateChanged', tabState);
|
||||
|
||||
tabState.connectionInfo.reconnectTimer = setTimeout(async () => {
|
||||
if (!tabState.connectionInfo.userDisconnected) {
|
||||
// Check if tab still exists before attempting reconnect
|
||||
try {
|
||||
await chrome.tabs.get(tabState.tabId);
|
||||
console.log(`Attempting reconnect for tab ${tabState.tabId}`);
|
||||
this._createConnection(tabState);
|
||||
} catch (error) {
|
||||
console.log(`Tab ${tabState.tabId} no longer exists, removing from manager`);
|
||||
this.removeTab(tabState.tabId);
|
||||
}
|
||||
}
|
||||
}, backoffMs);
|
||||
}
|
||||
|
||||
_getBackoffMs(attemptNumber) {
|
||||
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, then cap at 60s
|
||||
return Math.min(1000 * Math.pow(2, attemptNumber), 60000);
|
||||
}
|
||||
|
||||
async _handleCommand(tabState, {command, params, id}) {
|
||||
try {
|
||||
let result;
|
||||
// some need to run with the background context
|
||||
if (backgroundCommands[command]) {
|
||||
result = await backgroundCommands[command](tabState, params);
|
||||
}
|
||||
// others we execute in the page context
|
||||
else {
|
||||
result = await chrome.tabs.sendMessage(tabState.tabId, {command, params});
|
||||
}
|
||||
// `success: true` means we didn't throw an error. TODO: rename or remove it
|
||||
const response = {id, type: 'response', success: true, result};
|
||||
this.sendMessage(tabState.tabId, response);
|
||||
}
|
||||
catch (error) {
|
||||
const errorResponse = { id, type: 'response', success: false, error: { message: error.message, code: 'COMMAND_FAILED' }};
|
||||
this.sendMessage(tabState.tabId, errorResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// Message sending
|
||||
sendMessage(tabId, data) {
|
||||
const tabState = this.getTab(tabId);
|
||||
if (!tabState || !tabState.websocket || tabState.websocket.readyState !== WebSocket.OPEN) {
|
||||
return { ok: false, error: 'Not connected' };
|
||||
}
|
||||
|
||||
try {
|
||||
const message = tabState.addMessage('outgoing', data);
|
||||
tabState.websocket.send(JSON.stringify(data));
|
||||
this.notifyListeners(tabId, 'messageSent', tabState, message);
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Message management
|
||||
clearMessages(tabId) {
|
||||
const tabState = this.getTab(tabId);
|
||||
if (tabState) {
|
||||
tabState.clearMessages();
|
||||
this.notifyListeners(tabId, 'messagesCleared', tabState);
|
||||
}
|
||||
}
|
||||
|
||||
// Console log management
|
||||
addConsoleLog(tabId, log) {
|
||||
const tabState = this.getTab(tabId);
|
||||
if (tabState) {
|
||||
tabState.addConsoleLog(log);
|
||||
this.notifyListeners(tabId, 'consoleLogAdded', tabState, log);
|
||||
}
|
||||
}
|
||||
|
||||
clearConsoleLogs(tabId) {
|
||||
const tabState = this.getTab(tabId);
|
||||
if (tabState) {
|
||||
tabState.clearConsoleLogs();
|
||||
this.notifyListeners(tabId, 'consoleLogsCleared', tabState);
|
||||
}
|
||||
}
|
||||
|
||||
// Port management
|
||||
addPort(tabId, port) {
|
||||
const tabState = this.getOrCreateTab(tabId);
|
||||
tabState.addPort(port);
|
||||
|
||||
// Send initial state
|
||||
port.postMessage({
|
||||
type: 'state',
|
||||
tabId,
|
||||
...tabState.getConnectionState()
|
||||
});
|
||||
|
||||
// Send current messages
|
||||
port.postMessage({
|
||||
type: 'messages',
|
||||
tabId,
|
||||
messages: tabState.getMessages()
|
||||
});
|
||||
|
||||
// Send console count
|
||||
port.postMessage({
|
||||
type: 'consoleCount',
|
||||
tabId,
|
||||
count: tabState.getConsoleLogCount()
|
||||
});
|
||||
}
|
||||
|
||||
removePort(tabId, port) {
|
||||
const tabState = this.getTab(tabId);
|
||||
if (tabState) {
|
||||
tabState.removePort(port);
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
addListener(callback) {
|
||||
this.listeners.add(callback);
|
||||
}
|
||||
|
||||
removeListener(callback) {
|
||||
this.listeners.delete(callback);
|
||||
}
|
||||
|
||||
notifyListeners(tabId, event, tabState, data = null) {
|
||||
this.listeners.forEach(callback => {
|
||||
try {
|
||||
callback(tabId, event, tabState, data);
|
||||
} catch (e) {
|
||||
console.error('Error in TabManager listener:', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Utility
|
||||
getAllTabs() {
|
||||
return Array.from(this.tabs.values());
|
||||
}
|
||||
|
||||
getActiveTabCount() {
|
||||
return this.tabs.size;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user