UNPKG

devil-windows

Version:

Debugger, profiler and runtime with embedded WebKit DevTools client (for Windows).

886 lines (724 loc) 31.3 kB
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;