Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 133 additions & 14 deletions packages/bcode-browser/src/cdp/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { bindDomains, type Domains, type Transport } from './generated.ts';

type Pending = {
ws: WebSocket;
resolve: (v: unknown) => void;
reject: (e: unknown) => void;
};
Expand Down Expand Up @@ -46,6 +47,9 @@ export class Session implements Transport {
private nextId = 1;
private pending = new Map<number, Pending>();
private activeSessionId: string | undefined;
private activeTargetId: string | undefined;
private reattachPromise?: Promise<void>;
private enabledDomains = new Map<string, Map<string, unknown>>();
private eventListeners: Array<(method: string, params: unknown, sessionId?: string) => void> = [];
private callResultListeners: Array<(method: string, params: unknown, result: unknown) => void> = [];

Expand Down Expand Up @@ -124,15 +128,33 @@ export class Session implements Transport {
else res();
};
const timer = setTimeout(() => finish(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs);
ws.addEventListener('open', () => finish());
ws.addEventListener('open', () => {
if (done) {
try { ws.close(); } catch { /* ignore */ }
return;
}
const previous = this.ws;
this.ws = ws;
this.activeSessionId = undefined;
this.activeTargetId = undefined;
this.enabledDomains.clear();
finish();
if (previous && previous !== ws) {
try { previous.close(); } catch { /* ignore */ }
}
});
ws.addEventListener('error', (e) => finish(new Error(`WS error: ${(e as any)?.message ?? 'connect failed (likely 403, permission not granted, or port closed)'}`)));
ws.addEventListener('message', (e) => this.onMessage(String(e.data)));
ws.addEventListener('message', (e) => this.onMessage(String(e.data), ws));
ws.addEventListener('close', () => {
for (const [, p] of this.pending) p.reject(new Error('CDP socket closed'));
this.pending.clear();
this.rejectPending(ws, new Error('CDP socket closed'));
if (this.ws === ws) {
this.ws = undefined;
this.activeSessionId = undefined;
this.activeTargetId = undefined;
this.enabledDomains.clear();
}
finish(new Error('WS closed before open (likely 403 or port closed)'));
});
this.ws = ws;
});
}

