/**
* Roku BrightScript Debug Console Client (Telnet port 8085)
*
* Official Roku API Reference:
* - developer.roku.com/docs/developer-program/debugging/debugging-channels.md
*
* Port 8085: BrightScript console — runtime output, crash logs, print statements
* Port 8080: SceneGraph debug — thread rendezvous, texture memory
*/
import * as net from 'node:net';
export class LogClient {
private host: string;
private port: number;
private socket: net.Socket | null = null;
private logBuffer: string[] = [];
private maxBufferLines: number;
private connected: boolean = false;
constructor(host: string, port: number = 8085, maxBufferLines: number = 2000) {
this.host = host;
this.port = port;
this.maxBufferLines = maxBufferLines;
}
/** Connect to the BrightScript debug console */
connect(): Promise<void> {
return new Promise((resolve, reject) => {
if (this.connected && this.socket) {
resolve();
return;
}
this.socket = new net.Socket();
const timeout = setTimeout(() => {
this.socket?.destroy();
reject(new Error(`Connection to ${this.host}:${this.port} timed out after 5s`));
}, 5000);
this.socket.connect(this.port, this.host, () => {
clearTimeout(timeout);
this.connected = true;
resolve();
});
this.socket.on('data', (data: Buffer) => {
const text = data.toString('utf-8');
const lines = text.split('\n');
for (const line of lines) {
const trimmed = line.replace(/\r$/, '');
if (trimmed.length > 0) {
this.logBuffer.push(trimmed);
}
}
// Trim buffer
while (this.logBuffer.length > this.maxBufferLines) {
this.logBuffer.shift();
}
});
this.socket.on('error', (err: Error) => {
clearTimeout(timeout);
this.connected = false;
reject(err);
});
this.socket.on('close', () => {
this.connected = false;
});
});
}
/** Disconnect from the debug console */
disconnect(): void {
if (this.socket) {
this.socket.destroy();
this.socket = null;
this.connected = false;
}
}
/** Check if connected */
isConnected(): boolean {
return this.connected;
}
/** Get recent log lines */
getRecentLogs(lines: number = 100): string[] {
if (lines >= this.logBuffer.length) return [...this.logBuffer];
return this.logBuffer.slice(-lines);
}
/** Get logs matching a pattern */
getFilteredLogs(pattern: string, lines: number = 100): string[] {
const regex = new RegExp(pattern, 'i');
const matched = this.logBuffer.filter(line => regex.test(line));
if (lines >= matched.length) return matched;
return matched.slice(-lines);
}
/** Wait for a specific log pattern to appear. Returns matching lines or throws on timeout. */
waitForLog(pattern: string, timeoutMs: number = 10000): Promise<string[]> {
return new Promise((resolve, reject) => {
const regex = new RegExp(pattern, 'i');
const startIdx = this.logBuffer.length;
const checkInterval = 200;
const timer = setInterval(() => {
const newLines = this.logBuffer.slice(startIdx);
const matches = newLines.filter(line => regex.test(line));
if (matches.length > 0) {
clearInterval(timer);
clearTimeout(timeout);
resolve(matches);
}
}, checkInterval);
const timeout = setTimeout(() => {
clearInterval(timer);
reject(new Error(`Timeout waiting for log pattern: ${pattern}`));
}, timeoutMs);
});
}
/** Clear the log buffer */
clearBuffer(): void {
this.logBuffer = [];
}
/** Get buffer size */
getBufferSize(): number {
return this.logBuffer.length;
}
/** Check RAF-related logs */
getRafLogs(): {
adManagerLogs: string[];
beaconLogs: string[];
impressionLogs: string[];
errorLogs: string[];
summary: string;
} {
const adManagerLogs = this.logBuffer.filter(l => l.includes('[AdManager]'));
const beaconLogs = this.logBuffer.filter(l => /beacon|tracking|impression/i.test(l));
const impressionLogs = this.logBuffer.filter(l => /impression|adCompleted|adSkipped/i.test(l));
const errorLogs = this.logBuffer.filter(l => /\[AdManager\].*ERROR|ad.*error|RAF.*error/i.test(l));
let summary = `AdManager logs: ${adManagerLogs.length}, Beacon events: ${beaconLogs.length}, ` +
`Impressions: ${impressionLogs.length}, Errors: ${errorLogs.length}`;
if (errorLogs.length > 0) {
summary += ` ⚠️ RAF errors detected!`;
}
if (adManagerLogs.some(l => l.includes('Test mode'))) {
summary += ' ℹ️ Running in test mode (RAF_ENABLED=False)';
}
return { adManagerLogs: adManagerLogs.slice(-20), beaconLogs: beaconLogs.slice(-20), impressionLogs: impressionLogs.slice(-20), errorLogs: errorLogs.slice(-20), summary };
}
/** Check accessibility-related logs */
getAccessibilityLogs(): string[] {
return this.logBuffer.filter(l =>
/roTextToSpeech|roAudioGuide|accessibility|screen.?reader|audio.?guide|caption/i.test(l)
).slice(-50);
}
}