browser, extracted from the platform — extension, relay and all

Your own Chrome, driven from Officer. Not a headless one: it attaches to the browser you are already
signed into, which is the whole difference from /scrape, and /scrape stays behind in the platform.

The relay is a sidecar now, and that is the point. It was a second Bun.serve inside the platform
process on its own port, which is exactly why it was switched off on 2026-08-13 — "the platform serves
one port". A sidecar is a process allowed its own listeners, so the objection stops applying instead of
being argued with.

The boundary across it is deliberately small: two questions, who is connected and what tabs they have,
because only those live in memory on the relay side. Screenshots, evaluation and navigation still go
straight down cdp.ts's own WebSocket to the relay's /cdp endpoint, exactly as before — routing page
bytes through the platform would be work for nothing.

Three normally-breaking changes taken now because now they are free, each already recommended in a
comment by whoever switched the relay off. Its own signing key instead of borrowing getKey('jwt'). Its
own table instead of the platform's user_integrations. And the port kept at 18792 on purpose, because
the port is an input to the token HMAC as well as an address — moving it fails every pairing silently,
and the extension blames the address. user_integrations held zero browser-relay rows, checked in
Postgres rather than assumed, so there was nothing to unpair.

The screen became panels. BrowserScreen.tsx rendered TabList and TabPreview through appType: null
placeholders; both components were already self-contained and took no props, so the screen had nothing
to pass and simply went.

