export const DEFAULT_PORT = 9333;
export const RELAY_PATH = '/browser-commander';
export const KEEPALIVE_ALARM = 'browser-commander-relay';
export const DEBUGGER_PROTOCOL_VERSION = '1.3';
export function relayUrl(port = DEFAULT_PORT) {
return `ws://127.0.0.1:${port}${RELAY_PATH}`;
}
function requireTabId(params) {
if (!Number.isInteger(params.tabId)) {
throw new Error('params.tabId must be an integer tab id');
}
return params.tabId;
}
function describeTab(tab) {
return {
tabId: tab.id,
url: tab.url ?? tab.pendingUrl ?? '',
title: tab.title ?? '',
active: Boolean(tab.active),
};
}
export async function handleRelayRequest(
chrome,
{ method, params = {} },
attachedTabs
) {
switch (method) {
case 'tabs.list': {
const tabs = await chrome.tabs.query({});
return tabs.filter((tab) => Number.isInteger(tab.id)).map(describeTab);
}
case 'tabs.create': {
if (params.url !== undefined && typeof params.url !== 'string') {
throw new Error('params.url must be a string');
}
const tab = await chrome.tabs.create(
params.url === undefined ? {} : { url: params.url }
);
return { tabId: tab.id };
}
case 'debugger.attach': {
const tabId = requireTabId(params);
if (!attachedTabs.has(tabId)) {
await chrome.debugger.attach({ tabId }, DEBUGGER_PROTOCOL_VERSION);
attachedTabs.add(tabId);
}
return { attached: true };
}
case 'debugger.detach': {
const tabId = requireTabId(params);
if (attachedTabs.has(tabId)) {
attachedTabs.delete(tabId);
await chrome.debugger.detach({ tabId });
}
return { detached: true };
}
case 'cdp.send': {
const tabId = requireTabId(params);
if (typeof params.method !== 'string') {
throw new Error('params.method must be a CDP method name');
}
const result = await chrome.debugger.sendCommand(
{ tabId },
params.method,
params.params ?? {}
);
return result ?? {};
}
default:
throw new Error(`Unknown relay method: ${method}`);
}
}
async function readPort(chrome) {
try {
const { port } = await chrome.storage.local.get('port');
const number = Number(port);
return Number.isInteger(number) && number > 0 && number < 65536
? number
: DEFAULT_PORT;
} catch {
return DEFAULT_PORT;
}
}
export function createRelayClient({
chrome,
WebSocket,
userAgent = '',
retryMs = 3_000,
keepaliveMs = 20_000,
setTimeout: schedule = globalThis.setTimeout,
clearTimeout: cancel = globalThis.clearTimeout,
setInterval: repeat = globalThis.setInterval,
clearInterval: cancelRepeat = globalThis.clearInterval,
}) {
const attachedTabs = new Set();
let socket = null;
let connecting = false;
let stopped = false;
let retryTimer = null;
let keepaliveTimer = null;
const isOpen = () => socket !== null && socket.readyState === WebSocket.OPEN;
function post(message) {
if (isOpen()) {
socket.send(JSON.stringify(message));
}
}
function scheduleRetry() {
if (!stopped && retryTimer === null) {
retryTimer = schedule(() => {
retryTimer = null;
connect();
}, retryMs);
}
}
function detachAll() {
for (const tabId of [...attachedTabs]) {
attachedTabs.delete(tabId);
Promise.resolve()
.then(() => chrome.debugger.detach({ tabId }))
.catch(() => {});
}
}
async function respond(message) {
try {
const result = await handleRelayRequest(chrome, message, attachedTabs);
post({ id: message.id, result: result ?? {} });
} catch (error) {
post({
id: message.id,
error: { message: error?.message ?? String(error) },
});
}
}
function onMessage(event) {
let message;
try {
message = JSON.parse(event.data);
} catch {
return;
}
if (message && Number.isInteger(message.id) && message.method) {
respond(message);
}
}
function onOpen(opened) {
if (socket !== opened) {
return;
}
post({
type: 'hello',
extensionVersion: chrome.runtime.getManifest().version,
userAgent,
});
keepaliveTimer = repeat(() => post({ type: 'keepalive' }), keepaliveMs);
}
function onClose(closed) {
if (socket !== closed) {
return;
}
socket = null;
if (keepaliveTimer !== null) {
cancelRepeat(keepaliveTimer);
keepaliveTimer = null;
}
detachAll();
scheduleRetry();
}
async function connect() {
if (stopped || connecting || socket !== null) {
return;
}
connecting = true;
try {
if (retryTimer !== null) {
cancel(retryTimer);
retryTimer = null;
}
const port = await readPort(chrome);
if (stopped) {
return;
}
let opened;
try {
opened = new WebSocket(relayUrl(port));
} catch {
scheduleRetry();
return;
}
socket = opened;
opened.onopen = () => onOpen(opened);
opened.onmessage = onMessage;
opened.onclose = () => onClose(opened);
opened.onerror = () => {};
} finally {
connecting = false;
}
}
function reconnect() {
const current = socket;
if (current) {
current.close();
onClose(current);
}
connect();
}
function start() {
chrome.debugger.onEvent.addListener((source, method, params) => {
if (attachedTabs.has(source.tabId)) {
post({ type: 'event', tabId: source.tabId, method, params });
}
});
chrome.debugger.onDetach.addListener((source, reason) => {
if (attachedTabs.delete(source.tabId)) {
post({ type: 'detached', tabId: source.tabId, reason });
}
});
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === KEEPALIVE_ALARM) {
connect();
}
});
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'local' && changes.port) {
reconnect();
}
});
chrome.runtime.onStartup.addListener(() => connect());
chrome.runtime.onInstalled.addListener(() => connect());
Promise.resolve()
.then(() =>
chrome.alarms.create(KEEPALIVE_ALARM, { periodInMinutes: 0.5 })
)
.catch(() => {});
connect();
}
function stop() {
stopped = true;
if (retryTimer !== null) {
cancel(retryTimer);
retryTimer = null;
}
const current = socket;
if (current) {
current.close();
onClose(current);
}
}
return {
start,
connect,
stop,
connected: isOpen,
attachedTabs,
};
}