nodejs-adapter.ts•18.6 kB
import { EventEmitter } from 'events';
import { Logger } from '../server/logger';
import { MetricsCollector } from '../server/metrics';
import { DapStdioTransport } from '../transport/stdio-transport';
import {
DapStartRequest,
DapAttachRequest,
DapContinueRequest,
DapPauseRequest,
DapStepInRequest,
DapStepOverRequest,
DapStepOutRequest,
DapThreadsRequest,
DapStackTraceRequest,
DapScopesRequest,
DapVariablesRequest,
DapEvaluateRequest,
DebugEvent,
} from '../schemas/index';
import { createError } from '../server/error-taxonomy';
/**
* Node.js Debug Adapter
*
* Implements debugging adapter for Node.js applications using
* the vscode-js-debug adapter (Node Debug).
*/
export class NodeJSAdapter extends EventEmitter {
private logger: Logger;
private metrics: MetricsCollector;
private transport: DapStdioTransport;
private sessionId: string | null = null;
private processId: number | null = null;
private isAttached: boolean = false;
private capabilities: Record<string, any> = {};
private breakpoints: Map<string, number[]> = new Map();
private lastEventId: number = 0;
// private _currentThreadId: number | null = null; // Unused for now
constructor() {
super();
this.logger = new Logger('NodeJSAdapter');
this.metrics = MetricsCollector.getInstance();
this.transport = new DapStdioTransport();
this.setupTransportHandlers();
}
/**
* Initialize the adapter
*/
async initialize(): Promise<Record<string, any>> {
this.logger.info('Initializing Node.js debug adapter');
try {
// Discover and validate Node.js adapter
const adapterRegistry = await import('../adapters/registry').then(m =>
m.createAdapterRegistry()
);
const jsDebugAdapter = await adapterRegistry.getAdapter('vscode-js-debug');
if (!jsDebugAdapter) {
throw createError('ADAPTER_001', { adapterType: 'vscode-js-debug' });
}
// Set up transport
const adapterPath = jsDebugAdapter.path;
await this.transport.connect(['node', adapterPath]);
// Negotiate capabilities
this.capabilities = await this.negotiateCapabilities();
this.logger.info('Node.js debug adapter initialized successfully');
this.metrics.increment('nodejs.adapter.initialize.count');
return this.capabilities;
} catch (error) {
this.logger.error('Failed to initialize Node.js debug adapter:', error);
throw createError('ADAPTER_003', { error: (error as Error).message });
}
}
/**
* Handle launch request
*/
async launch(config: DapStartRequest): Promise<{ sessionId: string }> {
this.logger.info('Handling Node.js launch request', { program: config.arguments.program });
try {
this.sessionId = `nodejs_${Date.now()}`;
// Validate configuration
this.validateLaunchConfig(config);
// Set up transport
await this.setupTransport();
// Send initialize request
await this.sendInitializeRequest();
// Send launch request
await this.sendLaunchRequest(config);
// Wait for adapter to be ready
await this.waitForReady();
this.logger.info('Node.js launch completed successfully');
this.metrics.increment('nodejs.launch.count');
return { sessionId: this.sessionId };
} catch (error) {
this.logger.error('Node.js launch failed:', error);
await this.cleanup();
throw error;
}
}
/**
* Handle attach request
*/
async attach(config: DapAttachRequest): Promise<{ sessionId: string }> {
this.logger.info('Handling Node.js attach request', { processId: config.arguments.processId });
try {
this.sessionId = `nodejs_${Date.now()}`;
this.isAttached = true;
// Validate configuration
this.validateAttachConfig(config);
// Set up transport
await this.setupTransport();
// Send initialize request
await this.sendInitializeRequest();
// Send attach request
await this.sendAttachRequest(config);
// Wait for adapter to be ready
await this.waitForReady();
this.logger.info('Node.js attach completed successfully');
this.metrics.increment('nodejs.attach.count');
return { sessionId: this.sessionId };
} catch (error) {
this.logger.error('Node.js attach failed:', error);
await this.cleanup();
throw error;
}
}
/**
* Handle execution control requests
*/
async continue(request: DapContinueRequest): Promise<void> {
await this.sendRequest('continue', {
threadId: request.arguments.threadId,
});
this.emit(
'event',
this.createDebugEvent('continued', { threadId: request.arguments.threadId })
);
}
async pause(request: DapPauseRequest): Promise<void> {
await this.sendRequest('pause', {
threadId: request.arguments.threadId,
});
this.emit('event', this.createDebugEvent('paused', { threadId: request.arguments.threadId }));
}
async stepIn(request: DapStepInRequest): Promise<void> {
await this.sendRequest('stepIn', {
threadId: request.arguments.threadId,
targetId: request.arguments.targetId,
waitForAdditionalInformation: request.arguments.waitForAdditionalInformation,
});
this.emit(
'event',
this.createDebugEvent('steppedIn', { threadId: request.arguments.threadId })
);
}
async stepOver(request: DapStepOverRequest): Promise<void> {
await this.sendRequest('stepOver', {
threadId: request.arguments.threadId,
waitForAdditionalInformation: request.arguments.waitForAdditionalInformation,
});
this.emit(
'event',
this.createDebugEvent('steppedOver', { threadId: request.arguments.threadId })
);
}
async stepOut(request: DapStepOutRequest): Promise<void> {
await this.sendRequest('stepOut', {
threadId: request.arguments.threadId,
waitForAdditionalInformation: request.arguments.waitForAdditionalInformation,
});
this.emit(
'event',
this.createDebugEvent('steppedOut', { threadId: request.arguments.threadId })
);
}
/**
* Handle thread and stack requests
*/
async threads(_request: DapThreadsRequest): Promise<any> {
return await this.sendRequest('threads', {});
}
async stackTrace(request: DapStackTraceRequest): Promise<any> {
return await this.sendRequest('stackTrace', {
threadId: request.arguments.threadId,
startFrame: request.arguments.startFrame,
levels: request.arguments.levels,
});
}
async scopes(request: DapScopesRequest): Promise<any> {
return await this.sendRequest('scopes', {
frameId: request.arguments.frameId,
});
}
async variables(request: DapVariablesRequest): Promise<any> {
return await this.sendRequest('variables', {
variablesReference: request.arguments.variablesReference,
filter: request.arguments.filter,
start: request.arguments.start,
count: request.arguments.count,
});
}
async evaluate(request: DapEvaluateRequest): Promise<any> {
return await this.sendRequest('evaluate', {
expression: request.arguments.expression,
frameId: request.arguments.frameId,
context: request.arguments.context,
});
}
/**
* Set breakpoints
*/
async setBreakpoints(sourcePath: string, lines: number[]): Promise<any> {
const source = { path: sourcePath };
const breakpoints = lines.map(line => ({
line,
column: 0,
verified: false,
}));
const response = await this.sendRequest('setBreakpoints', {
source,
breakpoints,
});
// Update local breakpoints tracking
this.breakpoints.set(sourcePath, lines);
this.emit(
'event',
this.createDebugEvent('breakpointSet', {
sourcePath,
lines,
verified: response.body?.breakpoints?.map((bp: any) => bp.verified) || [],
})
);
return response;
}
/**
* Terminate debugging session
*/
async terminate(): Promise<void> {
this.logger.info('Terminating Node.js debugging session');
try {
if (this.transport.connected) {
await this.sendRequest('terminate', {});
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for cleanup
}
await this.cleanup();
this.logger.info('Node.js debugging session terminated');
this.metrics.increment('nodejs.terminate.count');
} catch (error) {
this.logger.warn('Error during termination:', error);
await this.cleanup();
throw error;
}
}
/**
* Disconnect debugging session
*/
async disconnect(): Promise<void> {
this.logger.info('Disconnecting Node.js debugging session');
try {
if (this.transport.connected) {
await this.sendRequest('disconnect', {
restart: false,
terminateDebuggee: false,
suspendDebuggee: false,
});
}
await this.cleanup();
this.logger.info('Node.js debugging session disconnected');
this.metrics.increment('nodejs.disconnect.count');
} catch (error) {
this.logger.warn('Error during disconnect:', error);
await this.cleanup();
throw error;
}
}
/**
* Get session information
*/
getSessionInfo(): {
sessionId: string | null;
processId: number | null;
isAttached: boolean;
capabilities: Record<string, any>;
} {
return {
sessionId: this.sessionId,
processId: this.processId,
isAttached: this.isAttached,
capabilities: this.capabilities,
};
}
/**
* Setup transport event handlers
*/
private setupTransportHandlers(): void {
this.transport.on('event', (event: string, body: any) => {
this.handleDapEvent(event, body);
});
this.transport.on('error', (error: Error) => {
this.logger.error('Transport error:', error);
this.emit('error', error);
});
this.transport.on('disconnected', () => {
this.logger.info('Transport disconnected');
this.emit('disconnected');
});
}
/**
* Setup transport for Node.js debugging
*/
private async setupTransport(): Promise<void> {
// Additional Node.js specific setup if needed
this.logger.debug('Setting up Node.js debug transport');
}
/**
* Negotiate capabilities
*/
private async negotiateCapabilities(): Promise<Record<string, any>> {
const capabilities = {
supportsConfigurationDoneRequest: true,
supportsEvaluateForHovers: true,
supportsSetVariable: true,
supportsRestartRequest: true,
supportsStepBack: false,
supportsDataBreakpoints: false,
supportsInstructionBreakpoints: false,
supportsCompletionsQuery: true,
supportsModulesQuery: true,
supportsGotoTargetsRequest: true,
supportsLoadedSourcesRequest: true,
supportsTerminateDebuggee: true,
supportsTerminateRequest: true,
supportsDelayedStackTraceLoading: false,
supportsConditionalBreakpoints: true,
supportsHitConditionalBreakpoints: true,
supportsLogPoints: true,
supportsExceptionInfoRequest: true,
supportsSetExpression: false,
supportsTerminateThreadsRequest: false,
supportsSetFunctionBreakpoints: false,
supportsThreadNamesRequest: true,
supportsVariableType: true,
supportsVariablePaging: true,
supportsRunInTerminalRequest: false,
supportsMemoryReferences: false,
supportsArgsCanBeInterpretedByShell: false,
supportsProgressReporting: false,
supportsInvalidatedEvent: true,
supportsMemoryEvent: false,
};
return capabilities;
}
/**
* Validate launch configuration
*/
private validateLaunchConfig(config: DapStartRequest): void {
if (!config.arguments.program) {
throw new Error('program is required for Node.js launch debugging');
}
// Validate program path
const fs = require('fs');
if (!fs.existsSync(config.arguments.program)) {
throw new Error(`Program file not found: ${config.arguments.program}`);
}
// Validate working directory if provided
if (config.arguments.cwd) {
if (!fs.existsSync(config.arguments.cwd)) {
throw new Error(`Working directory not found: ${config.arguments.cwd}`);
}
}
}
/**
* Validate attach configuration
*/
private validateAttachConfig(config: DapAttachRequest): void {
if (!config.arguments.processId && !config.arguments.address) {
throw new Error('processId or address is required for Node.js attach debugging');
}
if (config.arguments.processId && typeof config.arguments.processId !== 'number') {
throw new Error('processId must be a number');
}
}
/**
* Send initialize request
*/
private async sendInitializeRequest(): Promise<void> {
await this.transport.sendRequest('initialize', {
clientID: 'watchtower',
clientName: 'Watchtower MCP Server',
adapterID: 'nodejs-debug',
locale: 'en-us',
linesStartAt1: true,
columnsStartAt1: true,
pathFormat: 'path',
supportsVariableType: true,
supportsVariablePaging: true,
supportsRunInTerminalRequest: true,
supportsMemoryReferences: false,
supportsArgsCanBeInterpretedByShell: false,
supportsProgressReporting: false,
supportsInvalidatedEvent: true,
supportsMemoryEvent: false,
});
}
/**
* Send launch request
*/
private async sendLaunchRequest(config: DapStartRequest): Promise<void> {
const launchArgs = {
...config.arguments,
// Node.js specific arguments
runtimeExecutable: config.arguments.runtimeExecutable || 'node',
runtimeArgs: config.arguments.runtimeArgs || [],
sourceMaps: config.arguments.sourceMaps !== undefined ? config.arguments.sourceMaps : true,
outFiles: config.arguments.outFiles || [],
resolveSourceMapLocations: config.arguments.resolveSourceMapLocations || [],
skipFiles: config.arguments.skipFiles || [],
};
await this.transport.sendRequest('launch', launchArgs);
}
/**
* Send attach request
*/
private async sendAttachRequest(config: DapAttachRequest): Promise<void> {
const attachArgs = {
...config.arguments,
// Node.js specific arguments
sourceMaps: config.arguments.sourceMaps !== undefined ? config.arguments.sourceMaps : true,
skipFiles: config.arguments.skipFiles || [],
};
await this.transport.sendRequest('attach', attachArgs);
}
/**
* Wait for adapter to be ready
*/
private async waitForReady(): Promise<void> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Timeout waiting for Node.js debug adapter to be ready'));
}, 10000);
const checkReady = () => {
if (this.transport.connected) {
clearTimeout(timeout);
resolve();
} else {
setTimeout(checkReady, 100);
}
};
checkReady();
});
}
/**
* Send DAP request
*/
private async sendRequest(command: string, arguments_: any): Promise<any> {
if (!this.transport.connected) {
throw new Error('Transport not connected');
}
return await this.transport.sendRequest(command, arguments_);
}
/**
* Handle DAP events from transport
*/
private handleDapEvent(event: string, body: any): void {
this.logger.debug('Received DAP event:', { event, body });
// Convert to DebugEvent for consistency
const debugEvent = this.createDebugEvent(event, body);
this.emit('event', debugEvent);
// Handle specific events
switch (event) {
case 'initialized':
this.emit('initialized', body);
break;
case 'stopped':
this.handleStoppedEvent(body);
break;
case 'continued':
this.handleContinuedEvent(body);
break;
case 'thread':
this.handleThreadEvent(body);
break;
case 'output':
this.handleOutputEvent(body);
break;
case 'breakpoint':
this.handleBreakpointEvent(body);
break;
case 'exited':
this.handleExitedEvent(body);
break;
case 'terminated':
this.handleTerminatedEvent(body);
break;
case 'module':
this.handleModuleEvent(body);
break;
}
}
/**
* Handle stopped event
*/
private handleStoppedEvent(body: any): void {
// this._currentThreadId = body.threadId; // Unused for now
this.lastEventId++;
this.emit('stopped', body);
}
/**
* Handle continued event
*/
private handleContinuedEvent(body: any): void {
// this._currentThreadId = null; // Unused for now
this.emit('continued', body);
}
/**
* Handle thread event
*/
private handleThreadEvent(body: any): void {
this.emit('threadEvent', body);
}
/**
* Handle output event
*/
private handleOutputEvent(body: any): void {
this.emit('output', body);
}
/**
* Handle breakpoint event
*/
private handleBreakpointEvent(body: any): void {
this.emit('breakpointEvent', body);
}
/**
* Handle exited event
*/
private handleExitedEvent(body: any): void {
this.emit('exited', body);
}
/**
* Handle terminated event
*/
private handleTerminatedEvent(body: any): void {
this.emit('terminated', body);
}
/**
* Handle module event
*/
private handleModuleEvent(body: any): void {
this.emit('moduleEvent', body);
}
/**
* Create debug event
*/
private createDebugEvent(event: string, data: any): DebugEvent {
return {
event_id: `nodejs_${this.lastEventId++}`,
session_id: this.sessionId || 'unknown',
timestamp: Date.now(),
event_type: event as any, // Type assertion to allow string
data,
};
}
/**
* Cleanup resources
*/
private async cleanup(): Promise<void> {
this.sessionId = null;
this.processId = null;
this.isAttached = false;
this.breakpoints.clear();
// this._currentThreadId = null; // Unused for now
this.lastEventId = 0;
if (this.transport.connected) {
try {
await this.transport.disconnect();
} catch (error) {
this.logger.warn('Error during transport cleanup:', error);
}
}
this.removeAllListeners();
}
}
/**
* Create Node.js adapter instance
*/
export function createNodeJSAdapter(): NodeJSAdapter {
return new NodeJSAdapter();
}