devil-windows
Version:
Debugger, profiler and runtime with embedded WebKit DevTools client (for Windows).
886 lines (724 loc) • 31.3 kB
JavaScript
var util = require('util'),
path = require('path'),
EventEmitter = require('events').EventEmitter,
async = require('async'),
fs = require('fs'),
DebuggerClient = require('./v8/DebuggerClient'),
v8Helper = require('./v8/Helper'),
ScriptManager = require('./ScriptManager'),
ScriptStorage = require('./ScriptStorage');
function escapeRegex(str) {
return str.replace(/([/\\.?*()^${}|[\]])/g, '\\$1');
}
var MODULE_HEADER = '(function (exports, require, module, __filename, __dirname) { ';
var MODULE_TRAILER = '\n});';
var MODULE_WRAP_REGEX = new RegExp(
'^' + escapeRegex(MODULE_HEADER) +
'([\\s\\S]*)' +
escapeRegex(MODULE_TRAILER) + '$'
);
var newError = function (message) {
var nameMatch = /^([^:]+):/.exec(message);
return {
type: 'object',
objectId: 'ERROR',
className: nameMatch ? nameMatch[1] : 'Error',
description: message,
name: nameMatch ? nameMatch[1] : 'Error',
message: message
};
};
var v8ResultToInspectorResult = function (result) {
var subtype,
inspectorResult;
if (['object', 'function', 'regexp', 'error'].indexOf(result.type) > -1) {
return v8Helper.v8RefToInspectorObject(result);
}
if (result.type == 'null') {
// workaround for the problem with front-end's setVariableValue
// implementation not preserving null type
result.value = null;
subtype = 'null';
}
inspectorResult = {
type: result.type,
subtype: subtype,
value: result.value,
description: String(result.value)
};
return inspectorResult;
};
var v8ObjectToInspectorProperties = function (obj, refs, ownProperties, accessorPropertiesOnly) {
var proto = obj.protoObject,
props = obj.properties || [];
props = props.map(function (prop) {
var ref = refs[prop.ref];
return {
name: String(prop.name),
writable: !(prop.attributes & 1 << 0),
enumerable: !(prop.attributes & 1 << 1),
configurable: !(prop.attributes & 1 << 2),
value: v8ResultToInspectorResult(ref)
};
});
if (ownProperties && proto) {
proto = refs[proto.ref];
if (proto.type !== 'undefined') {
props.push({
name: '__proto__',
value: v8Helper.v8RefToInspectorObject(proto),
writable: true,
configurable: true,
enumerable: false,
isOwn: true
});
}
}
props = props.filter(function (prop) {
/*
Node.js does not return get/set property descriptors now (v0.11.11),
therefore we can't fully implement 'accessorPropertiesOnly'.
See https://github.com/joyent/node/issues/7139
*/
var isAccessorProperty = ('get' in prop || 'set' in prop);
return accessorPropertiesOnly ? isAccessorProperty : !isAccessorProperty;
});
return props;
};
function getFullJson (properties, refs) {
return properties.filter(function isArrayIndex(p) {
return /^\d+$/.test(p.name);
}).map(function resolvePropertyValue(p) {
return refs[p.ref].value;
}).join('').trim();
}
/**
* Debugger class
*
* This class implements all the logic needed to debug an application.
* It combines the standard v8 debugger client with inject functionality.
* Implements wrappers for all kinds of debug needs.
*
* @params {ChildProcess} child
* @params {v8.DebuggerClient} client
* @constructor
* @augments v8.DebuggerClient
*/
function Debugger (port, options) {
DebuggerClient.call(this, port, options);
var $this = this;
/**
* @type {ScriptManager}
*/
this.scriptManager = new ScriptManager(this, options.hidden);
/**
* @type {ScriptStorage}
*/
this.scriptStorage = new ScriptStorage(this, options.preload);
//
// Private stuff
/**
* @type {ChildProcess}
* @private
*/
var _process = null;
/**
* @type {boolean}
* @private
*/
var _saveLiveEdit = options.saveLiveEdit ? true : false;
/**
* @type {boolean}
* @private
*/
var _mute = options.mute ? true : false;
console.log("DEBUGGER OPTIONS", options);
/**
* @type {boolean}
* @private
*/
var _injected = false;
/**
* @type {string}
* @private
*/
var _eventsPrefix = '__DEBUGGER_EVENT_START_' + Math.round((Date.now() - Math.round(Math.random() * 10000)) / 1000) + '-' + Math.round(Math.random() * 10000);
var _eventsSuffix = '__DEBUGGER_EVENT_END_' + Math.round((Date.now() - Math.round(Math.random() * 10000)) / 1000) + '-' + Math.round(Math.random() * 10000);
/**
* @private
*/
var _isRequireInFrame = function (TARGET_FRAME, cb) {
$this._request('evaluate', {
expression: 'require',
frame: TARGET_FRAME >= 0 ? TARGET_FRAME : undefined,
global: TARGET_FRAME == -1
}, function (error, result) {
cb(!error);
});
};
/**
* Inject the debuggee code into the debuggee process
* @private
*/
var _inject = function (callback) {
if (_injected) return;
var PARENT_CALL_FRAME = 1,
CURRENT_CALL_FRAME = 0,
GLOBAL_CALL_FRAME = -1;
if ($this.isConnected() && !$this.isRunning()) {
_isRequireInFrame(CURRENT_CALL_FRAME, function (isInCurrentFrame) {
if (isInCurrentFrame) _doInject(CURRENT_CALL_FRAME, callback);
else {
console.log("Injection failed: no require in current frame.");
var error = new Error('Injection failed: no require in current frame.');
callback(error);
}
});
} else {
setTimeout(function () {
_inject(callback);
}, 1);
}
};
var _doInject = function (TARGET_FRAME, callback) {
var injectorServerPath = JSON.stringify(require.resolve('./Debuggee.js'));
console.log("DOINJECT");
var options = {
'port': port,
'eventsPrefix': _eventsPrefix,
'eventsSuffix': _eventsSuffix,
'v8-profiler': require.resolve('../../lib/v8-profiler'),
'heapdump': require.resolve('../../lib/heapdump')
};
var injection = '(require(\'module\')._load(' + injectorServerPath + '))(' + JSON.stringify(options) + ')';
$this._request('evaluate', {
expression: injection,
frame: TARGET_FRAME >= 0 ? TARGET_FRAME : undefined,
global: TARGET_FRAME == -1
}, function (err) {
if (err) return callback (err);
_injected = true;
callback();
});
};
var _createUniqueLoaderId = function () {
var randomPart = String(Math.random()).slice(2);
return Date.now() + '-' + randomPart;
};
var _resolveMainAppScript = function (startDirectory, mainAppScript, callback) {
$this.scriptManager.mainAppScript = mainAppScript;
if (mainAppScript == null) {
// mainScriptFile is null when running in the REPL mode
return callback(null, startDirectory, mainAppScript);
}
fs.stat(mainAppScript, function (err, stat) {
if (err && !/\.js$/.test(mainAppScript)) {
mainAppScript += '.js';
}
return callback(null, startDirectory, mainAppScript);
});
};
var _createResourceTreeResponse = function (mainAppScript, scriptFiles, callback) {
var loaderId = _createUniqueLoaderId();
callback(null, mainAppScript, loaderId, scriptFiles);
};
var _getResourceTreeForAppScript = function (startDirectory, mainAppScript, callback) {
async.waterfall([
$this.scriptStorage.findAllApplicationScripts.bind($this.scriptStorage, startDirectory, mainAppScript),
_createResourceTreeResponse.bind(this, mainAppScript)
], callback);
};
var _changeLiveOrRestartFrameResponseHandler = function (callback, err, response) {
if (err) return callback(err);
function sendResponse(callFrames) {
callback(null, {
callFrames: callFrames || [],
result: response.result
});
}
function sendResponseWithCallStack() {
$this.breakEventHandler.fetchCallFrames(function (err, response) {
var callFrames = [];
if (!err) callFrames = response;
// $this.emit('info', {type: 'error', text: 'Cannot update stack trace after a script changed: ' + ((typeof err === 'string') ? err : err.message)});
sendResponse(callFrames);
});
}
var result = response.result;
if (result['stack_modified'] && !result['stack_update_needs_step_in']) sendResponseWithCallStack();
else sendResponse();
};
var _persistScriptChanges = function (scriptId, newSource) {
if (!_saveLiveEdit) return _warn(
'Saving of live-edit changes back to source files is disabled.\n' +
'Use the button above to enable saving.'
);
var source = $this.scriptManager.findScriptByID(scriptId);
if (!source) return _warn('Cannot save changes to disk: unknown script id %s', scriptId);
var scriptFile = source.v8name;
if (!scriptFile || scriptFile.indexOf(path.sep) == -1) return _warn(
'Cannot save changes to disk: script id %s "%s" was not loaded from a file.',
scriptId,
scriptFile || 'null'
);
$this.scriptStorage.save(scriptFile, newSource, function (err) {
if (err) return _warn('Cannot save changes to disk. %s', err);
});
};
var _warn = function () {
$this.emit('runtimeError', {type: 'warning', error: util.format.apply(this, arguments)});
console.log('[warning]', util.format.apply(this, arguments));
};
/**
* Get the full app scripts tree
* @param {Function} callback
*/
this.getScriptsTree = function (callback) {
var describeProgram = '[process.cwd(), ' + 'process.mainModule ? process.mainModule.filename : process.argv[1]]';
async.waterfall([
$this.evaluate.bind($this, describeProgram),
function (result, refs, cb) {
console.log("BEFORE GETTING TREE", arguments);
if (result.type != 'object' && result.className != 'Array') {
return callback(new Error('Evaluate returned unexpected result: type: ' + result.type + ' className: ' + result.className));
}
var props = result.properties.filter(function isArrayIndex(p) {
return /^\d+$/.test(p.name);
}).map(function resolvePropertyValue(p) {
return refs[p.ref].value;
});
cb(null, props[0], props[1]);
},
_resolveMainAppScript,
_getResourceTreeForAppScript
], function (err, mainAppScript, loaderId, scriptFiles) {
callback(err, mainAppScript, loaderId, scriptFiles);
$this.emit('appTree', mainAppScript, loaderId, scriptFiles);
});
};
/**
* Get script source code request
* Copied from node-inspector
* https://github.com/node-inspector/node-inspector
*
* @param {number} id
* @param {Function} callback
*/
this.getScriptSource = function (id, callback) {
this._request('scripts', {includeSource: true, types: 4, ids: [Number(id)]}, function scriptSourceResponseHandler(err, result) {
if (err) return callback(err);
// Some modules gets unloaded (?) after they are parsed,
// e.g. node_modules/express/node_modules/methods/index.js
// V8 request 'scripts' returns an empty result in such case
var source = result.length > 0 ? result[0].source : undefined;
if (!err && !source) {
var script = $this.scriptManager.findScriptByID(id);
if (script) {
source = script.source;
if (typeof source === 'undefined') {
fs.readFile(script.v8name, 'utf-8', function (err, content) {
if (err) return callback(err);
content = content.replace(/^\#\!.*/, '');
var source = content;
$this.scriptManager.setContent(script.v8name, source);
return callback(null, source);
});
} else {
callback(null, source);
}
} else {
callback(null, source);
}
} else {
var match = MODULE_WRAP_REGEX.exec(source);
if (match) source = match[1];
callback(null, source);
}
});
};
this.getScripts = function (callback) {
this._request('scripts', {includeSource: true, types: 4}, function handleScriptsResponse(err, result) {
if (err) return callback(err);
result.forEach($this.scriptManager.addScript.bind($this.scriptManager));
callback(null, result);
});
};
this.reloadScripts = function (callback) {
this.scriptManager.reset(callback);
};
this.loadScript = function (file, callback) {
return this.scriptStorage.load(file, callback);
};
this.setScriptSource = function (scriptId, source, callback) {
this._request('changelive', {
script_id: Number(scriptId),
new_source: MODULE_HEADER + source + MODULE_TRAILER,
preview_only: false
}, function (err, response) {
_changeLiveOrRestartFrameResponseHandler(callback, err, response);
_persistScriptChanges(scriptId, source);
});
};
this.restartFrame = function (frameId) {
this._request('restartframe', {
frame: Number(frameId)
}, _changeLiveOrRestartFrameResponseHandler.bind(null, callback));
};
this.eval = function eval(expression, objectGroup, returnByValue, generatePreview, callback) {
expression = 'global.process.___NODEBUG.runtime.register(' +
'eval(' + JSON.stringify(expression) + '), ' +
JSON.stringify(objectGroup) + ',' +
returnByValue.toString() + ',' +
generatePreview.toString() + '' +
');';
this.evaluate(expression, function (err, result, refs) {
if (err) return callback(err);
if (result.type != 'object' || result.className != 'Array') {
callback(newError("JSONError: JSON response is not an array."));
return;
}
var full = getFullJson(result.properties, refs);
try {
callback(null, JSON.parse(full));
} catch (e) {
callback(newError("JSONError: Cannot parse evaluate response."));
}
});
};
this.callFunction = function (objectId, fn, args, returnByValue, generatePreview, callback) {
fn = '(' + fn + ')';
var expression = 'global.process.___NODEBUG.runtime.callFunctionOn(' +
JSON.stringify(objectId) + ', ' +
'eval(' + JSON.stringify(fn) + '), ' +
JSON.stringify(args) + ', ' +
returnByValue.toString() + ', ' +
generatePreview.toString() + '' +
');';
this.evaluate(expression, function (err, result, refs) {
if (err) return callback(err);
if (result.type != 'object' || result.className != 'Array') {
callback(newError("JSONError: JSON response is not an array."));
return;
}
try {
callback(null, JSON.parse(getFullJson(result.properties, refs)));
} catch (e) {
callback(newError("JSONError: Cannot parse evaluate response."));
}
});
};
var _getPropertiesOfScopeId = function (scopeId, ownProperties, accessorPropertiesOnly, callback) {
$this.callFramesProvider.resolveScopeId(scopeId, function (err, result) {
if (err) callback(err);
else _getPropertiesOfObjectId(result, ownProperties, accessorPropertiesOnly, callback);
});
};
var _getPropertiesOfObjectId = function (objectId, ownProperties, accessorPropertiesOnly, callback) {
var handle = parseInt(objectId, 10);
var request = {handles: [handle], includeSource: false};
$this._request('lookup', request, function (error, responseBody, responseRefs) {
if (error) {
callback(error);
return;
}
var obj = responseBody[handle],
props = v8ObjectToInspectorProperties(obj, responseRefs, ownProperties, accessorPropertiesOnly);
callback(null, {result: props});
});
};
this.getProperties = function (objectId, ownProperties, accessorPropertiesOnly, callback) {
if (objectId.indexOf('__runtime__') != 0) {
if (objectId.indexOf('scope:') == 0) {
_getPropertiesOfScopeId(objectId, ownProperties, accessorPropertiesOnly, callback);
} else {
_getPropertiesOfObjectId(objectId, ownProperties, accessorPropertiesOnly, callback);
}
return;
}
var expression = 'global.process.___NODEBUG.runtime.getProperties(' +
JSON.stringify(objectId) + ',' +
ownProperties.toString() + ',' +
accessorPropertiesOnly.toString() + '' +
');';
this.evaluate(expression, function (err, result, refs) {
if (err) return callback(err);
if (result.type != 'object' || result.className != 'Array') {
callback(newError("JSONError: JSON response is not an array."));
return;
}
if (result.type != 'object' || result.className != 'Array') {
callback(newError("JSONError: JSON response is not an array."));
return;
}
try {
callback(null, JSON.parse(getFullJson(result.properties, refs)));
} catch (e) {
callback(newError("JSONError: Cannot parse evaluate response."));
}
});
};
this.releaseObject = function (objectId, callback) {
var expression = 'global.process.___NODEBUG.runtime.releaseObject(' + JSON.stringify(objectId) + ');';
this.evaluate(expression, function (err, result) {
if (err) return callback(err);
callback(null, result.value);
});
callback();
};
this.releaseObjectGroup = function (objectGroup, callback) {
var expression = 'global.process.___NODEBUG.runtime.releaseObjectGroup(' + JSON.stringify(objectGroup) + ');';
this.evaluate(expression, function (err, result) {
if (err) return callback(err);
callback(null, result.value);
});
callback();
};
this.getFunctionDetails = function (functionId, callback) {
if (functionId.toString().indexOf('__runtime__') === 0) {
var expression = 'global.process.___NODEBUG.runtime.getFunctionDetails(' + JSON.stringify(functionId) + ');';
this.evaluate(expression, function (err, result, refs) {
if (err) return callback(err);
if (result.type != 'object' || result.className != 'Array') {
callback(newError("JSONError: JSON response is not an array."));
return;
}
try {
callback(null, JSON.parse(getFullJson(result.properties, refs)));
} catch (e) {
callback(newError("JSONError: Cannot parse evaluate response."));
}
});
} else {
// Debugger function request.
this._request('lookup', {handles: [functionId], includeSource: false}, function (error, responseBody) {
var name = responseBody.name || responseBody['inferredName'];
if (error) callback(error);
else callback(null, responseBody.scriptId, responseBody.line, responseBody.column, name);
});
}
};
this.takeHeapSnapshot = function (reportProgress, callback) {
var expression = 'global.process.___NODEBUG.profiler.takeSnapshot(' + reportProgress.toString() + ');';
this.evaluate(expression, function (err, result) {
if (err) return callback(err);
callback(null, result.value);
});
this.on('snapshotProgress', function (data) {
$this.emit('heapSnapshotProgress', data);
if (data.finished) {
$this.removeAllListeners('snapshotProgress');
fs.readFile(data.file, 'utf8', function (err, snapshot) {
var chunks = snapshot.match(/[\s\S]{1,8192}/g);
chunks.forEach(function (chunk, key) {
$this.emit('heapSnapshotData', {
chunk: chunk
});
});
fs.unlink(data.file);
$this.emit('heapSnapshotDone');
});
}
});
};
this.startHeapProfiler = function (trackAllocations, callback) {
// TODO: implement
// must emit 'objectSeen' event with objectId and timestamp
};
this.endHeapProfiler = function (reportProgress, callback) {
// TODO: implement
};
this.startCpuProfiler = function (callback) {
var expression = 'global.process.___NODEBUG.profiler.startCpu();';
this.evaluate(expression, function (err, result) {
if (err) return callback(err);
callback(null, result.value);
});
};
this.stopCpuProfiler = function (callback) {
var expression = 'global.process.___NODEBUG.profiler.stopCpu();';
this.evaluate(expression, function (err, result) {
if (err) return callback(err);
$this.once('cpuReady', function (data) {
console.log("CPU IS READY", data);
fs.readFile(data.file, 'utf8', function (err, snapshot) {
if (err) callback(err);
try {
snapshot = JSON.parse(snapshot.trim());
if (!snapshot.samples) snapshot.samples = [];
} catch (e) {
callback(newError("JSONError: Cannot parse evaluate response."));
}
fs.unlink(data.file);
callback(null, snapshot);
});
});
});
};
this.startEventLogger = function (maxCallStackDepth, callback) {
var expression = 'global.process.___NODEBUG.timeline.start(' + maxCallStackDepth + ');';
this.evaluate(expression, function (err, result) {
if (err) return callback(err);
callback(null, result.value);
});
};
this.stopEventLogger = function (callback) {
var expression = 'global.process.___NODEBUG.timeline.stop();';
this.evaluate(expression, function (err, result) {
if (err) return callback(err);
callback(null, result.value);
});
};
this.helper = {
v8NameToInspectorUrl: function (v8name) {
if (!v8name || v8name === 'repl') return '';
if (/^\//.test(v8name)) return 'source://' + v8name;
else if (/^[a-zA-Z]:\\/.test(v8name)) return 'source:///' + v8name.replace(/\\/g, '/');
else if (/^\\\\/.test(v8name)) return 'source://' + v8name.substring(2).replace(/\\/g, '/');
else return 'node:///' + v8name;
return v8name;
},
inspectorUrlToV8Name: function (url) {
var path = url.replace(/^source:\/\//, '').replace(/^node:\/\//, '');
if (/^\/[a-zA-Z]:\//.test(path)) return path.substring(1).replace(/\//g, '\\'); // Windows disk path
if (/^\//.test(path)) return path; // UNIX-style
if (/^source:\/\//.test(url)) return '\\\\' + path.replace(/\//g, '\\'); // Windows UNC path
return url;
}
};
// Once started and connected, we have to inject to be able to continue with anything.
this.once('nodeScriptParsed', function () {
console.log("BEFORE READY HERE");
_inject(function (err) {
if (err) return $this.emit('error', err);
console.log("READY HERE");
$this.emit('ready');
});
});
var _dataBuffer = '';
function _dataHandler (data) {
var events = [];
var msg = '', tmp;
if (_dataBuffer.length > 0) {
data = _eventsPrefix + _dataBuffer + data;
_dataBuffer = '';
}
var startsWithPrefix = data.indexOf(_eventsPrefix) === 0;
var prx = data.split(_eventsPrefix);
if (startsWithPrefix) {
// Remove the first element, because it was prefixed
var rem = prx.shift();
} else {
// This is just a normal message that should be printed.
msg = prx.shift();
}
for (var i = 0; i < prx.length; i++) {
var idx = prx[i].indexOf(_eventsSuffix);
if (idx == -1) {
// There is no suffix??!?!
if (i != prx.length - 1) {
// This is not supposed to happen!
throw new Error("There is no events suffix in this message.");
} else {
// Buffer this message, because it is an unfinished event.
_dataBuffer = prx[i];
}
} else {
// We have a suffix in the message, but it must be in the end.
tmp = prx[i].split(_eventsSuffix);
if (tmp.length > 2) throw new Error("This here is really strange.");
// This is just a normal event. Just add it.
events.push(tmp[0]);
if (tmp[1] != '') {
// The suffix is not in the end of the message!
// This means that the second part of the message is just a normal message
msg += tmp[1];
}
}
}
for (i = 0; i < events.length; i ++) {
var expression = 'global.process.___NODEBUG.getEvent(' + events[i] + ');';
_receiving ++;
$this.evaluate(expression, function (err, result, refs) {
_receiving --;
if (_receiving <= 0) {
_receiving = 0;
$this.emit('received');
}
if (err) throw err;
var a = getFullJson(result.properties, refs);
try {
var event = JSON.parse(a);
} catch (e) {
throw new Error("JSONError: Cannot parse evaluate response.");
}
// Emit that shit.
$this.emit(event.name, event.data);
});
}
return msg;
}
var _receiving = 0;
var _finished = false;
this.attachProcess = function (child) {
_process = child;
_process.on('close', function () {
$this.emit('finish');
_finished = true;
});
var firstDebuggerOutput = false;
_process.stdout.on('data', function (data) {
if (!firstDebuggerOutput) {
if (data.indexOf('Debugger listening on port ') === 0) {
data = '[INFO] ' + data;
firstDebuggerOutput = true;
}
}
if (data && data.length && !_mute) process.stdout.write(data);
});
_process.stderr.on('data', function (data) {
if (!firstDebuggerOutput) {
if (data.indexOf('Debugger listening on port ') === 0) {
data = '[INFO] ' + data;
firstDebuggerOutput = true;
}
}
var msg = _dataHandler(data);
if (msg && msg.length && !_mute) process.stdout.write(msg);
});
};
this.destroy = function (callback) {
// We must wait for all events to come and only then destroy the instance.
console.log("ALMOST DESTRUCT");
function destruct() {
$this._destroy();
$this.removeAllListeners();
$this.scriptManager.destroy();
$this.scriptStorage.destroy();
_process = null;
_dataBuffer = null;
console.log("DESTRUCTED");
callback();
}
if (!_finished) {
console.log("NOT FINISHED YET");
this.once('finish', function () {
console.log("= FINISHED");
if (_receiving > 0) {
console.log("NOT RECEIVED YET");
$this.once('received', function () {
console.log("= RECEIVED");
destruct();
});
} else {
console.log("RECEIVED ALREADY");
destruct();
}
});
} else {
console.log("FINISHED ALREADY");
destruct();
}
}
}
util.inherits(Debugger, DebuggerClient);
module.exports = Debugger;