iobroker.javascript
Version:
Rules Engine for ioBroker
689 lines (621 loc) • 27 kB
JavaScript
/*
* Copyright Node.js contributors. All rights reserved.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
;
const { spawn } = require('child_process');
const { EventEmitter } = require('events');
const net = require('net');
const util = require('util');
const path = require('path');
const fs = require('fs');
let breakOnStart;
for (let i = 0; i < process.argv.length; i++) {
if (process.argv[i] === '--breakOnStart') {
breakOnStart = true;
}
}
//const runAsStandalone = typeof __dirname !== 'undefined';
const InspectClient = require('node-inspect/lib/internal/inspect_client');
const createRepl = require('./debugger');
const debuglog = util.debuglog('inspect');
class StartupError extends Error {
constructor(message) {
super(message);
this.name = 'StartupError';
}
}
function portIsFree(host, port, timeout = 9999) {
if (port === 0) {
// Binding to a random port.
return Promise.resolve();
}
const retryDelay = 150;
let didTimeOut = false;
return new Promise((resolve, reject) => {
setTimeout(() => {
didTimeOut = true;
reject(new StartupError(
`Timeout (${timeout}) waiting for ${host}:${port} to be free`));
}, timeout);
function pingPort() {
if (didTimeOut) return;
const socket = net.connect(port, host);
let didRetry = false;
function retry() {
if (!didRetry && !didTimeOut) {
didRetry = true;
setTimeout(pingPort, retryDelay);
}
}
socket.on('error', (error) => {
if (error.code === 'ECONNREFUSED') {
resolve();
} else {
retry();
}
});
socket.on('connect', () => {
socket.destroy();
retry();
});
}
pingPort();
});
}
function runScript(script, scriptArgs, inspectHost, inspectPort, childPrint) {
script = path.normalize(script);
return portIsFree(inspectHost, inspectPort)
.then(() => {
return new Promise((resolve) => {
const args = [breakOnStart ? `--inspect-brk=${inspectPort}` : `--inspect=${inspectPort}`].concat([script], scriptArgs);
const child = spawn(process.execPath, args);
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', childPrint);
child.stderr.on('data', text => childPrint(text, true));
let output = '';
function waitForListenHint(text) {
output += text;
if (/Debugger listening on ws:\/\/\[?(.+?)]?:(\d+)\//.test(output)) {
const host = RegExp.$1;
const port = Number.parseInt(RegExp.$2);
child.stderr.removeListener('data', waitForListenHint);
resolve([child, port, host]);
}
}
child.stderr.on('data', waitForListenHint);
});
});
}
function createAgentProxy(domain, client) {
const agent = new EventEmitter();
agent.then = (...args) => {
// TODO: potentially fetch the protocol and pretty-print it here.
const descriptor = {
[util.inspect.custom](depth, { stylize }) {
return stylize(`[Agent ${domain}]`, 'special');
},
};
return Promise.resolve(descriptor).then(...args);
};
return new Proxy(agent, {
get(target, name) {
if (name in target) return target[name];
return function callVirtualMethod(params) {
return client.callMethod(`${domain}.${name}`, params);
};
},
});
}
class NodeInspector {
constructor(options, stdin, stdout) {
this.options = options;
this.stdin = stdin;
this.stdout = stdout;
this.scripts = {};
this.deleyedContext = null;
this.paused = true;
this.child = null;
if (options.script) {
this._runScript = runScript.bind(null,
options.script,
options.scriptArgs,
options.host,
options.port,
this.childPrint.bind(this),
);
} else {
this._runScript =
() => Promise.resolve([null, options.port, options.host]);
}
this.client = new InspectClient();
this.domainNames = ['Debugger', 'HeapProfiler', 'Profiler', 'Runtime'];
this.domainNames.forEach(domain =>
this[domain] = createAgentProxy(domain, this.client));
this.handleDebugEvent = (fullName, params) => {
const [domain, name] = fullName.split('.');
if (domain === 'Debugger' && name === 'scriptParsed') {
// console.log(`Parsed: ${params.url}`);
if ((scriptToDebug && params.url.includes(scriptToDebug)) || (instanceToDebug && params.url.includes(instanceToDebug))) {
console.log(`My scriptID: ${params.scriptId}`);
this.mainScriptId = params.scriptId;
this.mainFile = params.url.replace('file:///', '');
// load text of a script
this.scripts[this.mainScriptId] = this.Debugger.getScriptSource({ scriptId: this.mainScriptId })
.then(script => ({ script: script.scriptSource, scriptId: this.mainScriptId }));
// sometimes the pause event comes before scriptParsed
if (this.deleyedContext) {
console.log('Send to debugger: readyToDebug');
this.scripts[this.mainScriptId]
.then(data => {
// console.log('Send to debugger: readyToDebug ' + JSON.stringify(data));
sendToHost({
cmd: 'readyToDebug',
scriptId: this.mainScriptId,
script: data.script,
context: this.deleyedContext,
url: this.mainFile
});
this.deleyedContext = null;
});
}
}
return;
} else if (domain === 'Debugger' && name === 'resumed') {
this.Debugger.emit(name, params);
sendToHost({ cmd: 'resumed', context: params });
return;
} else if (domain === 'Debugger' && name === 'paused') {
console.log(`PAUSED!! ${alreadyPausedOnFirstLine}`);
if (!alreadyPausedOnFirstLine && params.reason === 'exception') {
// ignore all exceptions by start
this.Debugger.resume();
return;
}
//console.warn(fullName + ': => \n' + JSON.stringify(params, null, 2));
this.Debugger.emit(name, params);
if (!alreadyPausedOnFirstLine) {
alreadyPausedOnFirstLine = true;
// sometimes the pause event comes before scriptParsed
if (this.mainScriptId && this.scripts[this.mainScriptId]) {
this.scripts[this.mainScriptId]
.then(data =>
sendToHost({ cmd: 'readyToDebug', scriptId: data.scriptId, script: data.script, context: params, url: this.mainFile }));
} else {
// store context to send it later when script ID will be known
this.deleyedContext = params;
console.log('PAUSED, but no scriptId');
}
} else {
// this.scripts[params.loca]
// .then(data =>
sendToHost({ cmd: 'paused', context: params });
}
return;
}
if (domain === 'Runtime') {
//console.warn(fullName + ': => \n' + JSON.stringify(params, null, 2));
}
if (domain === 'Runtime' && name === 'consoleAPICalled') {
const text = params.args[0].value;
if (instanceToDebug) {
sendToHost({ cmd: 'log', severity: params.type === 'warning' ? 'warn' : 'error', text, ts: Date.now() });
} else if (text.includes(`$$${scriptToDebug}$$`)) {
console.log(`${fullName} [${params.executionContextId}]: => ${text}`);
const [severity, _text] = text.split(`$$${scriptToDebug}$$`);
sendToHost({ cmd: 'log', severity, text: _text, ts: params.args[1] && params.args[1].value ? params.args[1].value : Date.now() });
} else if (params.type === 'warning' || params.type === 'error') {
sendToHost({
cmd: 'log',
severity: params.type === 'warning' ? 'warn' : 'error',
text,
ts: Date.now(),
});
}
return;
} else if (domain === 'Runtime' && (params.id === 2 || params.executionContextId === 2)) {
if (name === 'executionContextCreated') {
console.warn(`${fullName}: =>\n${JSON.stringify(params, null, 2)}`);
} else if (name === 'executionContextDestroyed') {
console.warn(`${fullName}: =>\n${JSON.stringify(params, null, 2)}`);
sendToHost({ cmd: 'finished', context: params });
}
return;
} else if (domain === 'Runtime' && name === 'executionContextDestroyed' && params.executionContextId === 1) {
sendToHost({ cmd: 'finished', context: params });
console.log('Exited!');
setTimeout(() =>
process.exit(125), 200);
} else if (domain === 'Debugger' && name === 'scriptFailedToParse') {
// ignore
return;
}
console.warn(`${fullName}: =>\n${JSON.stringify(params, null, 2)}`);
/*if (domain in this) {
this[domain].emit(name, params);
}*/
};
this.client.on('debugEvent', this.handleDebugEvent);
const startRepl = createRepl(this);
// Handle all possible exits
process.on('exit', () => this.killChild());
process.once('SIGTERM', process.exit.bind(process, 0));
process.once('SIGHUP', process.exit.bind(process, 0));
this.run()
.then(() => startRepl())
.then((repl) => {
this.repl = repl;
this.repl.on('exit', () =>
process.exit(0));
this.paused = false;
})
.then(null, (error) => process.nextTick(() => { throw error; }));
}
suspendReplWhile(fn) {
if (this.repl) {
this.repl.pause();
}
this.stdin.pause();
this.paused = true;
return new Promise(resolve => resolve(fn()))
.then(() => {
this.paused = false;
if (this.repl) {
this.repl.resume();
this.repl.displayPrompt();
}
this.stdin.resume();
})
.then(null, error => process.nextTick(() => { throw error; }));
}
killChild() {
this.client.reset();
if (this.child) {
this.child.kill();
this.child = null;
}
}
run() {
this.killChild();
return this._runScript().then(([child, port, host]) => {
this.child = child;
let connectionAttempts = 0;
const attemptConnect = () => {
++connectionAttempts;
debuglog('connection attempt #%d', connectionAttempts);
this.stdout.write('.');
return this.client.connect(port, host)
.then(() => {
debuglog('connection established');
this.stdout.write(' ok');
}, error => {
debuglog('connect failed', error);
// If it's failed to connect 10 times, then print a failed message
if (connectionAttempts >= 10) {
this.stdout.write(' failed to connect, please retry\n');
process.exit(1);
}
return new Promise((resolve) => setTimeout(resolve, 500))
.then(attemptConnect);
});
};
this.print(`connecting to ${host}:${port} ..`, true);
return attemptConnect();
});
}
clearLine() {
if (this.stdout.isTTY) {
this.stdout.cursorTo(0);
this.stdout.clearLine(1);
} else {
this.stdout.write('\b');
}
}
print(text, oneline = false) {
this.clearLine();
this.stdout.write(oneline ? text : `${text}\n`);
}
childPrint(text, isError) {
isError && this.print(
text.toString()
.split(/\r\n|\r|\n/g)
.filter((chunk) => !!chunk)
.map((chunk) => `< ${chunk}`)
.join('\n')
);
if (!this.paused) {
this.repl.displayPrompt(true);
}
if (/Waiting for the debugger to disconnect\.\.\.\n$/.test(text)) {
this.killChild();
sendToHost({ cmd: 'finished', text });
}
}
}
function parseArgv([target, ...args]) {
let host = '127.0.0.1';
let port = 9229;
let isRemote = false;
let script = target;
let scriptArgs = args;
const hostMatch = target.match(/^([^:]+):(\d+)$/);
const portMatch = target.match(/^--port=(\d+)$/);
if (hostMatch) {
// Connecting to remote debugger
// `node-inspect localhost:9229`
host = hostMatch[1];
port = parseInt(hostMatch[2], 10);
isRemote = true;
script = null;
} else if (portMatch) {
// start debugee on custom port
// `node inspect --port=9230 script.js`
port = parseInt(portMatch[1], 10);
script = args[0];
scriptArgs = args.slice(1);
} else if (args.length === 1 && /^\d+$/.test(args[0]) && target === '-p') {
// Start debugger against a given pid
const pid = parseInt(args[0], 10);
try {
process._debugProcess(pid);
} catch (e) {
if (e.code === 'ESRCH') {
/* eslint-disable no-console */
console.error(`Target process: ${pid} doesn't exist.`);
/* eslint-enable no-console */
process.exit(1);
}
throw e;
}
script = null;
isRemote = true;
}
return {
host, port, isRemote, script, scriptArgs,
};
}
function startInspect(argv = process.argv.slice(2), stdin = process.stdin, stdout = process.stdout) {
/* eslint-disable no-console */
/*if (argv.length < 1) {
const invokedAs = runAsStandalone ?
'node-inspect' :
`${process.argv0} ${process.argv[1]}`;
console.error(`Usage: ${invokedAs} script.js`);
console.error(` ${invokedAs} <host>:<port>`);
console.error(` ${invokedAs} -p <pid>`);
process.exit(1);
}*/
const options = parseArgv(argv);
inspector = new NodeInspector(options, stdin, stdout);
stdin.resume();
function handleUnexpectedError(e) {
if (!(e instanceof StartupError)) {
console.error('There was an internal error in node-inspect. Please report this bug.');
console.error(e.message);
console.error(e.stack);
} else {
console.error(e.message);
}
if (inspector.child) inspector.child.kill();
process.exit(1);
}
process.on('uncaughtException', handleUnexpectedError);
/* eslint-enable no-console */
}
function extractErrorMessage(stack) {
if (!stack) {
return '<unknown>';
}
const m = stack.match(/^\w+: ([^\n]+)/);
return m ? m[1] : stack;
}
function convertResultToError(result) {
const { className, description } = result;
const err = new Error(extractErrorMessage(description));
err.stack = description;
Object.defineProperty(err, 'name', { value: className });
return err;
}
let inspector;
let scriptToDebug;
let instanceToDebug;
let alreadyPausedOnFirstLine = false;
process.on('message', message => {
if (typeof message === 'string') {
try {
message = JSON.parse(message);
} catch (e) {
return console.error(`Cannot parse: ${message}`);
}
}
processCommand(message);
});
sendToHost({ cmd: 'ready' });
// possible commands
// start - {cmd: 'start', scriptName: 'script.js.myName'} - start the debugging
// end - {cmd: 'end'} - end the debugging and stop process
// source - {cmd: 'source', scriptId} - read text of script by id
// watch - {cmd: 'watch', expressions: ['i']} - add to watch the variable
// unwatch - {cmd: 'unwatch', expressions: ['i']} - add to watch the variable
// sb - {cmd: 'sb', breakpoints: [{scriptId: 50, lineNumber: 4, columnNumber: 0}]} - set breakpoint
// cb - {cmd: 'cb', breakpoints: [{scriptId: 50, lineNumber: 4, columnNumber: 0}]} - clear breakpoint
// pause - {cmd: 'pause'} - pause execution
// cont - {cmd: 'cont'} - resume execution
// next - {cmd: 'next'} - Continue to next line in current file
// step - {cmd: 'step'} - Step into, potentially entering a function
// out - {cmd: 'step'} - Step out, leaving the current function
function processCommand(data) {
if (data.cmd === 'start') {
scriptToDebug = data.scriptName;
instanceToDebug = data.adapterInstance;
if (scriptToDebug) {
startInspect([`${__dirname}/../main.js`, data.instance || 0, '--debug', '--debugScript', scriptToDebug]);
} else {
const [adapter, instance] = instanceToDebug.split('.');
let file;
try {
file = require.resolve(`iobroker.${adapter}`);
} catch (e) {
// try to locate in the same dir
const dir = path.normalize(path.join(__dirname, '..', 'iobroker.' + adapter));
if (fs.existsSync(dir)) {
const pack = require(path.join(dir, 'package.json'));
if (fs.existsSync(path.join(dir, pack.main || `${adapter}.js`))) {
file = path.join(dir, pack.main || `${adapter}.js`);
}
}
if (!file) {
sendToHost({cmd: 'error', error: `Cannot locate iobroker.${adapter}`, errorContext: e});
return setTimeout(() => {
sendToHost({cmd: 'finished', context: `Cannot locate iobroker.${adapter}`});
setTimeout(() => process.exit(124), 500);
}, 200);
}
}
file = file.replace(/\\/g, '/');
instanceToDebug = file;
console.log(`Start ${file} ${instance} --debug`);
startInspect([file, instance, '--debug']);
}
} else if (data.cmd === 'end') {
process.exit();
} else if (data.cmd === 'source') {
inspector.Debugger.getScriptSource({ scriptId: data.scriptId })
.then(script =>
sendToHost({ cmd: 'script', scriptId: data.scriptId, text: script.scriptSource }));
} else if (data.cmd === 'cont') {
inspector.Debugger.resume()
.catch(e => sendToHost({ cmd: 'error', error: e }));
} else if (data.cmd === 'next') {
inspector.Debugger.stepOver()
.catch(e => sendToHost({ cmd: 'error', error: e }));
} else if (data.cmd === 'pause') {
inspector.Debugger.pause()
.catch(e => sendToHost({ cmd: 'error', error: e }));
} else if (data.cmd === 'step') {
inspector.Debugger.stepInto()
.catch(e => sendToHost({ cmd: 'error', error: e }));
} else if (data.cmd === 'out') {
inspector.Debugger.stepOut()
.catch(e => sendToHost({ cmd: 'error', error: e }));
} else if (data.cmd === 'sb') {
console.log(JSON.stringify(data));
Promise.all(data.breakpoints.map(bp =>
inspector.Debugger.setBreakpoint({
location: {
scriptId: bp.scriptId,
lineNumber: bp.lineNumber,
columnNumber: bp.columnNumber
}
})
.then(result =>
({ id: result.breakpointId, location: result.actualLocation}))
.catch(e =>
sendToHost({ cmd: 'error', error: `Cannot set breakpoint: ${e}`, errorContext: e, bp }))))
.then(breakpoints =>
sendToHost({cmd: 'sb', breakpoints}));
} else if (data.cmd === 'cb') {
Promise.all(data.breakpoints.map(breakpointId =>
inspector.Debugger.removeBreakpoint({breakpointId})
.then(() => breakpointId)
.catch(e =>
sendToHost({ cmd: 'error', error: `Cannot clear breakpoint: ${e}`, errorContext: e, id: breakpointId }))))
.then(breakpoints =>
sendToHost({ cmd: 'cb', breakpoints}));
} else if (data.cmd === 'watch') {
Promise.all(data.expressions.map(expr =>
inspector.Debugger.watch(expr)
.catch(e =>
sendToHost({ cmd: 'error', error: `Cannot watch expr: ${e}`, errorContext: e, expr }))))
.then(() => console.log('Watch done'));
} else if (data.cmd === 'unwatch') {
Promise.all(data.expressions.map(expr =>
inspector.Debugger.unwatch(expr)
.catch(e =>
sendToHost({ cmd: 'error', error: `Cannot unwatch expr: ${e}`, errorContext: e, expr }))))
.then(() => console.log('Watch done'));
} else if (data.cmd === 'scope') {
Promise.all(data.scopes.filter(scope => scope && scope.object && scope.object.objectId).map(scope =>
inspector.Runtime.getProperties({
objectId: scope.object.objectId,
generatePreview: true,
})
.then(result => ({ type: scope.type, properties: result }))
.catch(e =>
sendToHost({ cmd: 'error', error: `Cannot get scopes expr: ${e}`, errorContext: e }))))
.then(scopes =>
sendToHost({ cmd: 'scope', scopes: scopes }));
} else if (data.cmd === 'setValue') {
inspector.Debugger.setVariableValue({
variableName: data.variableName,
scopeNumber: data.scopeNumber,
newValue: data.newValue,
callFrameId: data.callFrameId,
})
.catch(e =>
sendToHost({cmd: 'setValue', variableName: `Cannot setValue: ${e}`, errorContext: e}))
.then(() =>
sendToHost(data));
} else if (data.cmd === 'expressions') {
Promise.all(data.expressions.map(item =>
inspector.Debugger.evaluateOnCallFrame({
callFrameId: data.callFrameId,
expression: item.name,
objectGroup: 'node-inspect',
returnByValue: true,
generatePreview: true,
})
.then(({ result, wasThrown }) => {
if (wasThrown) {
return {name: item.name, result: convertResultToError(result)};
} else {
return {name: item.name, result};
}
})
.catch(e =>
sendToHost({cmd: 'expressions', variableName: `Cannot setValue: ${e}`, errorContext: e}))))
.then(expressions =>
sendToHost({cmd: 'expressions', expressions}));
} else if (data.cmd === 'stopOnException') {
inspector.Debugger.setPauseOnExceptions({state: data.state ? 'all' : 'none'})
.catch(e =>
sendToHost({cmd: 'stopOnException', variableName: `Cannot stopOnException: ${e}`, errorContext: e}));
} else if (data.cmd === 'getPossibleBreakpoints') {
inspector.Debugger.getPossibleBreakpoints({start: data.start, end: data.end})
.then(breakpoints =>
sendToHost({cmd: 'getPossibleBreakpoints', breakpoints}))
.catch(e =>
sendToHost({cmd: 'getPossibleBreakpoints', variableName: `Cannot getPossibleBreakpoints: ${e}`, errorContext: e}));
} else {
console.error(`Unknown command: ${JSON.stringify(data)}`);
}
}
function sendToHost(data) {
if (data.cmd === 'error') {
console.error(data.text);
data.expr && console.error(`[EXPRESSION] ${data.expr}`);
data.bp && console.error(`[BP] ${data.bp}`);
}
if (typeof data !== 'string') {
data = JSON.stringify(data);
}
process.send && process.send(data);
}