Not tested end to end — no browser has been paired since the move, and that needs a Chrome and a
person. BLOCKERS.md says so rather than the commit implying otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 01:04:09 +00:00
co-authored by Claude Opus 5
commit 52934f52dc
30 changed files with 3275 additions and 0 deletions
+99
View File
@@ -0,0 +1,99 @@
type CdpCommandOptions = {
relayPort: number;
userToken: string;
targetId?: string;
};
async function sendCdpCommand(opts: CdpCommandOptions, method: string, params?: unknown): Promise<unknown> {
const { relayPort, userToken, targetId } = opts;
const url = `ws://127.0.0.1:${relayPort}/cdp?token=${encodeURIComponent(userToken)}`;
return await new Promise<unknown>((resolve, reject) => {
const ws = new WebSocket(url);
let settled = false;
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
ws.close();
reject(new Error(`CDP command timeout: ${method}`));
}, 30_000);
ws.addEventListener('open', () => {
const cmd: Record<string, unknown> = { id: 1, method };
if (params) cmd.params = params;
if (targetId) cmd.sessionId = targetId;
ws.send(JSON.stringify(cmd));
});
ws.addEventListener('message', (event) => {
if (settled) return;
try {
const msg = JSON.parse(String(event.data)) as { id?: number; result?: unknown; error?: { message: string } };
if (msg.id === 1) {
settled = true;
clearTimeout(timeout);
ws.close();
if (msg.error) reject(new Error(msg.error.message));
else resolve(msg.result);
}
} catch {
// ignore parse errors, wait for correct message
}
});
ws.addEventListener('error', () => {
if (settled) return;
settled = true;
clearTimeout(timeout);
reject(new Error('CDP WebSocket connection failed'));
});
ws.addEventListener('close', () => {
if (settled) return;
settled = true;
clearTimeout(timeout);
reject(new Error('CDP WebSocket closed before response'));
});
});
}
export async function captureScreenshot(
opts: CdpCommandOptions,
format: 'png' | 'jpeg' = 'png',
quality?: number,
): Promise<string> {
const params: Record<string, unknown> = { format };
if (quality !== undefined) params.quality = quality;
const result = (await sendCdpCommand(opts, 'Page.captureScreenshot', params)) as { data: string };
return result.data;
}
export async function evaluateJS(opts: CdpCommandOptions, expression: string): Promise<unknown> {
const result = (await sendCdpCommand(opts, 'Runtime.evaluate', {
expression,
returnByValue: true,
awaitPromise: true,
})) as { result?: { value?: unknown; description?: string }; exceptionDetails?: { text?: string } };
if (result.exceptionDetails) {
throw new Error(result.exceptionDetails.text ?? 'Evaluation failed');
}
return result.result?.value;
}
export async function navigateTo(opts: CdpCommandOptions, url: string): Promise<void> {
await sendCdpCommand(opts, 'Page.navigate', { url });
}
export async function getPageInfo(opts: CdpCommandOptions): Promise<{ title: string; url: string }> {
const result = (await sendCdpCommand(opts, 'Runtime.evaluate', {
expression: 'JSON.stringify({ title: document.title, url: location.href })',
returnByValue: true,
})) as { result?: { value?: string } };
try {
return JSON.parse(result.result?.value ?? '{}');
} catch {
return { title: '', url: '' };
}
}
+72
View File
@@ -0,0 +1,72 @@
import { API_URL } from '@@/officer-url.mjs';
import { createSidecarConnector } from '@@/sidecar/connect';
import { startBrowserRelay, stopBrowserRelay, getUserRelayStatus, getUserTargets, getRelayPort } from './relay';
import { registerUserToken, unregisterUserToken, deriveRelayToken } from './relay-auth';
import { BROWSER_SIDECAR_KIND, RELAY_PORT, type BrowserCommand, type BrowserEvent } from './protocol';
// The browser relay, as its own process.
//
// It was a second `Bun.serve` inside the platform, on its own port, and that is exactly why it was
// switched off on 2026-08-13 — "the platform serves one port". A sidecar is the shape that objection
// asks for, and it is what this is now.
//
// Two listeners live here: the extension's WebSocket (RELAY_PORT, fixed — see protocol.ts) and the
// registration socket back to the platform, which is how `api/router.ts` asks it anything.
type ReplyFn = (msg: BrowserEvent) => void;
async function handleCommand(cmd: BrowserCommand, reply: ReplyFn): Promise<void> {
try {
switch (cmd.type) {
case 'ping':
return reply({ type: 'pong', id: cmd.id });
case 'relay:status': {
const status = getUserRelayStatus(cmd.userId);
return reply({ type: 'relay:status', id: cmd.id, ...status });
}
case 'relay:targets':
return reply({ type: 'relay:targets', id: cmd.id, targets: getUserTargets(cmd.userId) });
case 'relay:token-add': {
// Derived here rather than sent over the wire: the key lives in this process, and a token that
// crossed the boundary would be a credential in a log the moment anything traced these messages.
const token = registerUserToken(cmd.userId, getRelayPort(), cmd.salt);
return reply({ type: 'relay:token', id: cmd.id, token, port: getRelayPort() });
}
case 'relay:token-remove': {
unregisterUserToken(deriveRelayToken(cmd.userId, getRelayPort(), cmd.salt));
return reply({ type: 'relay:ok', id: cmd.id });
}
default:
return reply({ type: 'error', id: (cmd as { id: string }).id, error: `unknown command` });
}
} catch (err) {
reply({ type: 'error', id: cmd.id, error: err instanceof Error ? err.message : String(err) });
}
}
await startBrowserRelay(RELAY_PORT);
console.log(`[browser-relay] listening for extensions on ${RELAY_PORT}`);
const connection = createSidecarConnector({
apiUrl: `${API_URL}/api/sidecar/register`,
name: 'browser-relay',
handles: [BROWSER_SIDECAR_KIND],
onCommand(cmd, reply) {
void handleCommand(cmd as BrowserCommand, reply as ReplyFn);
},
});
function shutdown(signal: string) {
console.log(`[browser-relay] ${signal} received, closing`);
stopBrowserRelay();
connection.destroy();
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
+44
View File
@@ -0,0 +1,44 @@
import type { ConnectedTarget } from './relay-types';
// What the api half says to the relay process, and what comes back.
//
// The relay used to run INSIDE the platform, so `api/router.ts` simply called `getUserRelayStatus()`.
// It cannot now: the relay is its own process because it owns a second listener, and "the platform
// serves one port" is why the whole thing was switched off in the first place.
//
// So this is the boundary. Small on purpose — four questions and one passthrough — because everything
// stateful stays on the relay side where the sockets are.
export type BrowserCommand =
| { type: 'ping'; id: string }
| { type: 'relay:status'; id: string; userId: number }
| { type: 'relay:targets'; id: string; userId: number }
/** Register a derived token so the relay will accept an extension presenting it. */
| { type: 'relay:token-add'; id: string; userId: number; salt: string }
| { type: 'relay:token-remove'; id: string; userId: number; salt: string };
// NOT here: CDP. `cdp.ts` opens its own WebSocket to the relay's /cdp endpoint with a derived token, which
// already crosses the process boundary — routing screenshots through this channel would put page bytes
// through the platform for no reason.
export type BrowserEvent =
| { type: 'pong'; id: string }
| { type: 'error'; id: string; error: string }
| { type: 'relay:status'; id: string; extensionConnected: boolean; targetCount: number }
| { type: 'relay:targets'; id: string; targets: ConnectedTarget[] }
| { type: 'relay:token'; id: string; token: string; port: number }
| { type: 'relay:ok'; id: string };
export const BROWSER_SIDECAR_KIND = 'browser-relay';
/**
* The port the extension connects to.
*
* NOT kernel-assigned, unlike every other sidecar's listener. The extension is configured by hand and
* stores the number, so a port that changed each restart would break the pairing each restart — and the
* port is an input to the token HMAC, so changing it invalidates every paired browser silently. The
* extension then reports "Relay not reachable", which SETUP.md attributes to a wrong address or token.
*
* 18792 is what it was as `BROWSER_RELAY_PORT` before the extraction, kept so any extension configured
* back then still points at the right place.
*/
export const RELAY_PORT = Number(process.env.BROWSER_RELAY_PORT ?? 18792);
+48
View File
@@ -0,0 +1,48 @@
import { createHmac } from 'node:crypto';
import { getKey } from 'officerdb/secret-store';
const RELAY_TOKEN_CONTEXT = 'officer-browser-relay-v1';
// ── Its own key, not the platform's ──
//
// This derived tokens from the platform's session signing key, and the file said, in this very
// comment, that an extraction should stop doing that: "a plugin deriving tokens from the platform's
// signing key is the coupling the per-purpose split exists to remove". Done now.
//
// Changing the key changes every derived token, so this is normally a breaking change of the same kind as
// changing the port. It is free today and only today: the relay has been off since 2026-08-13 and there
// are no pairings to invalidate — checked in Postgres before the change, not assumed.
//
// `getKey` mints a random value for a purpose that has none yet and stores it, which is right here: this
// key protects nothing but its own tokens, and a fresh install having a fresh one is correct.
export function deriveRelayToken(userId: number, port: number, salt: string): string {
return createHmac('sha256', getKey('browser-relay')).update(`${RELAY_TOKEN_CONTEXT}:${port}:${userId}:${salt}`).digest('hex');
}
const tokenToUser = new Map<string, number>();
export function registerUserToken(userId: number, port: number, salt: string): string {
const token = deriveRelayToken(userId, port, salt);
tokenToUser.set(token, userId);
return token;
}
export function unregisterUserToken(token: string): void {
tokenToUser.delete(token);
}
export function restoreUserTokens(entries: Array<{ userId: number; salt: string }>, port: number): void {
for (const { userId, salt } of entries) {
const token = deriveRelayToken(userId, port, salt);
tokenToUser.set(token, userId);
}
}
export function resolveUserFromToken(token: string): number | null {
return tokenToUser.get(token) ?? null;
}
export function getRelayAuthHeader(): string {
return 'x-officer-relay-token';
}
+89
View File
@@ -0,0 +1,89 @@
export type TargetInfo = {
targetId: string;
type?: string;
title?: string;
url?: string;
attached?: boolean;
};
export type ConnectedTarget = {
sessionId: string;
targetId: string;
targetInfo: TargetInfo;
};
export type RelayStatus = {
extensionConnected: boolean;
targetCount: number;
};
export type CdpCommand = {
id: number;
method: string;
params?: unknown;
sessionId?: string;
};
export type CdpResponse = {
id: number;
result?: unknown;
error?: { message: string };
sessionId?: string;
};
export type CdpEvent = {
method: string;
params?: unknown;
sessionId?: string;
};
export type ExtensionForwardCommandMessage = {
id: number;
method: 'forwardCDPCommand';
params: { method: string; params?: unknown; sessionId?: string };
};
export type ExtensionResponseMessage = {
id: number;
result?: unknown;
error?: string;
};
export type ExtensionForwardEventMessage = {
method: 'forwardCDPEvent';
params: { method: string; params?: unknown; sessionId?: string };
};
export type ExtensionPingMessage = { method: 'ping' };
export type ExtensionPongMessage = { method: 'pong' };
export type ExtensionMessage = ExtensionResponseMessage | ExtensionForwardEventMessage | ExtensionPongMessage;
export type AttachedToTargetEvent = {
sessionId: string;
targetInfo: TargetInfo;
waitingForDebugger?: boolean;
};
export type DetachedFromTargetEvent = {
sessionId: string;
targetId?: string;
};
export type UserRelay = {
extensionWs: unknown | null;
cdpClients: Set<unknown>;
connectedTargets: Map<string, ConnectedTarget>;
pingInterval: Timer | null;
gracePeriodTimer: Timer | null;
pendingExtension: Map<
number,
{
resolve: (v: unknown) => void;
reject: (e: Error) => void;
timer: Timer;
}
>;
nextExtensionId: number;
extensionReconnectWaiters: Set<(connected: boolean) => void>;
};
+675
View File
@@ -0,0 +1,675 @@
// Based on OpenClaw Browser Extension Relay (MIT License)
// https://github.com/nicepkg/openclaw
//
// Rewritten for Bun.serve() with multi-user support.
import type { ServerWebSocket } from 'bun';
import type {
AttachedToTargetEvent,
CdpCommand,
CdpEvent,
CdpResponse,
ConnectedTarget,
DetachedFromTargetEvent,
ExtensionForwardCommandMessage,
ExtensionForwardEventMessage,
ExtensionMessage,
ExtensionPongMessage,
} from './relay-types';
import { allPairings } from '../db/queries';
import { getRelayAuthHeader, resolveUserFromToken, restoreUserTokens } from './relay-auth';
const RELAY_AUTH_HEADER = getRelayAuthHeader();
const DEFAULT_EXTENSION_RECONNECT_GRACE_MS = 5_000;
const DEFAULT_EXTENSION_COMMAND_RECONNECT_WAIT_MS = 3_000;
type WSData = {
kind: 'extension' | 'cdp';
userId: number;
token: string;
};
type UserRelay = {
extensionWs: ServerWebSocket<WSData> | null;
cdpClients: Set<ServerWebSocket<WSData>>;
connectedTargets: Map<string, ConnectedTarget>;
pingInterval: Timer | null;
gracePeriodTimer: Timer | null;
pendingExtension: Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: Timer }>;
nextExtensionId: number;
extensionReconnectWaiters: Set<(connected: boolean) => void>;
};
const userRelays = new Map<number, UserRelay>();
function getUserRelay(userId: number): UserRelay {
let relay = userRelays.get(userId);
if (!relay) {
relay = {
extensionWs: null,
cdpClients: new Set(),
connectedTargets: new Map(),
pingInterval: null,
gracePeriodTimer: null,
pendingExtension: new Map(),
nextExtensionId: 1,
extensionReconnectWaiters: new Set(),
};
userRelays.set(userId, relay);
}
return relay;
}
function extensionConnected(relay: UserRelay): boolean {
return relay.extensionWs !== null && relay.extensionWs.readyState === WebSocket.OPEN;
}
function flushExtensionReconnectWaiters(relay: UserRelay, connected: boolean) {
if (relay.extensionReconnectWaiters.size === 0) return;
const waiters = Array.from(relay.extensionReconnectWaiters);
relay.extensionReconnectWaiters.clear();
for (const waiter of waiters) waiter(connected);
}
function clearGracePeriodTimer(relay: UserRelay) {
if (!relay.gracePeriodTimer) return;
clearTimeout(relay.gracePeriodTimer);
relay.gracePeriodTimer = null;
}
function closeCdpClientsAfterExtensionDisconnect(relay: UserRelay) {
relay.connectedTargets.clear();
for (const client of relay.cdpClients) {
try {
client.close(1011, 'extension disconnected');
} catch {
// ignore
}
}
relay.cdpClients.clear();
flushExtensionReconnectWaiters(relay, false);
}
function scheduleExtensionDisconnectCleanup(relay: UserRelay) {
clearGracePeriodTimer(relay);
relay.gracePeriodTimer = setTimeout(() => {
relay.gracePeriodTimer = null;
if (extensionConnected(relay)) return;
closeCdpClientsAfterExtensionDisconnect(relay);
}, DEFAULT_EXTENSION_RECONNECT_GRACE_MS);
}
async function waitForExtensionReconnect(relay: UserRelay, timeoutMs: number): Promise<boolean> {
if (extensionConnected(relay)) return true;
return await new Promise<boolean>((resolve) => {
let settled = false;
const waiter = (connected: boolean) => {
if (settled) return;
settled = true;
clearTimeout(timer);
relay.extensionReconnectWaiters.delete(waiter);
resolve(connected);
};
const timer = setTimeout(() => waiter(false), timeoutMs);
relay.extensionReconnectWaiters.add(waiter);
});
}
function sendToExtension(relay: UserRelay, payload: ExtensionForwardCommandMessage): Promise<unknown> {
const ws = relay.extensionWs;
if (!ws || ws.readyState !== WebSocket.OPEN) {
throw new Error('Chrome extension not connected');
}
ws.send(JSON.stringify(payload));
return new Promise<unknown>((resolve, reject) => {
const timer = setTimeout(() => {
relay.pendingExtension.delete(payload.id);
reject(new Error(`extension request timeout: ${payload.params.method}`));
}, 30_000);
relay.pendingExtension.set(payload.id, { resolve, reject, timer });
});
}
function broadcastToCdpClients(relay: UserRelay, evt: CdpEvent) {
const msg = JSON.stringify(evt);
for (const ws of relay.cdpClients) {
if (ws.readyState !== WebSocket.OPEN) continue;
ws.send(msg);
}
}
function sendResponseToCdp(ws: ServerWebSocket<WSData>, res: CdpResponse) {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify(res));
}
function ensureTargetEventsForClient(relay: UserRelay, ws: ServerWebSocket<WSData>, mode: 'autoAttach' | 'discover') {
for (const target of relay.connectedTargets.values()) {
if (mode === 'autoAttach') {
ws.send(
JSON.stringify({
method: 'Target.attachedToTarget',
params: {
sessionId: target.sessionId,
targetInfo: { ...target.targetInfo, attached: true },
waitingForDebugger: false,
},
} satisfies CdpEvent),
);
} else {
ws.send(
JSON.stringify({
method: 'Target.targetCreated',
params: { targetInfo: { ...target.targetInfo, attached: true } },
} satisfies CdpEvent),
);
}
}
}
async function routeCdpCommand(relay: UserRelay, cmd: CdpCommand): Promise<unknown> {
switch (cmd.method) {
case 'Browser.getVersion':
return {
protocolVersion: '1.3',
product: 'Chrome/Officer-Browser-Relay',
revision: '0',
userAgent: 'Officer-Browser-Relay',
jsVersion: 'V8',
};
case 'Browser.setDownloadBehavior':
return {};
case 'Target.setAutoAttach':
case 'Target.setDiscoverTargets':
return {};
case 'Target.getTargets':
return {
targetInfos: Array.from(relay.connectedTargets.values()).map((t) => ({
...t.targetInfo,
attached: true,
})),
};
case 'Target.getTargetInfo': {
const params = (cmd.params ?? {}) as { targetId?: string };
const targetId = typeof params.targetId === 'string' ? params.targetId : undefined;
if (targetId) {
for (const t of relay.connectedTargets.values()) {
if (t.targetId === targetId) return { targetInfo: t.targetInfo };
}
}
if (cmd.sessionId && relay.connectedTargets.has(cmd.sessionId)) {
const t = relay.connectedTargets.get(cmd.sessionId);
if (t) return { targetInfo: t.targetInfo };
}
const first = Array.from(relay.connectedTargets.values())[0];
return { targetInfo: first?.targetInfo };
}
case 'Target.attachToTarget': {
const params = (cmd.params ?? {}) as { targetId?: string };
const targetId = typeof params.targetId === 'string' ? params.targetId : undefined;
if (!targetId) throw new Error('targetId required');
for (const t of relay.connectedTargets.values()) {
if (t.targetId === targetId) return { sessionId: t.sessionId };
}
throw new Error('target not found');
}
default: {
const id = relay.nextExtensionId++;
return await sendToExtension(relay, {
id,
method: 'forwardCDPCommand',
params: {
method: cmd.method,
sessionId: cmd.sessionId,
params: cmd.params,
},
});
}
}
}
function handleExtensionMessage(relay: UserRelay, data: string) {
let parsed: ExtensionMessage | null = null;
try {
parsed = JSON.parse(data) as ExtensionMessage;
} catch {
return;
}
if (parsed && typeof parsed === 'object' && 'id' in parsed && typeof parsed.id === 'number') {
const pending = relay.pendingExtension.get(parsed.id);
if (!pending) return;
relay.pendingExtension.delete(parsed.id);
clearTimeout(pending.timer);
if ('error' in parsed && typeof parsed.error === 'string' && parsed.error.trim()) {
pending.reject(new Error(parsed.error));
} else {
pending.resolve(parsed.result);
}
return;
}
if (parsed && typeof parsed === 'object' && 'method' in parsed) {
if ((parsed as ExtensionPongMessage).method === 'pong') return;
if ((parsed as ExtensionForwardEventMessage).method !== 'forwardCDPEvent') return;
const evt = parsed as ExtensionForwardEventMessage;
const method = evt.params?.method;
const params = evt.params?.params;
const sessionId = evt.params?.sessionId;
if (!method || typeof method !== 'string') return;
if (method === 'Target.attachedToTarget') {
const attached = (params ?? {}) as AttachedToTargetEvent;
const targetType = attached?.targetInfo?.type ?? 'page';
if (targetType !== 'page') return;
if (attached?.sessionId && attached?.targetInfo?.targetId) {
const prev = relay.connectedTargets.get(attached.sessionId);
const nextTargetId = attached.targetInfo.targetId;
const prevTargetId = prev?.targetId;
const changedTarget = Boolean(prev && prevTargetId && prevTargetId !== nextTargetId);
relay.connectedTargets.set(attached.sessionId, {
sessionId: attached.sessionId,
targetId: nextTargetId,
targetInfo: attached.targetInfo,
});
if (changedTarget && prevTargetId) {
broadcastToCdpClients(relay, {
method: 'Target.detachedFromTarget',
params: { sessionId: attached.sessionId, targetId: prevTargetId },
sessionId: attached.sessionId,
});
}
if (!prev || changedTarget) {
broadcastToCdpClients(relay, { method, params, sessionId });
}
return;
}
}
if (method === 'Target.detachedFromTarget') {
const detached = (params ?? {}) as DetachedFromTargetEvent;
if (detached?.sessionId) {
relay.connectedTargets.delete(detached.sessionId);
}
broadcastToCdpClients(relay, { method, params, sessionId });
return;
}
if (method === 'Target.targetInfoChanged') {
const changed = (params ?? {}) as { targetInfo?: { targetId?: string; type?: string } };
const targetInfo = changed?.targetInfo;
const targetId = targetInfo?.targetId;
if (targetId && (targetInfo?.type ?? 'page') === 'page') {
for (const [sid, target] of relay.connectedTargets) {
if (target.targetId !== targetId) continue;
relay.connectedTargets.set(sid, {
...target,
targetInfo: { ...target.targetInfo, ...(targetInfo as object) },
});
}
}
}
broadcastToCdpClients(relay, { method, params, sessionId });
}
}
function handleExtensionClose(relay: UserRelay, ws: ServerWebSocket<WSData>) {
if (relay.extensionWs !== ws) return;
if (relay.pingInterval) {
clearInterval(relay.pingInterval);
relay.pingInterval = null;
}
relay.extensionWs = null;
for (const [, pending] of relay.pendingExtension) {
clearTimeout(pending.timer);
pending.reject(new Error('extension disconnected'));
}
relay.pendingExtension.clear();
scheduleExtensionDisconnectCleanup(relay);
}
function handleExtensionOpen(relay: UserRelay, ws: ServerWebSocket<WSData>) {
relay.extensionWs = ws;
clearGracePeriodTimer(relay);
flushExtensionReconnectWaiters(relay, true);
relay.pingInterval = setInterval(() => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ method: 'ping' }));
}, 5000);
}
async function handleCdpMessage(relay: UserRelay, ws: ServerWebSocket<WSData>, data: string) {
let cmd: CdpCommand | null = null;
try {
cmd = JSON.parse(data) as CdpCommand;
} catch {
return;
}
if (!cmd || typeof cmd !== 'object') return;
if (typeof cmd.id !== 'number' || typeof cmd.method !== 'string') return;
if (!extensionConnected(relay)) {
const reconnected = await waitForExtensionReconnect(relay, DEFAULT_EXTENSION_COMMAND_RECONNECT_WAIT_MS);
if (!reconnected || !extensionConnected(relay)) {
sendResponseToCdp(ws, {
id: cmd.id,
sessionId: cmd.sessionId,
error: { message: 'Extension not connected' },
});
return;
}
}
try {
const result = await routeCdpCommand(relay, cmd);
if (cmd.method === 'Target.setAutoAttach' && !cmd.sessionId) {
ensureTargetEventsForClient(relay, ws, 'autoAttach');
}
if (cmd.method === 'Target.setDiscoverTargets') {
const discover = (cmd.params ?? {}) as { discover?: boolean };
if (discover.discover === true) {
ensureTargetEventsForClient(relay, ws, 'discover');
}
}
if (cmd.method === 'Target.attachToTarget') {
const params = (cmd.params ?? {}) as { targetId?: string };
const targetId = typeof params.targetId === 'string' ? params.targetId : undefined;
if (targetId) {
const target = Array.from(relay.connectedTargets.values()).find((t) => t.targetId === targetId);
if (target) {
ws.send(
JSON.stringify({
method: 'Target.attachedToTarget',
params: {
sessionId: target.sessionId,
targetInfo: { ...target.targetInfo, attached: true },
waitingForDebugger: false,
},
} satisfies CdpEvent),
);
}
}
}
sendResponseToCdp(ws, { id: cmd.id, sessionId: cmd.sessionId, result });
} catch (err) {
sendResponseToCdp(ws, {
id: cmd.id,
sessionId: cmd.sessionId,
error: { message: err instanceof Error ? err.message : String(err) },
});
}
}
function getTokenFromUrl(url: string): string | null {
try {
const parsed = new URL(url, 'http://localhost');
return parsed.searchParams.get('token')?.trim() ?? null;
} catch {
return null;
}
}
function getTokenFromHeaders(headers: Headers): string | null {
return headers.get(RELAY_AUTH_HEADER)?.trim() ?? null;
}
function getTokenFromRequest(req: Request): string | null {
return getTokenFromHeaders(req.headers) ?? getTokenFromUrl(req.url);
}
function authenticateRequest(req: Request): number | null {
const token = getTokenFromRequest(req);
if (!token) return null;
return resolveUserFromToken(token);
}
let relayServer: ReturnType<typeof Bun.serve> | null = null;
let relayPort = 0;
export function getRelayPort(): number {
return relayPort;
}
export function getUserRelayStatus(userId: number): { extensionConnected: boolean; targetCount: number } {
const relay = userRelays.get(userId);
if (!relay) return { extensionConnected: false, targetCount: 0 };
return {
extensionConnected: extensionConnected(relay),
targetCount: relay.connectedTargets.size,
};
}
export function getUserTargets(userId: number): ConnectedTarget[] {
const relay = userRelays.get(userId);
if (!relay) return [];
return Array.from(relay.connectedTargets.values());
}
export async function startBrowserRelay(port: number) {
relayPort = port;
// Rebuild the token map from the plugin's own table. Every paired browser's token is derived rather
// than stored, so this is what makes a restart transparent to an extension that is already configured.
const entries = await allPairings();
if (entries.length > 0) {
restoreUserTokens(entries, port);
}
const server = Bun.serve<WSData>({
port,
hostname: '0.0.0.0',
idleTimeout: 60,
routes: {
'/': (req: Request) => {
if (req.method === 'HEAD') return new Response(null, { status: 200 });
return new Response('OK', { status: 200, headers: { 'Content-Type': 'text/plain' } });
},
},
fetch(req, server) {
const url = new URL(req.url);
const path = url.pathname;
// CORS for chrome-extension origins
const origin = req.headers.get('origin');
const corsHeaders: Record<string, string> = {};
if (origin) {
corsHeaders['Access-Control-Allow-Origin'] = origin;
corsHeaders['Vary'] = 'Origin';
}
if (req.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
...corsHeaders,
'Access-Control-Allow-Methods': 'GET, PUT, POST, OPTIONS',
'Access-Control-Allow-Headers': `Content-Type, ${RELAY_AUTH_HEADER}`,
'Access-Control-Max-Age': '86400',
},
});
}
if (path === '/extension/status') {
const connectedCount = Array.from(userRelays.values()).filter((r) => extensionConnected(r)).length;
return Response.json({ connected: connectedCount > 0, users: connectedCount }, { headers: corsHeaders });
}
// Auth-protected endpoints
const token = getTokenFromRequest(req);
const userId = token ? resolveUserFromToken(token) : null;
if (path === '/json/version' || path === '/json/version/') {
if (userId === null) return new Response('Unauthorized', { status: 401, headers: corsHeaders });
const relay = userRelays.get(userId);
const payload: Record<string, unknown> = {
Browser: 'Officer/browser-relay',
'Protocol-Version': '1.3',
};
if (relay && extensionConnected(relay)) {
const hostHeader = req.headers.get('host')?.trim() || `0.0.0.0:${port}`;
payload.webSocketDebuggerUrl = `ws://${hostHeader}/cdp?token=${token}`;
}
return Response.json(payload, { headers: corsHeaders });
}
const listPaths = new Set(['/json', '/json/', '/json/list', '/json/list/']);
if (listPaths.has(path)) {
if (userId === null) return new Response('Unauthorized', { status: 401, headers: corsHeaders });
const relay = userRelays.get(userId);
const hostHeader = req.headers.get('host')?.trim() || `0.0.0.0:${port}`;
const cdpWsUrl = `ws://${hostHeader}/cdp?token=${token}`;
const list = relay
? Array.from(relay.connectedTargets.values()).map((t) => ({
id: t.targetId,
type: t.targetInfo.type ?? 'page',
title: t.targetInfo.title ?? '',
description: t.targetInfo.title ?? '',
url: t.targetInfo.url ?? '',
webSocketDebuggerUrl: cdpWsUrl,
}))
: [];
return Response.json(list, { headers: corsHeaders });
}
const activateMatch = path.match(/^\/json\/activate\/(.+)$/);
if (activateMatch) {
if (userId === null) return new Response('Unauthorized', { status: 401, headers: corsHeaders });
const targetId = decodeURIComponent(activateMatch[1]!).trim();
if (!targetId) return new Response('targetId required', { status: 400, headers: corsHeaders });
const relay = userRelays.get(userId);
if (relay && extensionConnected(relay)) {
void sendToExtension(relay, {
id: relay.nextExtensionId++,
method: 'forwardCDPCommand',
params: { method: 'Target.activateTarget', params: { targetId } },
}).catch(() => {});
}
return new Response('OK', { status: 200, headers: corsHeaders });
}
const closeMatch = path.match(/^\/json\/close\/(.+)$/);
if (closeMatch) {
if (userId === null) return new Response('Unauthorized', { status: 401, headers: corsHeaders });
const targetId = decodeURIComponent(closeMatch[1]!).trim();
if (!targetId) return new Response('targetId required', { status: 400, headers: corsHeaders });
const relay = userRelays.get(userId);
if (relay && extensionConnected(relay)) {
void sendToExtension(relay, {
id: relay.nextExtensionId++,
method: 'forwardCDPCommand',
params: { method: 'Target.closeTarget', params: { targetId } },
}).catch(() => {});
}
return new Response('OK', { status: 200, headers: corsHeaders });
}
// WebSocket upgrades
if (path === '/extension') {
if (userId === null) return new Response('Unauthorized', { status: 401, headers: corsHeaders });
const relay = getUserRelay(userId);
// Close existing extension WS (stale or active) — last connection wins
if (relay.extensionWs) {
try {
relay.extensionWs.close(4000, 'replaced by new connection');
} catch {
// ignore
}
relay.extensionWs = null;
}
const ok = server.upgrade(req, { data: { kind: 'extension', userId, token: token! } });
if (!ok) return new Response('Upgrade failed', { status: 500, headers: corsHeaders });
return undefined as unknown as Response;
}
if (path === '/cdp') {
if (userId === null) return new Response('Unauthorized', { status: 401, headers: corsHeaders });
const relay = userRelays.get(userId);
if (!relay || !extensionConnected(relay)) {
return new Response('Extension not connected', { status: 503, headers: corsHeaders });
}
const ok = server.upgrade(req, { data: { kind: 'cdp', userId, token: token! } });
if (!ok) return new Response('Upgrade failed', { status: 500, headers: corsHeaders });
return undefined as unknown as Response;
}
return new Response('Not found', { status: 404, headers: corsHeaders });
},
websocket: {
open(ws) {
const { kind, userId } = ws.data;
const relay = getUserRelay(userId);
if (kind === 'extension') {
handleExtensionOpen(relay, ws);
} else {
relay.cdpClients.add(ws);
}
},
message(ws, raw) {
const { kind, userId } = ws.data;
const relay = userRelays.get(userId);
if (!relay) return;
const data = typeof raw === 'string' ? raw : new TextDecoder().decode(raw as unknown as ArrayBuffer);
if (kind === 'extension') {
handleExtensionMessage(relay, data);
} else {
void handleCdpMessage(relay, ws, data);
}
},
close(ws) {
const { kind, userId } = ws.data;
const relay = userRelays.get(userId);
if (!relay) return;
if (kind === 'extension') {
handleExtensionClose(relay, ws);
} else {
relay.cdpClients.delete(ws);
}
},
},
});
relayServer = server;
return server;
}
export function stopBrowserRelay() {
if (!relayServer) return;
for (const [, relay] of userRelays) {
clearGracePeriodTimer(relay);
if (relay.pingInterval) clearInterval(relay.pingInterval);
flushExtensionReconnectWaiters(relay, false);
for (const [, pending] of relay.pendingExtension) {
clearTimeout(pending.timer);
pending.reject(new Error('server stopping'));
}
relay.pendingExtension.clear();
try {
relay.extensionWs?.close(1001, 'server stopping');
} catch {
// ignore
}
for (const ws of relay.cdpClients) {
try {
ws.close(1001, 'server stopping');
} catch {
// ignore
}
}
}
userRelays.clear();
relayServer.stop();
relayServer = null;
}