@not-true/devtools
Version:
Remote debugging and development tools client library for React Native applications
329 lines (280 loc) • 7.99 kB
text/typescript
// @ts-ignore
import { io } from "socket.io-client";
import { RemoteDevToolsConfig, DevToolsEventHandlers, LogEntry, StateUpdate, REPLCommand, REPLResult, RemoteConfig, QueueItem } from "../types";
import { DeviceInfoUtils } from "../utils/DeviceInfoUtils";
import { QueueManager } from "../utils/QueueManager";
export class RemoteDevTools {
private socket: any | null = null;
private config: Required<RemoteDevToolsConfig>;
private handlers: DevToolsEventHandlers;
private queueManager: QueueManager;
private reconnectAttempts = 0;
private isConnected = false;
private originalConsole: Console;
constructor(config: RemoteDevToolsConfig, handlers: DevToolsEventHandlers = {}) {
this.config = {
maxQueueSize: 1000,
maxReconnectAttempts: 5,
reconnectDelay: 1000,
enableConsoleLogging: true,
enableStateSync: true,
enableREPL: true,
enableRemoteConfig: true,
stateUpdateThrottle: 500,
deviceInfo: {},
clientInfo: {},
...config,
};
this.handlers = handlers;
this.queueManager = new QueueManager(this.config.maxQueueSize);
this.originalConsole = { ...console };
if (this.config.enableConsoleLogging) {
this.interceptConsole();
}
}
/**
* Connect to the remote DevTools server
*/
async connect(): Promise<void> {
if (this.socket && this.socket.connected) {
console.warn("RemoteDevTools: Already connected");
return;
}
try {
this.socket = io(this.config.serverUrl, {
autoConnect: true,
reconnection: false,
timeout: 5000,
});
this.setupSocketListeners();
// Wait for connection
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Connection timeout"));
}, 10000);
this.socket!.on("connect", () => {
clearTimeout(timeout);
resolve();
});
this.socket!.on("connect_error", (error: Error) => {
clearTimeout(timeout);
reject(error);
});
});
} catch (error) {
this.handleError(error as Error);
throw error;
}
}
/**
* Disconnect from the remote DevTools server
*/
disconnect(): void {
if (this.socket) {
this.socket.disconnect();
this.socket = null;
}
this.isConnected = false;
this.reconnectAttempts = 0;
}
/**
* Send a log entry to the server
*/
log(level: "log" | "info" | "warn" | "error", ...args: any[]): void {
const logEntry: LogEntry = {
level,
args,
timestamp: Date.now(),
message: args.map((arg) => (typeof arg === "object" ? JSON.stringify(arg) : String(arg))).join(" "),
};
this.sendOrQueue("log", logEntry);
}
/**
* Send state update to the server
*/
updateState(stateUpdate: StateUpdate): void {
if (!this.config.enableStateSync) return;
const data = {
...stateUpdate,
timestamp: Date.now(),
};
this.sendOrQueue("state", data);
}
/**
* Send REPL result to the server
*/
sendREPLResult(result: REPLResult): void {
if (!this.config.enableREPL) return;
const data = {
...result,
timestamp: Date.now(),
};
this.sendOrQueue("repl-result", data);
}
/**
* Get current connection status
*/
isConnectedToServer(): boolean {
return this.isConnected;
}
/**
* Get current configuration
*/
getConfig(): RemoteDevToolsConfig {
return { ...this.config };
}
/**
* Update configuration
*/
updateConfig(newConfig: Partial<RemoteDevToolsConfig>): void {
this.config = { ...this.config, ...newConfig };
}
private setupSocketListeners(): void {
if (!this.socket) return;
this.socket.on("connect", () => {
this.isConnected = true;
this.reconnectAttempts = 0;
this.registerClient();
this.processQueue();
this.handlers.onConnect?.();
});
this.socket.on("disconnect", () => {
this.isConnected = false;
this.handlers.onDisconnect?.();
this.scheduleReconnect();
});
this.socket.on("connect_error", (error: any) => {
this.handleError(error instanceof Error ? error : new Error(String(error)));
this.scheduleReconnect();
});
// Remote config handler
this.socket.on("remote-config", (data: RemoteConfig) => {
if (this.config.enableRemoteConfig) {
this.handlers.onRemoteConfig?.(data);
}
});
// REPL command handler
this.socket.on("repl-execute", async (command: REPLCommand) => {
if (this.config.enableREPL && this.handlers.onREPLCommand) {
try {
const startTime = Date.now();
const result = await this.handlers.onREPLCommand(command);
const executionTime = Date.now() - startTime;
this.sendREPLResult({
commandId: command.commandId,
result,
executionTime,
});
} catch (error) {
const executionTime = Date.now() - Date.now();
this.sendREPLResult({
commandId: command.commandId,
result: null,
error: error instanceof Error ? error.message : String(error),
executionTime,
});
}
}
});
// Feature flags handler
this.socket.on("feature-flags", (data: { flags: Record<string, boolean> }) => {
// Update local feature flags
Object.assign(this.config, {
enableConsoleLogging: data.flags.enableConsoleLogging ?? this.config.enableConsoleLogging,
enableStateSync: data.flags.enableStateSync ?? this.config.enableStateSync,
enableREPL: data.flags.enableREPL ?? this.config.enableREPL,
enableRemoteConfig: data.flags.enableRemoteConfig ?? this.config.enableRemoteConfig,
});
});
}
private registerClient(): void {
if (!this.socket) return;
const deviceInfo = DeviceInfoUtils.getDeviceInfo();
this.socket.emit("register-client", {
name: this.config.name,
version: this.config.version,
platform: deviceInfo.platform,
deviceInfo: { ...deviceInfo, ...this.config.deviceInfo },
clientInfo: this.config.clientInfo,
});
}
private sendOrQueue(type: QueueItem["type"], data: any): void {
if (this.isConnected && this.socket) {
const eventName = this.getEventName(type);
this.socket.emit(eventName, data);
} else {
this.queueManager.enqueue({ type, data, timestamp: Date.now() });
}
}
private processQueue(): void {
if (!this.socket) return;
while (!this.queueManager.isEmpty()) {
const item = this.queueManager.dequeue();
if (item) {
const eventName = this.getEventName(item.type);
this.socket.emit(eventName, item.data);
}
}
}
private getEventName(type: QueueItem["type"]): string {
switch (type) {
case "log":
return "console-log";
case "state":
return "state-update";
case "repl-result":
return "repl-result";
default:
return type;
}
}
private scheduleReconnect(): void {
if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
this.handlers.onReconnectError?.(new Error("Max reconnection attempts reached"));
return;
}
this.reconnectAttempts++;
const delay = this.config.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1); // Exponential backoff
setTimeout(() => {
if (!this.isConnected) {
this.handlers.onReconnect?.(this.reconnectAttempts);
this.reconnect();
}
}, delay);
}
private reconnect(): void {
if (this.socket) {
this.socket.disconnect();
}
this.connect().catch((error) => {
this.handleError(error);
});
}
private interceptConsole(): void {
const logLevels: Array<"log" | "info" | "warn" | "error"> = ["log", "info", "warn", "error"];
logLevels.forEach((level) => {
const originalMethod = this.originalConsole[level];
(console as any)[level] = (...args: any[]) => {
// Call original console method
originalMethod.apply(console, args);
// Send to remote
this.log(level, ...args);
};
});
}
private restoreConsole(): void {
Object.assign(console, this.originalConsole);
}
private handleError(error: Error): void {
console.error("RemoteDevTools Error:", error);
this.handlers.onError?.(error);
}
/**
* Cleanup and restore original console
*/
destroy(): void {
this.disconnect();
this.restoreConsole();
this.queueManager.clear();
}
}