Expand All @@ -151,12 +173,14 @@ export class Session implements Transport {
async use(targetId: string): Promise<string> {
const r = await this._call('Target.attachToTarget', { targetId, flatten: true }) as { sessionId: string };
this.activeSessionId = r.sessionId;
this.activeTargetId = targetId;
return r.sessionId;
}

/** Set the active sessionId directly (e.g. one you already attached). */
setActiveSession(sessionId: string | undefined): void {
this.activeSessionId = sessionId;
this.activeTargetId = undefined;
}

getActiveSession(): string | undefined {
Expand Down Expand Up @@ -206,35 +230,116 @@ export class Session implements Transport {
}

// Transport implementation. Called by the generated domain bindings.
_call(method: string, params: unknown = {}): Promise<unknown> {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
async _call(method: string, params: unknown = {}): Promise<unknown> {
const browserLevel = isBrowserLevel(method);
const sentSessionId = browserLevel ? undefined : this.activeSessionId;
const sentTargetId = browserLevel ? undefined : this.activeTargetId;
try {
return await this.send(method, params, sentSessionId);
} catch (error) {
if (!sentSessionId || !isMissingSessionError(error)) throw error;

// Chrome explicitly rejected the command before executing it, so this is
// safe to retry once. Socket drops are deliberately not retried: Chrome
// may have applied a click or submission before the response was lost.
if (this.activeSessionId === sentSessionId) {
await this.reattachPage(sentSessionId, sentTargetId);
}
else if (this.reattachPromise) await this.reattachPromise;
if (!this.activeSessionId || this.activeSessionId === sentSessionId) throw error;
return this.send(method, params, this.activeSessionId);
}
}

private send(method: string, params: unknown, sessionId?: string): Promise<unknown> {
const ws = this.ws;
if (!ws || ws.readyState !== WebSocket.OPEN) {
return Promise.reject(new Error('Not connected. Call session.connect(...) first.'));
}

const id = this.nextId++;
const msg: Record<string, unknown> = { id, method, params: params ?? {} };
if (this.activeSessionId && !isBrowserLevel(method)) {
msg.sessionId = this.activeSessionId;
}
if (sessionId) msg.sessionId = sessionId;
return new Promise((resolve, reject) => {
this.pending.set(id, {
ws,
resolve: (v) => {
this.recordDomainState(method, params, sessionId);
for (const fn of this.callResultListeners) {
try { fn(method, params, v); } catch { /* ignore */ }
}
resolve(v);
},
reject,
});
this.ws!.send(JSON.stringify(msg));
try {
ws.send(JSON.stringify(msg));
} catch (error) {
this.pending.delete(id);
reject(error);
}
});
}

private onMessage(raw: string): void {
private async reattachPage(staleSessionId: string, staleTargetId?: string): Promise<void> {
if (this.reattachPromise) return this.reattachPromise;

const attempt = this.attachPage(staleSessionId, staleTargetId);
this.reattachPromise = attempt;
try {
await attempt;
} finally {
if (this.reattachPromise === attempt) this.reattachPromise = undefined;
}
}

private async attachPage(staleSessionId: string, staleTargetId?: string): Promise<void> {
const domainsToRestore = [...(this.enabledDomains.get(staleSessionId)?.entries() ?? [])];
const { targetInfos } = await this.domains.Target.getTargets({});
const pages = targetInfos as PageTarget[];
const exactTarget = staleTargetId
? pages.find(target => target.type === 'page' && target.targetId === staleTargetId)
: undefined;
const targetId = exactTarget?.targetId
?? (!staleTargetId ? pages.find(isUsablePageTarget)?.targetId : undefined)
?? (await this.domains.Target.createTarget({ url: 'about:blank' })).targetId;
const sessionId = await this.use(targetId);
await Promise.all(
domainsToRestore.map(
([method, params]) => this.send(method, params, sessionId),
),
);
this.enabledDomains.delete(staleSessionId);
}

private recordDomainState(method: string, params: unknown, sessionId?: string): void {
if (!sessionId) return;
const match = /^([^.]+)\.(enable|disable)$/.exec(method);
if (!match) return;
const [, domain, command] = match;
if (!domain || !command) return;
const enabled = this.enabledDomains.get(sessionId) ?? new Map<string, unknown>();
if (command === 'enable') enabled.set(`${domain}.enable`, params);
else enabled.delete(`${domain}.enable`);
if (enabled.size > 0) this.enabledDomains.set(sessionId, enabled);
else this.enabledDomains.delete(sessionId);
}

private rejectPending(ws: WebSocket, error: Error): void {
for (const [id, pending] of this.pending) {
if (pending.ws !== ws) continue;
this.pending.delete(id);
pending.reject(error);
}
}

private onMessage(raw: string, ws: WebSocket): void {
if (ws !== this.ws) return;
let m: any;
try { m = JSON.parse(raw); } catch { return; }
if (typeof m.id === 'number') {
const p = this.pending.get(m.id);
if (!p) return;
if (!p || p.ws !== ws) return;
this.pending.delete(m.id);
if (m.error) p.reject(new CdpError(m.error.code, m.error.message, m.error.data));
else p.resolve(m.result);
Expand All @@ -258,6 +363,12 @@ function isBrowserLevel(method: string): boolean {
return method.startsWith('Browser.') || method.startsWith('Target.');
}

function isMissingSessionError(error: unknown): boolean {
return error instanceof CdpError
&& error.code === -32001
&& error.message.includes('Session with given id not found');
}

/**
* Resolve a WebSocket URL for one of the explicit connect forms:
* { wsUrl } — passthrough.
Expand Down Expand Up @@ -329,6 +440,15 @@ export async function listPageTargets(session: Session): Promise<PageTarget[]> {
);
}

function isUsablePageTarget(target: PageTarget): boolean {
return target.type === 'page'
&& !target.url.startsWith('chrome://')
&& !target.url.startsWith('chrome-untrusted://')
&& !target.url.startsWith('devtools://')
&& !target.url.startsWith('chrome-extension://')
&& (!target.url.startsWith('about:') || target.url === 'about:blank');
}

/**
* Scan OS-specific user-data directories for Chromium-based browsers that
* currently have remote debugging enabled (a `DevToolsActivePort` file exists
Expand Down Expand Up @@ -423,4 +543,3 @@ async function tryReadDevToolsActivePort(
return undefined;
}
}

Loading
Loading