Skip to content
Merged
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
37 changes: 37 additions & 0 deletions packages/browseros-agent/apps/server/src/browser/backends/cdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ class CdpBackend implements ICdpBackend {
private reconnecting = false
private reconnectRequested = false
private eventHandlers = new Map<string, ((params: unknown) => void)[]>()
private sessionEventHandlers = new Map<
string,
((params: unknown, sessionId: string) => void)[]
>()
private sessionCache = new Map<string, ProtocolApi>()
private keepaliveTimer: ReturnType<typeof setInterval> | null = null
private preferredDiscoveryHost: LoopbackDiscoveryHost | null = null
Expand Down Expand Up @@ -432,15 +436,37 @@ class CdpBackend implements ICdpBackend {
}
}

onSessionEvent(
event: string,
handler: (params: unknown, sessionId: string) => void,
): () => void {
if (!this.sessionEventHandlers.has(event)) {
this.sessionEventHandlers.set(event, [])
}
const handlers = this.sessionEventHandlers.get(event)
if (handlers) {
handlers.push(handler)
}
return () => {
const list = this.sessionEventHandlers.get(event)
if (list) {
const idx = list.indexOf(handler)
if (idx !== -1) list.splice(idx, 1)
}
}
}

private handleMessage(data: string): void {
const message = JSON.parse(data) as {
id?: number
method?: string
params?: unknown
result?: unknown
error?: { message: string; code: number }
sessionId?: string
}

// Route responses to pending requests
if (message.id !== undefined) {
const pending = this.pending.get(message.id)
if (pending) {
Expand All @@ -453,12 +479,23 @@ class CdpBackend implements ICdpBackend {
}
}
} else if (message.method) {
// Dispatch to global event handlers
const handlers = this.eventHandlers.get(message.method)
if (handlers) {
for (const handler of handlers) {
handler(message.params)
}
}

// Dispatch to session-aware handlers when sessionId is present
if (message.sessionId) {
const sessionHandlers = this.sessionEventHandlers.get(message.method)
if (sessionHandlers) {
for (const handler of sessionHandlers) {
handler(message.params, message.sessionId)
}
}
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ export interface CdpBackend extends ProtocolApi {
isConnected(): boolean
getTargets(): Promise<CdpTarget[]>
session(sessionId: string): ProtocolApi
onSessionEvent(
event: string,
handler: (params: unknown, sessionId: string) => void,
): () => void
}

export interface ControllerBackend {
Expand Down
29 changes: 27 additions & 2 deletions packages/browseros-agent/apps/server/src/browser/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import { logger } from '../lib/logger'
import type { CdpBackend, ControllerBackend } from './backends/types'
import type { BookmarkNode } from './bookmarks'
import * as bookmarks from './bookmarks'
import {
ConsoleCollector,
type GetConsoleLogsOptions,
type GetConsoleLogsResult,
} from './console-collector'
import {
buildContentMarkdownExpression,
type ContentMarkdownOptions,
Expand Down Expand Up @@ -84,13 +89,15 @@ export class Browser {
private cdp: CdpBackend
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: kept for later removal
private controller: ControllerBackend
private consoleCollector: ConsoleCollector
private pages = new Map<number, PageInfo>()
private sessions = new Map<string, string>()
private nextPageId = 1

constructor(cdp: CdpBackend, controller: ControllerBackend) {
this.cdp = cdp
this.controller = controller
this.consoleCollector = new ConsoleCollector(cdp)
this.setupEventHandlers()
}

Expand Down Expand Up @@ -123,11 +130,14 @@ export class Browser {
throw new Error(
`Unknown page ${page}. Use list_pages to see available pages.`,
)
const sessionId = await this.attachToPage(info.targetId)
const sessionId = await this.attachToPage(info.targetId, page)
return this.cdp.session(sessionId)
}

private async attachToPage(targetId: string): Promise<string> {
private async attachToPage(
targetId: string,
pageId: number,
): Promise<string> {
const cached = this.sessions.get(targetId)
if (cached) return cached

Expand All @@ -143,10 +153,13 @@ export class Browser {
session.Page.enable(),
session.DOM.enable(),
session.Runtime.enable(),
session.Log.enable(),
session.Accessibility.enable(),
])

this.sessions.set(targetId, sessionId)
this.consoleCollector.attach(pageId, sessionId)

return sessionId
}

Expand Down Expand Up @@ -204,6 +217,7 @@ export class Browser {

for (const [pageId, info] of this.pages) {
if (!seenTargetIds.has(info.targetId)) {
this.consoleCollector.detach(pageId)
this.pages.delete(pageId)
}
}
Expand Down Expand Up @@ -290,6 +304,7 @@ export class Browser {
`Unknown page ${page}. Use list_pages to see available pages.`,
)
await this.cdp.Browser.closeTab({ tabId: info.tabId })
this.consoleCollector.detach(page)
this.pages.delete(page)
this.sessions.delete(info.targetId)
}
Expand Down Expand Up @@ -1236,4 +1251,14 @@ export class Browser {
async closeTabGroup(groupId: string): Promise<void> {
return tabGroups.closeTabGroup(this.cdp, groupId)
}

// --- Console ---

async getConsoleLogs(
page: number,
opts?: GetConsoleLogsOptions,
): Promise<GetConsoleLogsResult> {
await this.resolveSession(page)
return this.consoleCollector.getLogs(page, opts)
}
}
219 changes: 219 additions & 0 deletions packages/browseros-agent/apps/server/src/browser/console-collector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import type { EntryAddedEvent } from '@browseros/cdp-protocol/domains/log'
import type {
ConsoleAPICalledEvent,
ExceptionThrownEvent,
RemoteObject,
} from '@browseros/cdp-protocol/domains/runtime'
import { CONTENT_LIMITS } from '@browseros/shared/constants/limits'
import type { CdpBackend } from './backends/types'

export type ConsoleLevel = 'error' | 'warning' | 'info' | 'debug'

export interface ConsoleEntry {
source: 'console' | 'exception' | 'browser'
level: ConsoleLevel
text: string
url?: string
lineNumber?: number
timestamp: number
}

export interface GetConsoleLogsOptions {
level?: ConsoleLevel
search?: string
limit?: number
clear?: boolean
}

export interface GetConsoleLogsResult {
entries: ConsoleEntry[]
totalCount: number
}

// Lower number = higher severity
const LEVEL_PRIORITY: Record<ConsoleLevel, number> = {
error: 0,
warning: 1,
info: 2,
debug: 3,
}

const CONSOLE_TYPE_TO_LEVEL: Record<string, ConsoleLevel> = {
error: 'error',
assert: 'error',
warning: 'warning',
log: 'info',
info: 'info',
dir: 'info',
dirxml: 'info',
table: 'info',
count: 'info',
timeEnd: 'info',
debug: 'debug',
trace: 'debug',
clear: 'debug',
startGroup: 'debug',
startGroupCollapsed: 'debug',
endGroup: 'debug',
profile: 'debug',
profileEnd: 'debug',
}

const LOG_LEVEL_MAP: Record<string, ConsoleLevel> = {
error: 'error',
warning: 'warning',
info: 'info',
verbose: 'debug',
}

export class ConsoleCollector {
private readonly buffers = new Map<number, ConsoleEntry[]>()
private readonly sessionToPage = new Map<string, number>()
private readonly pageToSession = new Map<number, string>()
private readonly maxEntries = CONTENT_LIMITS.CONSOLE_BUFFER_MAX_ENTRIES

constructor(cdp: CdpBackend) {
// Single handler per event type — O(1) routing via sessionToPage lookup
cdp.onSessionEvent('Runtime.consoleAPICalled', (params, sessionId) => {
const pageId = this.sessionToPage.get(sessionId)
if (pageId === undefined) return
this.handleConsoleAPI(pageId, params as ConsoleAPICalledEvent)
})

cdp.onSessionEvent('Runtime.exceptionThrown', (params, sessionId) => {
const pageId = this.sessionToPage.get(sessionId)
if (pageId === undefined) return
this.handleException(pageId, params as ExceptionThrownEvent)
})

cdp.onSessionEvent('Log.entryAdded', (params, sessionId) => {
const pageId = this.sessionToPage.get(sessionId)
if (pageId === undefined) return
this.handleLogEntry(pageId, params as EntryAddedEvent)
})

// Clear buffer on main-frame navigation
cdp.onSessionEvent('Page.frameNavigated', (params, sessionId) => {
const pageId = this.sessionToPage.get(sessionId)
if (pageId === undefined) return
const frame = (params as { frame: { parentId?: string } }).frame
if (!frame.parentId) {
this.buffers.set(pageId, [])
}
})
}

attach(pageId: number, sessionId: string): void {
if (!this.buffers.has(pageId)) {
this.buffers.set(pageId, [])
}
// Clean up old session mapping if session changed (re-attach after detach)
const oldSession = this.pageToSession.get(pageId)
if (oldSession && oldSession !== sessionId) {
this.sessionToPage.delete(oldSession)
}
this.sessionToPage.set(sessionId, pageId)
this.pageToSession.set(pageId, sessionId)
}

detach(pageId: number): void {
const sessionId = this.pageToSession.get(pageId)
if (sessionId) this.sessionToPage.delete(sessionId)
this.pageToSession.delete(pageId)
this.buffers.delete(pageId)
}

getLogs(pageId: number, opts?: GetConsoleLogsOptions): GetConsoleLogsResult {
const buffer = this.buffers.get(pageId) ?? []
const levelThreshold = LEVEL_PRIORITY[opts?.level ?? 'info']

// Filter by level
let filtered = buffer.filter(
(e) => LEVEL_PRIORITY[e.level] <= levelThreshold,
)

// Filter by search text
if (opts?.search) {
const term = opts.search.toLowerCase()
filtered = filtered.filter((e) => e.text.toLowerCase().includes(term))
}

// Return most recent entries up to limit
const totalCount = filtered.length
const limit = Math.min(
opts?.limit ?? CONTENT_LIMITS.CONSOLE_DEFAULT_LIMIT,
CONTENT_LIMITS.CONSOLE_MAX_LIMIT,
)
const entries = filtered.slice(-limit)

if (opts?.clear) {
this.buffers.set(pageId, [])
}

return { entries, totalCount }
}

private addEntry(pageId: number, entry: ConsoleEntry): void {
const buffer = this.buffers.get(pageId)
if (!buffer) return

// FIFO eviction when buffer is full
if (buffer.length >= this.maxEntries) {
buffer.shift()
}
buffer.push(entry)
}

private handleConsoleAPI(pageId: number, event: ConsoleAPICalledEvent): void {
const level = CONSOLE_TYPE_TO_LEVEL[event.type] ?? 'info'
const text = serializeArgs(event.args)
const frame = event.stackTrace?.callFrames[0]

this.addEntry(pageId, {
source: 'console',
level,
text,
url: frame?.url,
lineNumber: frame?.lineNumber,
timestamp: event.timestamp,
})
}

private handleException(pageId: number, event: ExceptionThrownEvent): void {
const details = event.exceptionDetails
const text = details.exception?.description ?? details.text

this.addEntry(pageId, {
source: 'exception',
level: 'error',
text,
url: details.url ?? details.stackTrace?.callFrames[0]?.url,
lineNumber: details.lineNumber,
timestamp: event.timestamp,
})
}

private handleLogEntry(pageId: number, event: EntryAddedEvent): void {
const entry = event.entry
const level = LOG_LEVEL_MAP[entry.level] ?? 'info'

this.addEntry(pageId, {
source: 'browser',
level,
text: entry.text,
url: entry.url,
lineNumber: entry.lineNumber,
timestamp: entry.timestamp,
})
}
}

function serializeArgs(args: RemoteObject[]): string {
return args
.map((arg) => {
if (arg.type === 'string') return arg.value as string
if (arg.value !== undefined) return String(arg.value)
return arg.description ?? `[${arg.type}]`
})
.join(' ')
}
Loading
Loading