UNPKG

iobroker.javascript

Version:
728 lines (727 loc) 28.4 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.NodeInspector = void 0; /* * 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 node_child_process_1 = require("node:child_process"); const node_events_1 = require("node:events"); const node_net_1 = require("node:net"); const node_util_1 = require("node:util"); const node_path_1 = require("node:path"); const node_fs_1 = require("node:fs"); // @ts-expect-error no types available const inspect_client_1 = __importDefault(require("node-inspect/lib/internal/inspect_client")); const debugger_1 = __importDefault(require("./debugger")); const breakOnStart = process.argv.includes('--breakOnStart'); const debuglog = (0, node_util_1.debuglog)('inspect'); let inspector; let scriptToDebug = ''; // script.js.yy let instanceToDebug = ''; // adapter.X let alreadyPausedOnFirstLine = false; 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 = (0, node_net_1.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 = (0, node_path_1.normalize)(script); return portIsFree(inspectHost, inspectPort).then(() => { return new Promise(resolve => { const args = [breakOnStart ? `--inspect-brk=${inspectPort}` : `--inspect=${inspectPort}`].concat([script], scriptArgs || []); const child = (0, node_child_process_1.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; const res = /Debugger listening on ws:\/\/\[?(.+?)]?:(\d+)\//.exec(output); if (res) { const host = res[1]; const port = Number.parseInt(res[2]); child.stderr.removeListener('data', waitForListenHint); resolve([child, port, host]); } } child.stderr.on('data', waitForListenHint); }); }); } function createAgentProxy(domain, client) { const agent = new node_events_1.EventEmitter(); agent.then = (...args) => { // TODO: potentially fetch the protocol and pretty-print it here. const descriptor = { [node_util_1.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 { options; stdin; stdout; scripts; delayedContext; paused; child; _runScript; client; Debugger; HeapProfiler; Profiler; Runtime; handleDebugEvent; mainScriptId; mainFile; repl; constructor(options, stdin, stdout) { this.options = options; this.stdin = stdin; this.stdout = stdout; this.scripts = {}; this.delayedContext = 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 inspect_client_1.default(); this.Debugger = createAgentProxy('Debugger', this.client); this.HeapProfiler = createAgentProxy('HeapProfiler', this.client); this.Profiler = createAgentProxy('Profiler', this.client); this.Runtime = createAgentProxy('Runtime', 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.delayedContext) { console.log('Send to debugger: readyToDebug'); void 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.delayedContext, url: this.mainFile, }); this.delayedContext = 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]) { void 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.delayedContext = 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: 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 = (0, debugger_1.default)(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 }); } } } exports.NodeInspector = NodeInspector; 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 { // Windows does not support UNIX signals. To enable debugging, you can use an undocumented API function // https://github.com/node-inspector/node-inspector?tab=readme-ov-file#windows // @ts-expect-error undocumented function process._debugProcess(pid); } catch (e) { if (e && e.code === 'ESRCH') { console.error(`Target process: ${pid} doesn't exist.`); 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) { /* 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); } inspector.child?.kill(); process.exit(1); } process.on('uncaughtException', handleUnexpectedError); } 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; } process.on('message', (message) => { if (typeof message === 'string') { try { message = JSON.parse(message); } catch { return console.error(`Cannot parse: ${JSON.stringify(message)}`); } } processCommand(message); }); sendToHost({ cmd: 'ready' }); function processCommand(data) { console.log(`[DEBUGGER]: processCommand: ${JSON.stringify(data)}`); if (data.cmd === 'start') { scriptToDebug = data.scriptName; // we can request the script by name or iobroker instance to debug if (scriptToDebug) { startInspect([ `${__dirname}/../main.js`, (data.instance || 0).toString(), '--debug', '--debugScript', scriptToDebug, ]); } else { instanceToDebug = data.adapterInstance; const [adapter, instance] = instanceToDebug.split('.'); let file; try { file = require.resolve(`iobroker.${adapter}`); } catch (e) { // try to locate in the same dir const dir = (0, node_path_1.normalize)((0, node_path_1.join)(__dirname, '..', `iobroker.${adapter}`)); if ((0, node_fs_1.existsSync)(dir)) { const pack = require((0, node_path_1.join)(dir, 'package.json')); if ((0, node_fs_1.existsSync)((0, node_path_1.join)(dir, pack.main || `${adapter}.js`))) { file = (0, node_path_1.join)(dir, pack.main || `${adapter}.js`); } } if (!file) { sendToHost({ cmd: 'error', error: `Cannot locate iobroker.${adapter}`, errorContext: e }); setTimeout(() => { sendToHost({ cmd: 'finished', context: `Cannot locate iobroker.${adapter}` }); setTimeout(() => process.exit(124), 500); }, 200); return; } } 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)); void 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') { void Promise.all(data.breakpoints?.map(breakpointId => inspector.Debugger.removeBreakpoint({ breakpointId }) .then(() => breakpointId) .catch((e) => { console.error(`[DEBUGGER]: Cannot clear breakpoint: ${e}`); sendToHost({ cmd: 'error', error: `Cannot clear breakpoint: ${e}`, errorContext: e, id: breakpointId, }); })) || []).then(breakpoints => { console.error(`[DEBUGGER]: sent breakpoints ${JSON.stringify(breakpoints)}`); sendToHost({ cmd: 'cb', breakpoints }); }); } else if (data.cmd === 'watch') { void 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') { void 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') { void Promise.all(data.scopes ?.filter(scope => scope?.object?.objectId) .map(scope => inspector.Runtime.getProperties({ objectId: scope.object.objectId, generatePreview: true, }) .then((result) => { return { type: scope.type, properties: result }; }) .catch((e) => sendToHost({ cmd: 'error', error: `Cannot get scopes expr: ${e}`, errorContext: e, }))) || []).then(scopes => sendToHost({ cmd: 'scope', scopes })); } else if (data.cmd === 'setValue') { inspector.Debugger.setVariableValue({ variableName: data.variableName, scopeNumber: data.scopeNumber, newValue: data.newValue, callFrameId: data.callFrameId, }) .catch((e) => { console.error(`Cannot setValue "${data.variableName}": ${e}`); sendToHost({ cmd: 'setValue', variableName: `Cannot setValue: ${e}`, errorContext: e, }); }) .then(() => { console.log(`setValue "${data.variableName}" successful`); sendToHost({ cmd: 'setValue', variableName: data.variableName, scopeNumber: data.scopeNumber, newValue: data.newValue, callFrameId: data.callFrameId, }); }); } else if (data.cmd === 'expressions') { void Promise.all(data.expressions?.map(item => inspector.Debugger.evaluateOnCallFrame({ callFrameId: data.callFrameId, expression: item.name, objectGroup: 'node-inspect', returnByValue: true, generatePreview: true, }) // @ts-expect-error fix later .then(({ result, wasThrown }) => { if (wasThrown) { return { name: item.name, result: convertResultToError(result) }; } 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: breakpoints.locations })) .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); if (data.expr) { console.error(`[EXPRESSION] ${JSON.stringify(data.expr)}`); } if (data.bp) { console.error(`[BP] ${JSON.stringify(data.bp)}`); } } if (process.send) { process.send(JSON.stringify(data)); } } //# sourceMappingURL=inspect.js.map