import * as inspector from 'inspector';
import * as path from 'path';
import * as semver from 'semver';
import consoleLogLevel = require('console-log-level');
import {StatusMessage} from '../../client/stackdriver/status-message';
import * as stackdriver from '../../types/stackdriver';
import {ResolvedDebugAgentConfig} from '../config';
import {ScanStats} from '../io/scanner';
import * as v8 from '../../types/v8';
export const messages = {
INVALID_BREAKPOINT: 'invalid snapshot - id or location missing',
SOURCE_FILE_NOT_FOUND:
'A script matching the source file was not found loaded on the debuggee',
SOURCE_FILE_AMBIGUOUS: 'Multiple files match the path specified',
V8_BREAKPOINT_ERROR: 'Unable to set breakpoint in v8',
V8_BREAKPOINT_CLEAR_ERROR: 'Unable to clear breakpoint in v8',
SYNTAX_ERROR_IN_CONDITION: 'Syntax error in condition: ',
ERROR_EVALUATING_CONDITION: 'Error evaluating condition: ',
ERROR_COMPILING_CONDITION: 'Error compiling condition.',
DISALLOWED_EXPRESSION: 'Expression not allowed',
SOURCE_MAP_READ_ERROR:
'The source map could not be read or was incorrectly formatted',
V8_BREAKPOINT_DISABLED: 'Internal error: V8 breakpoint externally disabled',
CAPTURE_BREAKPOINT_DATA: 'Error trying to capture snapshot data: ',
INVALID_LINE_NUMBER: 'Invalid snapshot position: ',
COULD_NOT_FIND_OUTPUT_FILE:
'Could not determine the output file associated with the transpiled input file',
};
export interface LegacyListener {
enabled: boolean;
listener: (args: v8.ExecutionState, eventData: v8.BreakEvent) => void;
}
export interface InspectorListener {
enabled: boolean;
listener: (args: inspector.Debugger.CallFrame[]) => void;
}
// Exposed for unit testing.
export function findScripts(
scriptPath: string,
config: ResolvedDebugAgentConfig,
fileStats: ScanStats,
logger: consoleLogLevel.Logger
): string[] {
// (path: string, knownFiles: string[], resolved: string[]) => string[]
const resolved = resolveScripts(scriptPath, config, fileStats);
if (config.pathResolver) {
if (typeof config.pathResolver !== 'function') {
logger.warn(
"The 'pathResolver' config must be a function. Continuing " +
"with the agent's default behavior."
);
return resolved;
}
const knownFiles = Object.keys(fileStats);
const calculatedPaths = config.pathResolver(
scriptPath,
knownFiles,
resolved
);
if (calculatedPaths === undefined) {
return resolved;
}
if (!calculatedPaths || !Array.isArray(calculatedPaths)) {
logger.warn(
"The 'pathResolver' config function returned a value " +
"other than 'undefined' or an array of strings. Continuing with " +
"the agent's default behavior."
);
return resolved;
}
for (const path of calculatedPaths) {
if (knownFiles.indexOf(path) === -1) {
logger.warn(
"The 'pathResolver' config function returned a path " +
`'${path}' that is not in the list of paths known to the debug agent ` +
JSON.stringify(knownFiles, null, 2) +
" only known paths can be returned. Continuing with the agent's " +
'default behavior.'
);
return resolved;
}
}
return calculatedPaths;
}
return resolved;
}
function resolveScripts(
scriptPath: string,
config: ResolvedDebugAgentConfig,
fileStats: ScanStats
): string[] {
// Use repository relative mapping if present.
if (config.appPathRelativeToRepository) {
const candidate = scriptPath.replace(
config.appPathRelativeToRepository,
config.workingDirectory
);
// There should be no ambiguity resolution if project root is provided.
return fileStats[candidate] ? [candidate] : [];
}
// Try for an exact match using the working directory.
const candidate = path.join(config.workingDirectory || '', scriptPath);
if (fileStats[candidate]) {
return [candidate];
}
// Next try to match path.
const regexp = pathToRegExp(scriptPath);
const matches = Object.keys(fileStats).filter(regexp.test.bind(regexp));
if (matches.length === 1) {
return matches;
}
// Finally look for files with the same name regardless of path.
return findScriptsFuzzy(scriptPath, Object.keys(fileStats));
}
/**
* Given an list of available files and a script path to match, this function
* tries to resolve the script to a (hopefully unique) match in the file list
* disregarding the full path to the script. This can be useful because repo
* file paths (that the UI has) may not necessarily be suffixes of the absolute
* paths of the deployed files. This happens when the user deploys a
* subdirectory of the repo.
*
* For example consider a file named `a/b.js` in the repo. If the
* directory contents of `a` are deployed rather than the whole repo, we are not
* going to have any file named `a/b.js` in the running Node process.
*
* We incrementally consider more components of the path until we find a unique
* match, or return all the potential matches.
*
* @example findScriptsFuzzy('a/b.js', ['/d/b.js']) // -> ['/d/b.js']
* @example findScriptsFuzzy('a/b.js', ['/c/b.js', '/d/b.js']); // -> []
* @example findScriptsFuzzy('a/b.js', ['/x/a/b.js', '/y/a/b.js'])
* // -> ['x/a/b.js', 'y/a/b.js']
*
* @param {string} scriptPath partial path to the script.
* @param {array<string>} fileList an array of absolute paths of filenames
* available.
* @return {array<string>} list of files that match.
*/
export function findScriptsFuzzy(
scriptPath: string,
fileList: string[]
): string[] {
let matches = fileList;
const components = scriptPath.split(path.sep);
for (let i = components.length - 1; i >= 0; i--) {
const regexp = pathToRegExp(components.slice(i).join(path.sep));
matches = matches.filter(regexp.test.bind(regexp));
if (matches.length <= 1) {
break; // Either no matches, or we found a unique match.
}
}
return matches;
}
/**
* @param {!string} scriptPath path of a script
*/
export function pathToRegExp(scriptPath: string): RegExp {
// make sure the script path starts with a slash. This makes sure our
// regexp doesn't match monkey.js when the user asks to set a breakpoint
// in key.js
if (path.sep === '/' || scriptPath.indexOf(':') === -1) {
scriptPath = path.join(path.sep, scriptPath);
}
if (path.sep !== '/') {
scriptPath = scriptPath.replace(new RegExp('\\\\', 'g'), '\\\\');
}
// Escape '.' characters.
scriptPath = scriptPath.replace('.', '\\.');
return new RegExp(scriptPath + '$');
}
/**
* Formats a provided message and a high-resolution interval of the format
* [seconds, nanoseconds] (for example, from process.hrtime()) prefixed with a
* provided message as a string intended for logging.
* @param {string} msg The mesage that prefixes the formatted interval.
* @param {number[]} interval The interval to format.
* @return {string} A formatted string.
*/
export const formatInterval = (msg: string, interval: number[]): string => {
return msg + (interval[0] * 1000 + interval[1] / 1000000) + 'ms';
};
export function setErrorStatusAndCallback(
fn: (err: Error | null) => void,
breakpoint: stackdriver.Breakpoint,
refersTo: stackdriver.Reference,
message: string
): void {
const error = new Error(message);
setImmediate(() => {
if (breakpoint && !breakpoint.status) {
breakpoint.status = new StatusMessage(refersTo, message, true);
}
fn(error);
});
}
/**
* Produces a compilation function based on the file extension of the
* script path in which the breakpoint is set.
*
* @param {Breakpoint} breakpoint
*/
export function getBreakpointCompiler(
breakpoint: stackdriver.Breakpoint
): ((uncompiled: string) => string) | null {
// TODO: Address the case where `breakpoint.location` is `null`.
switch (
path
.normalize((breakpoint.location as stackdriver.SourceLocation).path)
.split('.')
.pop()
) {
case 'coffee':
return uncompiled => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const comp = require('coffeescript');
const compiled = comp.compile('0 || (' + uncompiled + ')');
// Strip out coffeescript scoping wrapper to get translated condition
const re = /\(function\(\) {\s*0 \|\| \((.*)\);\n\n\}\)\.call\(this\);/;
const match = re.exec(compiled);
if (match && match.length > 1) {
return match[1].trim();
} else {
throw new Error('Compilation Error for: ' + uncompiled);
}
};
case 'es6':
case 'es':
case 'jsx':
return uncompiled => {
// If we want to support es6 watch expressions we can compile them
// here. Babel is a large dependency to have if we don't need it in
// all cases.
return uncompiled;
};
default:
return null;
}
}
export function removeFirstOccurrenceInArray<T>(array: T[], element: T): void {
const index = array.indexOf(element);
if (index >= 0) {
array.splice(index, 1);
}
}
/**
* Used to determine whether the specified node version satisfies the
* given semver range. This method is able to properly handle nightly
* builds. For example,
* satisfies('v10.0.0-nightly201804132a6ab9b37b', '>=10')
* returns `true`.
*
* @param version The node version.
* @param semverRange The semver range to check against
*/
export function satisfies(nodeVersion: string, semverRange: string) {
// Coercing the version is needed to handle nightly builds correctly.
// In particular,
// semver.satisfies('v10.0.0-nightly201804132a6ab9b37b', '>=10')
// returns `false`.
//
// `semver.coerce` can be used to coerce that nightly version to v10.0.0.
const coercedVersion = semver.coerce(nodeVersion);
const finalVersion = coercedVersion ? coercedVersion.version : nodeVersion;
return semver.satisfies(finalVersion, semverRange);
}
/**
* Used to determine if the specified file is a JavaScript file
* by determining if it has a `.js` file extension.
*
* @param filepath The path of the file to analyze.
*/
export function isJavaScriptFile(filepath: string) {
return path.extname(filepath).toLowerCase() === '.js';
}