import type {
BridgeClientMessage,
BridgeResultMessage,
BridgeServerMessage,
CommandName,
CommandPayload,
CommandPayloadMap,
CommandResult,
ConsoleLogEntry,
DomSnapshot,
DomSnapshotEntry,
PageStateSnapshot,
} from "@yetidevworks/shared";
const STORAGE_KEYS = {
connectedTabId: "yetibrowser:connectedTabId",
wsPort: "yetibrowser:wsPort",
wsPortMode: "yetibrowser:wsPortMode",
};
const DEFAULT_WS_PORT = 9010;
const DEFAULT_PORT_MODE = "auto" satisfies PortMode;
const FALLBACK_WS_PORTS = [
9010, 9011, 9012, 9013, 9014, 9015, 9016, 9017, 9018, 9019, 9020,
];
const AUTO_FAST_SCAN_WINDOW_MS = 30_000;
const AUTO_SLOW_RETRY_DELAY_MS = 5_000;
const AUTO_FAST_RETRY_DELAY_MS = 50;
const AUTO_CONNECT_ATTEMPT_TIMEOUT_MS = 1_000;
let connectedTabId: number | null = null;
let wsPort = DEFAULT_WS_PORT;
let portMode: PortMode = DEFAULT_PORT_MODE;
let fallbackPortIndex = 0;
let socket: WebSocket | null = null;
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
let keepAliveTimer: ReturnType<typeof setInterval> | null = null;
let expectedSocketClose = false;
let socketOpenedInCurrentAttempt = false;
let fallbackAdvancedForCurrentAttempt = false;
let fallbackWrappedInCurrentAttempt = false;
let socketStatus: SocketStatus = "disconnected";
let autoScanStartedAt = 0;
let activeAttemptToken = 0;
let attemptTimeout: ReturnType<typeof setTimeout> | null = null;
let reconnectPlannedAfterClose = false;
chrome.runtime.onInstalled.addListener(() => {
console.log("[yetibrowser] extension installed");
});
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (!message || typeof message !== "object") {
return;
}
if (message.type === "yetibrowser/connect") {
const { tabId } = message as { tabId: number };
setConnectedTab(tabId)
.then(() => sendResponse({ ok: true }))
.catch((error) => sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }));
return true;
}
if (message.type === "yetibrowser/disconnect") {
clearConnectedTab()
.then(() => sendResponse({ ok: true }))
.catch((error) => sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }));
return true;
}
if (message.type === "yetibrowser/getState") {
sendResponse({
tabId: connectedTabId,
wsPort,
socketConnected: socket?.readyState === WebSocket.OPEN,
portMode,
socketStatus,
});
return;
}
if (message.type === "yetibrowser/setPortConfig") {
const { mode, port } = message as { mode: PortMode; port?: number };
void setPortConfiguration(mode, port)
.then(() => sendResponse({ ok: true }))
.catch((error) =>
sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }),
);
return true;
}
if (message.type === "yetibrowser/reconnect") {
triggerManualReconnect();
sendResponse({ ok: true });
return;
}
});
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== "local") {
return;
}
if (STORAGE_KEYS.connectedTabId in changes) {
const value = changes[STORAGE_KEYS.connectedTabId]?.newValue;
connectedTabId = typeof value === "number" ? value : null;
console.log("[yetibrowser] connected tab changed", connectedTabId);
}
if (STORAGE_KEYS.wsPort in changes) {
const value = changes[STORAGE_KEYS.wsPort]?.newValue;
const parsed = typeof value === "number" && Number.isFinite(value) ? value : DEFAULT_WS_PORT;
if (parsed === wsPort) {
return;
}
wsPort = parsed;
updateFallbackIndex(parsed);
console.log("[yetibrowser] websocket port changed", wsPort);
reconnectWebSocket();
}
if (STORAGE_KEYS.wsPortMode in changes) {
const value = changes[STORAGE_KEYS.wsPortMode]?.newValue;
const parsed = value === "manual" ? "manual" : DEFAULT_PORT_MODE;
if (parsed === portMode) {
return;
}
portMode = parsed;
if (portMode === "auto") {
wsPort = DEFAULT_WS_PORT;
fallbackPortIndex = 0;
fallbackAdvancedForCurrentAttempt = false;
}
console.log("[yetibrowser] websocket port mode changed", portMode);
reconnectWebSocket();
}
});
void bootstrap();
async function bootstrap(): Promise<void> {
const stored = await chrome.storage.local.get(STORAGE_KEYS);
const storedTabId = stored[STORAGE_KEYS.connectedTabId];
const storedPort = stored[STORAGE_KEYS.wsPort];
const storedMode = stored[STORAGE_KEYS.wsPortMode];
if (typeof storedTabId === "number") {
connectedTabId = storedTabId;
await initializeTab(storedTabId);
}
if (storedMode === "manual" || storedMode === "auto") {
portMode = storedMode;
}
if (typeof storedPort === "number" && Number.isFinite(storedPort)) {
wsPort = storedPort;
updateFallbackIndex(storedPort);
}
connectWebSocket();
void updateBadge();
}
function connectWebSocket(): void {
if (socket && socket.readyState === WebSocket.OPEN) {
return;
}
if (socket && socket.readyState === WebSocket.CONNECTING) {
return;
}
// Clear any pending reconnect since we're connecting now
if (reconnectTimeout !== null) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
clearAttemptTimeout();
reconnectPlannedAfterClose = false;
expectedSocketClose = false;
socketOpenedInCurrentAttempt = false;
fallbackAdvancedForCurrentAttempt = false;
fallbackWrappedInCurrentAttempt = false;
if (portMode === "auto") {
ensureAutoScanWindow();
}
socketStatus = "connecting";
void updateBadge();
console.log(`[yetibrowser] Trying port ${wsPort} (mode: ${portMode})`);
try {
socket = new WebSocket(`ws://localhost:${wsPort}`);
} catch (error) {
console.error("[yetibrowser] failed to create WebSocket", error);
let delay = AUTO_FAST_RETRY_DELAY_MS;
if (portMode === "auto") {
advanceFallbackPort();
delay = getAutoReconnectDelay();
}
socketStatus = "connecting";
void updateBadge();
scheduleReconnect(delay);
return;
}
const attemptToken = ++activeAttemptToken;
startAttemptTimeout(attemptToken);
const currentSocket = socket;
currentSocket.addEventListener("open", () => {
if (socket !== currentSocket) {
try {
currentSocket.close();
} catch (error) {
console.debug("[yetibrowser] stale socket open event", error);
}
return;
}
clearAttemptTimeout();
console.log("[yetibrowser] connected to MCP server");
if (reconnectTimeout !== null) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
socketOpenedInCurrentAttempt = true;
socketStatus = "open";
autoScanStartedAt = 0;
persistWsPortIfNeeded(wsPort).catch((error) => {
console.error("[yetibrowser] failed to persist websocket port", error);
});
sendHello(currentSocket);
startKeepAlive();
void updateBadge();
});
currentSocket.addEventListener("message", (event) => {
if (socket !== currentSocket) {
return;
}
handleSocketMessage(event.data).catch((error) => {
console.error("[yetibrowser] failed to handle message", error);
});
});
currentSocket.addEventListener("close", () => {
if (socket !== currentSocket) {
return;
}
clearAttemptTimeout();
console.warn("[yetibrowser] MCP socket closed");
socket = null;
stopKeepAlive();
const intentional = expectedSocketClose;
expectedSocketClose = false;
if (intentional) {
const reconnecting = reconnectPlannedAfterClose;
reconnectPlannedAfterClose = false;
if (!reconnecting) {
socketStatus = "disconnected";
void updateBadge();
}
return; // Caller will handle follow-up if needed
}
reconnectPlannedAfterClose = false;
socketStatus = "connecting";
void updateBadge();
if (portMode === "auto" && socketOpenedInCurrentAttempt) {
restartAutoScanWindow();
}
if (!socketOpenedInCurrentAttempt && portMode === "auto") {
advanceFallbackPort();
const delay = getAutoReconnectDelay();
scheduleReconnect(delay);
} else {
const delay = socketOpenedInCurrentAttempt ? AUTO_FAST_RETRY_DELAY_MS : getAutoReconnectDelay();
scheduleReconnect(delay);
}
});
currentSocket.addEventListener("error", () => {
if (socket !== currentSocket) {
return;
}
// Error event is always followed by close event, so we don't need to handle reconnection here
if (!socketOpenedInCurrentAttempt && portMode === "auto") {
advanceFallbackPort();
}
});
}
function reconnectWebSocket(options: { resetAutoScan?: boolean } = {}): void {
const { resetAutoScan = false } = options;
if (resetAutoScan && portMode === "auto") {
wsPort = FALLBACK_WS_PORTS[0];
fallbackPortIndex = 0;
fallbackAdvancedForCurrentAttempt = false;
fallbackWrappedInCurrentAttempt = false;
autoScanStartedAt = 0;
}
if (socket) {
expectedSocketClose = true;
reconnectPlannedAfterClose = true;
try {
socket.close();
} catch (error) {
console.error("[yetibrowser] failed to close socket before reconnect", error);
}
socket = null;
}
stopKeepAlive();
clearAttemptTimeout();
if (reconnectTimeout !== null) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
if (portMode === "auto") {
restartAutoScanWindow();
}
socketStatus = "connecting";
void updateBadge();
scheduleReconnect(0);
}
function triggerManualReconnect(): void {
if (portMode === "auto") {
void setPortConfiguration("auto", undefined);
return;
}
void setPortConfiguration("manual", wsPort);
}
function scheduleReconnect(delayMs: number): void {
if (reconnectTimeout !== null) {
clearTimeout(reconnectTimeout);
}
reconnectTimeout = setTimeout(() => {
reconnectTimeout = null;
connectWebSocket();
}, Math.max(0, delayMs));
}
function advanceFallbackPort(): void {
if (portMode !== "auto") {
return;
}
if (fallbackAdvancedForCurrentAttempt) {
return;
}
const nextIndex = (fallbackPortIndex + 1) % FALLBACK_WS_PORTS.length;
fallbackPortIndex = nextIndex;
const nextPort = FALLBACK_WS_PORTS[nextIndex];
console.log(`[yetibrowser] Port ${wsPort} failed, trying port ${nextPort}`);
wsPort = nextPort;
fallbackAdvancedForCurrentAttempt = true;
fallbackWrappedInCurrentAttempt = nextIndex === 0;
}
function ensureAutoScanWindow(): void {
if (autoScanStartedAt === 0) {
autoScanStartedAt = Date.now();
}
}
function restartAutoScanWindow(): void {
autoScanStartedAt = Date.now();
}
function isInFastAutoScanWindow(): boolean {
if (autoScanStartedAt === 0) {
return true;
}
return Date.now() - autoScanStartedAt < AUTO_FAST_SCAN_WINDOW_MS;
}
function getAutoReconnectDelay(): number {
if (isInFastAutoScanWindow()) {
return AUTO_FAST_RETRY_DELAY_MS;
}
return fallbackWrappedInCurrentAttempt ? AUTO_SLOW_RETRY_DELAY_MS : AUTO_FAST_RETRY_DELAY_MS;
}
function startAttemptTimeout(token: number): void {
clearAttemptTimeout();
attemptTimeout = setTimeout(() => {
if (token !== activeAttemptToken) {
return;
}
if (!socket || socket.readyState !== WebSocket.CONNECTING) {
return;
}
console.warn(`[yetibrowser] Connection attempt on port ${wsPort} timed out`);
try {
socket.close();
} catch (error) {
console.error("[yetibrowser] failed to close timed out socket", error);
}
}, AUTO_CONNECT_ATTEMPT_TIMEOUT_MS);
}
function clearAttemptTimeout(): void {
if (attemptTimeout !== null) {
clearTimeout(attemptTimeout);
attemptTimeout = null;
}
}
function updateFallbackIndex(port: number): void {
if (portMode === "auto" && isAutoPort(port)) {
fallbackPortIndex = FALLBACK_WS_PORTS.indexOf(port);
}
}
function isAutoPort(port: number): boolean {
return FALLBACK_WS_PORTS.includes(port);
}
async function persistWsPortIfNeeded(port: number): Promise<void> {
if (portMode !== "auto") {
return;
}
const stored = await chrome.storage.local.get(STORAGE_KEYS.wsPort);
if (stored[STORAGE_KEYS.wsPort] === port) {
return;
}
await chrome.storage.local.set({ [STORAGE_KEYS.wsPort]: port });
}
async function setPortConfiguration(mode: PortMode, port: number | undefined): Promise<void> {
if (mode === "manual") {
if (!isValidPort(port)) {
throw new Error("Port must be an integer between 1 and 65535");
}
portMode = "manual";
wsPort = port!;
fallbackPortIndex = 0;
fallbackAdvancedForCurrentAttempt = false;
autoScanStartedAt = 0;
// Don't await storage, do it async for speed
chrome.storage.local.set({
[STORAGE_KEYS.wsPort]: wsPort,
[STORAGE_KEYS.wsPortMode]: portMode,
});
socketStatus = "connecting";
void updateBadge();
// Close and reconnect immediately
if (socket) {
expectedSocketClose = true;
reconnectPlannedAfterClose = true;
socket.close();
socket = null;
}
queueMicrotask(() => connectWebSocket());
return;
}
// Reset everything for auto mode to start fresh from 9010
portMode = "auto";
wsPort = FALLBACK_WS_PORTS[0]; // Always start from first port (9010)
fallbackPortIndex = 0;
fallbackAdvancedForCurrentAttempt = false;
autoScanStartedAt = 0;
console.log("[yetibrowser] Switching to auto mode, starting from port", wsPort);
// Don't await storage, do it async for speed
chrome.storage.local.set({
[STORAGE_KEYS.wsPort]: wsPort,
[STORAGE_KEYS.wsPortMode]: portMode,
});
socketStatus = "connecting";
void updateBadge();
// Close existing connection and start fresh
if (socket) {
expectedSocketClose = true;
reconnectPlannedAfterClose = true;
try {
socket.close();
} catch (error) {
console.error("[yetibrowser] failed to close socket", error);
}
socket = null;
}
queueMicrotask(() => connectWebSocket());
}
function isValidPort(value: number | undefined): value is number {
return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 65535;
}
type PortMode = "auto" | "manual";
type SocketStatus = "disconnected" | "connecting" | "open";
function sendHello(targetSocket: WebSocket | null = socket): void {
if (!targetSocket || targetSocket.readyState !== WebSocket.OPEN) {
return;
}
const message: BridgeClientMessage = {
type: "hello",
client: "yetibrowser-extension",
version: chrome.runtime.getManifest().version,
};
targetSocket.send(JSON.stringify(message));
}
function startKeepAlive(): void {
stopKeepAlive();
keepAliveTimer = setInterval(() => {
if (!socket || socket.readyState !== WebSocket.OPEN) {
return;
}
const message: BridgeClientMessage = {
type: "event",
event: "heartbeat",
payload: Date.now(),
};
socket.send(JSON.stringify(message));
}, 20_000);
}
function stopKeepAlive(): void {
if (keepAliveTimer !== null) {
clearInterval(keepAliveTimer);
keepAliveTimer = null;
}
}
async function handleSocketMessage(data: unknown): Promise<void> {
if (!socket) {
return;
}
let message: BridgeServerMessage;
try {
message = JSON.parse(String(data));
} catch (error) {
console.error("[yetibrowser] invalid message from server", error);
return;
}
if (message.type !== "call") {
console.warn("[yetibrowser] unsupported message type", message);
return;
}
try {
const result = await dispatchCommand(message.command, message.payload as CommandPayload<CommandName>);
respond({
type: "result",
id: message.id,
command: message.command,
ok: true,
result,
});
} catch (error) {
const messageText = error instanceof Error ? error.message : String(error);
respond({
type: "result",
id: message.id,
command: message.command,
ok: false,
error: messageText,
});
}
}
function respond(message: BridgeResultMessage): void {
if (!socket || socket.readyState !== WebSocket.OPEN) {
return;
}
socket.send(JSON.stringify(message));
}
async function dispatchCommand<K extends CommandName>(
command: K,
payload: CommandPayload<K>,
): Promise<CommandResult<K>> {
switch (command) {
case "ping":
return { ok: true } as CommandResult<K>;
case "getUrl":
return { url: (await ensureTab()).url ?? "about:blank" } as CommandResult<K>;
case "getTitle":
return { title: (await ensureTab()).title ?? "" } as CommandResult<K>;
case "snapshot": {
const snapshot = await captureSnapshot();
return snapshot as CommandResult<K>;
}
case "navigate": {
const { url } = payload as CommandPayloadMap["navigate"];
await navigateTo(url);
return { ok: true } as CommandResult<K>;
}
case "goBack":
await goBack();
return { ok: true } as CommandResult<K>;
case "goForward":
await goForward();
return { ok: true } as CommandResult<K>;
case "wait": {
const { seconds } = payload as CommandPayloadMap["wait"];
await delay(seconds * 1000);
return { ok: true } as CommandResult<K>;
}
case "pressKey": {
const { key } = payload as CommandPayloadMap["pressKey"];
await simulateKeyPress(key);
return { ok: true } as CommandResult<K>;
}
case "click": {
const { selector, description } = payload as CommandPayloadMap["click"];
await clickElement(selector, description);
return { ok: true } as CommandResult<K>;
}
case "hover": {
const { selector, description } = payload as CommandPayloadMap["hover"];
await hoverElement(selector, description);
return { ok: true } as CommandResult<K>;
}
case "type": {
const { selector, text, submit, description } = payload as CommandPayloadMap["type"];
await typeIntoElement(selector, text, submit ?? false, description);
return { ok: true } as CommandResult<K>;
}
case "selectOption": {
const { selector, values, description } = payload as CommandPayloadMap["selectOption"];
await selectOptions(selector, values, description);
return { ok: true } as CommandResult<K>;
}
case "screenshot": {
const { fullPage } = payload as CommandPayloadMap["screenshot"];
const { data, mimeType } = await takeScreenshot(fullPage ?? false);
return { data, mimeType } as CommandResult<K>;
}
case "getConsoleLogs": {
const logs = await readConsoleLogs();
return logs as CommandResult<K>;
}
case "pageState": {
const state = await capturePageState();
return state as CommandResult<K>;
}
default:
throw new Error(`Unsupported command ${command satisfies never}`);
}
}
async function ensureTab(): Promise<chrome.tabs.Tab> {
if (connectedTabId === null) {
throw new Error("No tab connected. Open the YetiBrowser popup and connect the target tab.");
}
try {
return await chrome.tabs.get(connectedTabId);
} catch (error) {
console.warn("[yetibrowser] failed to get connected tab", error);
await clearConnectedTab();
throw new Error("Connected tab is no longer available. Reconnect from the popup and try again.");
}
}
async function setConnectedTab(tabId: number): Promise<void> {
await chrome.storage.local.set({ [STORAGE_KEYS.connectedTabId]: tabId });
connectedTabId = tabId;
await initializeTab(tabId);
void updateBadge();
}
async function clearConnectedTab(): Promise<void> {
await chrome.storage.local.remove(STORAGE_KEYS.connectedTabId);
connectedTabId = null;
void updateBadge();
}
async function navigateTo(url: string): Promise<void> {
const tab = await ensureTab();
await chrome.tabs.update(tab.id!, { url });
await waitForTabComplete(tab.id!);
await initializeTab(tab.id!);
}
async function goBack(): Promise<void> {
const tab = await ensureTab();
try {
await chrome.tabs.goBack(tab.id!);
} catch (error) {
console.warn("[yetibrowser] unable to navigate back", error);
}
await waitForTabComplete(tab.id!);
await initializeTab(tab.id!);
}
async function goForward(): Promise<void> {
const tab = await ensureTab();
try {
await chrome.tabs.goForward(tab.id!);
} catch (error) {
console.warn("[yetibrowser] unable to navigate forward", error);
}
await waitForTabComplete(tab.id!);
await initializeTab(tab.id!);
}
async function waitForTabComplete(tabId: number): Promise<void> {
const tab = await chrome.tabs.get(tabId);
if (tab.status === "complete") {
return;
}
await new Promise<void>((resolve) => {
const listener = (updatedTabId: number, info: chrome.tabs.TabChangeInfo) => {
if (updatedTabId === tabId && info.status === "complete") {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}
};
chrome.tabs.onUpdated.addListener(listener);
});
}
async function captureSnapshot(): Promise<{ formatted: string; raw: DomSnapshot }> {
const tab = await ensureTab();
const results = await chrome.scripting.executeScript({
target: { tabId: tab.id! },
func: collectSnapshot,
});
const scriptResult = results[0]?.result as { snapshot: DomSnapshot } | string | undefined;
if (!scriptResult || typeof scriptResult === "string") {
const fallback: DomSnapshot = {
title: tab.title ?? "",
url: tab.url ?? "about:blank",
capturedAt: new Date().toISOString(),
entries: [],
};
return {
formatted: typeof scriptResult === "string" ? scriptResult : formatSnapshot(fallback),
raw: fallback,
};
}
const snapshot = scriptResult.snapshot;
if (!snapshot.capturedAt) {
snapshot.capturedAt = new Date().toISOString();
}
return {
formatted: formatSnapshot(snapshot),
raw: snapshot,
};
}
async function capturePageState(): Promise<PageStateSnapshot> {
const fallback: PageStateSnapshot = {
forms: [],
localStorage: [],
sessionStorage: [],
cookies: [],
capturedAt: new Date().toISOString(),
};
const response = await runInPage(() => {
const computeSelector = (element: Element): string => {
if (element.id) {
return `#${element.id}`;
}
const parts: string[] = [];
let current: Element | null = element;
while (current && parts.length < 5) {
const tag = current.tagName.toLowerCase();
const classes = (current.className || "")
.toString()
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((cls) => cls.replace(/[^a-zA-Z0-9_-]/g, ""))
.filter(Boolean);
let part = tag;
if (classes.length) {
part += `.${classes.join(".")}`;
}
const parent = current.parentElement;
if (parent) {
const siblings = Array.from(parent.children).filter((child) => child.tagName === current!.tagName);
if (siblings.length > 1) {
const index = siblings.indexOf(current) + 1;
part += `:nth-of-type(${index})`;
}
}
parts.push(part);
current = current.parentElement;
}
return parts.reverse().join(" > ");
};
const readStorage = (storage: Storage) => {
const entries: Array<{ key: string; value: string }> = [];
const limit = Math.min(storage.length, 100);
for (let index = 0; index < limit; index += 1) {
const key = storage.key(index);
if (!key) {
continue;
}
try {
const value = storage.getItem(key) ?? "";
entries.push({ key, value });
} catch (error) {
entries.push({ key, value: "<unavailable>" });
}
}
return entries;
};
const readCookies = () => {
const raw = document.cookie;
if (!raw) {
return [] as Array<{ key: string; value: string }>;
}
return raw.split(";").slice(0, 50).map((part) => {
const [name, ...rest] = part.split("=");
return { key: name.trim(), value: rest.join("=").trim() };
});
};
const forms = Array.from(document.querySelectorAll("form"))
.slice(0, 25)
.map((form) => {
const fields: Array<{ selector: string; name?: string; type?: string; value?: string; label?: string }> = [];
const elements = Array.from(form.elements ?? []).slice(0, 50);
for (const element of elements) {
if (
!(
element instanceof HTMLInputElement ||
element instanceof HTMLTextAreaElement ||
element instanceof HTMLSelectElement
)
) {
continue;
}
const selector = computeSelector(element);
const base: { selector: string; name?: string; type?: string; value?: string; label?: string } = {
selector,
name: element.getAttribute("name") ?? undefined,
};
if (element instanceof HTMLInputElement) {
base.type = element.type;
if (element.type === "password") {
base.value = "[redacted]";
} else if (element.type === "file") {
base.value = element.files?.length ? `${element.files.length} file(s)` : "";
} else {
base.value = element.value;
}
base.label = element.labels?.[0]?.innerText.trim() || element.placeholder || undefined;
} else if (element instanceof HTMLTextAreaElement) {
base.type = "textarea";
base.value = element.value;
base.label = element.labels?.[0]?.innerText.trim() || element.placeholder || undefined;
} else if (element instanceof HTMLSelectElement) {
base.type = "select";
base.value = Array.from(element.selectedOptions)
.map((option) => option.value || option.label)
.join(", ");
base.label = element.labels?.[0]?.innerText.trim() || undefined;
}
fields.push(base);
}
return {
selector: computeSelector(form),
name: form.getAttribute("name") ?? undefined,
method: form.getAttribute("method")?.toUpperCase() ?? undefined,
action: form.getAttribute("action") ?? undefined,
fields,
};
});
const snapshot: PageStateSnapshot = {
forms,
localStorage: readStorage(window.localStorage),
sessionStorage: readStorage(window.sessionStorage),
cookies: readCookies(),
capturedAt: new Date().toISOString(),
};
return { ok: true, value: snapshot } as const;
}, []);
return response ?? fallback;
}
function collectSnapshot(): { snapshot: DomSnapshot } {
function computeSelector(element: Element): string {
if (element.id) {
return `#${element.id}`;
}
const parts: string[] = [];
let current: Element | null = element;
while (current && parts.length < 5) {
const tag = current.tagName.toLowerCase();
const classes = (current.className || "")
.toString()
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((cls) => cls.replace(/[^a-zA-Z0-9_-]/g, ""))
.filter(Boolean);
let part = tag;
if (classes.length) {
part += `.${classes.join(".")}`;
}
const parent = current.parentElement;
if (parent) {
const siblings = Array.from(parent.children).filter((child) => child.tagName === current!.tagName);
if (siblings.length > 1) {
const index = siblings.indexOf(current) + 1;
part += `:nth-of-type(${index})`;
}
}
parts.push(part);
current = current.parentElement;
}
return parts.reverse().join(" > ");
}
function makeName(element: Element): string {
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
return element.placeholder || element.value || element.name || element.id || element.type;
}
if (element instanceof HTMLSelectElement) {
return element.options[element.selectedIndex]?.label || element.name || element.id || "select";
}
const text = element.textContent?.trim();
if (text) {
return text.slice(0, 160);
}
return element.getAttribute("aria-label") || element.getAttribute("title") || element.tagName.toLowerCase();
}
const targets = Array.from(
document.querySelectorAll("a, button, input, textarea, select, [role='button'], [role='link']"),
) as Element[];
const entries: DomSnapshotEntry[] = targets.slice(0, 100).map((element) => ({
selector: computeSelector(element),
role: element.getAttribute("role") ?? element.tagName.toLowerCase(),
name: makeName(element),
}));
return {
snapshot: {
title: document.title,
url: location.href,
capturedAt: new Date().toISOString(),
entries,
},
};
}
function formatSnapshot(snapshot: DomSnapshot): string {
const lines: string[] = [];
lines.push(`title: ${snapshot.title}`);
lines.push(`url: ${snapshot.url}`);
lines.push(`capturedAt: ${snapshot.capturedAt}`);
lines.push("elements:");
for (const entry of snapshot.entries) {
lines.push(` - selector: "${entry.selector.replace(/"/g, '\\"')}"`);
lines.push(` role: ${entry.role}`);
lines.push(` name: "${entry.name.replace(/"/g, '\\"')}"`);
}
return lines.join("\n");
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
type ScriptResponse<R> = { ok: true; value?: R } | { error: string };
async function runInPage<A extends unknown[], R>(
func: (...args: A) => ScriptResponse<R>,
args: A,
): Promise<R | undefined> {
const tab = await ensureTab();
const [execution] = await chrome.scripting.executeScript({
target: { tabId: tab.id! },
func,
args,
world: "MAIN",
});
const response = execution?.result as ScriptResponse<R> | undefined;
if (!response) {
throw new Error("Injected script did not return a result");
}
if ("error" in response) {
throw new Error(response.error);
}
return response.value;
}
async function simulateKeyPress(key: string): Promise<void> {
await runInPage(
(keyValue: string) => {
const active = document.activeElement as HTMLElement | null;
if (!active) {
return { error: "No element is focused" };
}
const init: KeyboardEventInit = { key: keyValue, bubbles: true, cancelable: true };
active.dispatchEvent(new KeyboardEvent("keydown", init));
active.dispatchEvent(new KeyboardEvent("keypress", init));
active.dispatchEvent(new KeyboardEvent("keyup", init));
return { ok: true };
},
[key],
);
}
async function clickElement(selector: string, description?: string): Promise<void> {
await runInPage(
(sel: string, label: string | null) => {
const element = document.querySelector(sel);
if (!element || !(element instanceof HTMLElement)) {
return { error: `Element not found: ${label ?? sel}` };
}
element.focus({ preventScroll: false });
element.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
element.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true }));
element.click();
return { ok: true };
},
[selector, description ?? null],
);
}
async function hoverElement(selector: string, description?: string): Promise<void> {
await runInPage(
(sel: string, label: string | null) => {
const element = document.querySelector(sel);
if (!element || !(element instanceof HTMLElement)) {
return { error: `Element not found: ${label ?? sel}` };
}
element.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, cancelable: true }));
element.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, cancelable: true }));
element.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true, cancelable: true }));
return { ok: true };
},
[selector, description ?? null],
);
}
async function typeIntoElement(
selector: string,
text: string,
submit: boolean,
description?: string,
): Promise<void> {
await runInPage(
(sel: string, value: string, shouldSubmit: boolean, label: string | null) => {
const element = document.querySelector(sel);
if (!element || !(element instanceof HTMLElement)) {
return { error: `Element not found: ${label ?? sel}` };
}
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
element.focus({ preventScroll: false });
element.value = value;
element.dispatchEvent(new Event("input", { bubbles: true, cancelable: true }));
element.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
} else if (element.isContentEditable) {
element.focus({ preventScroll: false });
element.textContent = value;
element.dispatchEvent(new Event("input", { bubbles: true, cancelable: true }));
} else {
return { error: "Element is not editable" };
}
if (shouldSubmit) {
const init: KeyboardEventInit = { key: "Enter", bubbles: true, cancelable: true };
element.dispatchEvent(new KeyboardEvent("keydown", init));
element.dispatchEvent(new KeyboardEvent("keyup", init));
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
element.form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
}
}
return { ok: true };
},
[selector, text, submit, description ?? null],
);
}
async function selectOptions(selector: string, values: string[], description?: string): Promise<void> {
await runInPage(
(sel: string, valueList: string[], label: string | null) => {
const element = document.querySelector(sel);
if (!(element instanceof HTMLSelectElement)) {
return { error: `Element is not a <select>: ${label ?? sel}` };
}
const targets = new Set(valueList);
let matched = 0;
for (const option of Array.from(element.options)) {
const shouldSelect =
targets.has(option.value) || targets.has(option.label) || targets.has(option.textContent ?? "");
if (shouldSelect) {
option.selected = true;
matched++;
if (!element.multiple) {
break;
}
} else if (!element.multiple) {
option.selected = false;
}
}
if (matched === 0) {
return { error: "None of the provided values matched" };
}
element.dispatchEvent(new Event("input", { bubbles: true, cancelable: true }));
element.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
return { ok: true };
},
[selector, values, description ?? null],
);
}
async function takeScreenshot(fullPage: boolean): Promise<{ data: string; mimeType: string }> {
const tab = await ensureTab();
let base64: string | undefined;
if (canUseDebugger()) {
try {
base64 = await captureScreenshotWithDebugger(tab.id!, fullPage);
} catch (error) {
console.warn("[yetibrowser] debugger capture failed, falling back", error);
}
}
if (!base64) {
base64 = await captureVisibleTabFallback(tab.windowId!);
}
return await encodeScreenshot(base64);
}
function canUseDebugger(): boolean {
const manifest = chrome.runtime.getManifest();
const permissions = Array.isArray(manifest.permissions) ? manifest.permissions : [];
const optionalPermissions = Array.isArray(manifest.optional_permissions) ? manifest.optional_permissions : [];
if (!permissions.includes("debugger") && !optionalPermissions.includes("debugger")) {
return false;
}
return typeof chrome.debugger?.attach === "function";
}
const DEBUGGER_PROTOCOL_VERSION = "1.3";
async function captureScreenshotWithDebugger(tabId: number, fullPage: boolean): Promise<string | undefined> {
const target: chrome.debugger.Debuggee = { tabId };
await attachDebugger(target);
let metricsOverridden = false;
try {
await sendDebuggerCommand(target, "Page.enable");
if (fullPage) {
try {
const metrics = await sendDebuggerCommand<{ contentSize?: { width?: number; height?: number } }>(
target,
"Page.getLayoutMetrics",
);
const width = Math.ceil(metrics.contentSize?.width ?? 0);
const height = Math.ceil(metrics.contentSize?.height ?? 0);
if (width > 0 && height > 0) {
await sendDebuggerCommand(target, "Emulation.setDeviceMetricsOverride", {
mobile: false,
deviceScaleFactor: 1,
width,
height,
screenWidth: width,
screenHeight: height,
viewport: {
x: 0,
y: 0,
width,
height,
scale: 1,
},
});
metricsOverridden = true;
}
} catch (error) {
console.warn("[yetibrowser] layout metrics unavailable, skipping full-page override", error);
}
}
const screenshot = await sendDebuggerCommand<{ data: string }>(target, "Page.captureScreenshot", {
format: "png",
captureBeyondViewport: fullPage,
});
if (metricsOverridden) {
await sendDebuggerCommand(target, "Emulation.clearDeviceMetricsOverride");
}
return screenshot.data;
} finally {
await detachDebugger(target);
}
}
async function captureVisibleTabFallback(windowId: number): Promise<string> {
if (windowId === undefined) {
throw new Error("Unable to determine window for active tab");
}
const dataUrl = await new Promise<string>((resolve, reject) => {
chrome.tabs.captureVisibleTab(windowId, { format: "png" }, (result) => {
const error = chrome.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
if (!result) {
reject(new Error("Failed to capture screenshot"));
return;
}
resolve(result);
});
});
const [, base64] = dataUrl.split(",");
if (!base64) {
throw new Error("Unexpected screenshot data format");
}
return base64;
}
async function attachDebugger(target: chrome.debugger.Debuggee): Promise<void> {
await new Promise<void>((resolve, reject) => {
chrome.debugger.attach(target, DEBUGGER_PROTOCOL_VERSION, () => {
const error = chrome.runtime.lastError;
if (error) {
if (error.message?.includes("Another debugger is already attached")) {
resolve();
return;
}
reject(new Error(error.message));
return;
}
resolve();
});
});
}
async function detachDebugger(target: chrome.debugger.Debuggee): Promise<void> {
await new Promise<void>((resolve) => {
chrome.debugger.detach(target, () => {
const error = chrome.runtime.lastError;
if (error && !error.message?.includes("No debugger is connected")) {
console.warn("[yetibrowser] failed to detach debugger", error);
}
resolve();
});
});
}
async function encodeScreenshot(base64Png: string): Promise<{ data: string; mimeType: string }> {
const bytes = Uint8Array.from(atob(base64Png), (char) => char.charCodeAt(0));
const blob = new Blob([bytes], { type: "image/png" });
const bitmap = await createImageBitmap(blob);
const maxWidth = 1280;
const scale = bitmap.width > maxWidth ? maxWidth / bitmap.width : 1;
const targetWidth = Math.round(bitmap.width * scale);
const targetHeight = Math.round(bitmap.height * scale);
const canvas = new OffscreenCanvas(targetWidth, targetHeight);
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Unable to create drawing context for screenshot");
}
ctx.drawImage(bitmap, 0, 0, targetWidth, targetHeight);
let outputBlob: Blob;
let mimeType = "image/webp";
try {
outputBlob = await canvas.convertToBlob({ type: "image/webp", quality: 0.85 });
} catch (error) {
console.warn("[yetibrowser] webp conversion failed, falling back to jpeg", error);
outputBlob = await canvas.convertToBlob({ type: "image/jpeg", quality: 0.85 });
mimeType = "image/jpeg";
}
const arrayBuffer = await outputBlob.arrayBuffer();
const outputBytes = new Uint8Array(arrayBuffer);
let binary = "";
outputBytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return { data: btoa(binary), mimeType };
}
async function sendDebuggerCommand<T>(
target: chrome.debugger.Debuggee,
method: string,
params?: Record<string, unknown>,
): Promise<T> {
return await new Promise<T>((resolve, reject) => {
chrome.debugger.sendCommand(target, method, params, (result) => {
const error = chrome.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve((result ?? {}) as T);
});
});
}
async function readConsoleLogs(): Promise<ConsoleLogEntry[]> {
const tab = await ensureTab();
await ensurePageHelpers(tab.id!);
const logs = await runInPage(() => {
const win = window as typeof window & { __yetibrowser?: { logs?: ConsoleLogEntry[] } };
const entries = Array.isArray(win.__yetibrowser?.logs) ? win.__yetibrowser!.logs! : [];
return { ok: true, value: entries.slice(-200) };
}, []);
return logs ?? [];
}
async function initializeTab(tabId: number): Promise<void> {
try {
await ensurePageHelpers(tabId);
} catch (error) {
console.warn("[yetibrowser] failed to initialize tab helpers", error);
}
void updateBadge();
}
async function ensurePageHelpers(tabId: number): Promise<void> {
await chrome.scripting.executeScript({
target: { tabId },
world: "MAIN",
func: () => {
const win = window as typeof window & {
__yetibrowser?: {
initialized?: boolean;
logs: ConsoleLogEntry[];
};
};
const install = () => {
const maxEntries = 500;
const state = win.__yetibrowser ?? { logs: [] as ConsoleLogEntry[] };
const logs = Array.isArray(state.logs) ? state.logs : [];
const originals = {
log: console.log.bind(console),
info: console.info.bind(console),
warn: console.warn.bind(console),
error: console.error.bind(console),
};
const serialize = (value: unknown) => {
if (typeof value === "string") {
return value;
}
if (value instanceof Error) {
return value.message;
}
try {
const serialized = JSON.stringify(value);
return serialized ?? String(value);
} catch (error) {
return String(value);
}
};
const extractStack = (values: unknown[]): string | undefined => {
for (const value of values) {
if (value instanceof Error && value.stack) {
return value.stack;
}
if (typeof value === "string" && value.includes("\n at ")) {
return value;
}
}
return undefined;
};
const pushEntry = (
level: keyof typeof originals,
args: unknown[],
explicitStack?: string,
) => {
const message = args
.map((arg) => serialize(arg))
.filter((part) => part.length > 0)
.join(" ") || level;
const stack = explicitStack ?? extractStack(args);
logs.push({ level, message, timestamp: Date.now(), stack });
if (logs.length > maxEntries) {
logs.shift();
}
};
const wrap = (level: keyof typeof originals) =>
(...args: unknown[]) => {
pushEntry(level, args);
originals[level](...args);
};
console.log = wrap("log") as typeof console.log;
console.info = wrap("info") as typeof console.info;
console.warn = wrap("warn") as typeof console.warn;
console.error = wrap("error") as typeof console.error;
window.addEventListener("error", (event) => {
const details: unknown[] = [event.message];
if (event.filename) {
const locationParts = [event.filename];
if (typeof event.lineno === "number") {
locationParts.push(String(event.lineno));
}
if (typeof event.colno === "number") {
locationParts.push(String(event.colno));
}
details.push(locationParts.join(":"));
}
const stack = event.error instanceof Error ? event.error.stack ?? undefined : undefined;
pushEntry("error", details, stack);
originals.error(event.message, event.error ?? event);
});
window.addEventListener("unhandledrejection", (event) => {
const reason = event.reason;
let message: string;
let stack: string | undefined;
if (reason instanceof Error) {
message = reason.message;
stack = reason.stack ?? undefined;
} else {
message = serialize(reason);
}
pushEntry("error", ["Unhandled promise rejection", message], stack);
originals.error("Unhandled promise rejection", reason);
});
logs.push({ level: "debug", message: "[yetibrowser] console hooks installed", timestamp: Date.now() });
win.__yetibrowser = {
initialized: true,
logs,
};
};
if (!win.__yetibrowser?.initialized) {
install();
return;
}
if (!Array.isArray(win.__yetibrowser.logs)) {
install();
}
},
});
const tab = await chrome.tabs.get(tabId);
if (tab.url?.startsWith("chrome://") || tab.url?.startsWith("edge://") || tab.url?.startsWith("about:")) {
console.warn("[yetibrowser] unable to inject helpers into special page", tab.url);
}
}
async function updateBadge(): Promise<void> {
const isConnected = connectedTabId !== null && socketStatus === "open";
try {
const text = isConnected ? "●" : "";
await chrome.action.setBadgeText({ text });
if (isConnected) {
try {
await chrome.action.setBadgeBackgroundColor({ color: "#111827" });
} catch (error) {
console.warn("[yetibrowser] failed to set badge background", error);
}
if (chrome.action.setBadgeTextColor) {
try {
await chrome.action.setBadgeTextColor({ color: "#facc15" });
} catch (error) {
console.warn("[yetibrowser] failed to set badge text color", error);
}
}
}
} catch (error) {
console.warn("[yetibrowser] failed to set badge", error);
}
}