Skip to content

Commit

Permalink
process: initial SourceMap support via NODE_V8_COVERAGE
Browse files Browse the repository at this point in the history
PR-URL: #28960
Reviewed-By: Anna Henningsen <[email protected]>
Reviewed-By: Benjamin Gruenbaum <[email protected]>
Reviewed-By: Matteo Collina <[email protected]>
Reviewed-By: David Carlier <[email protected]>
  • Loading branch information
bcoe authored and addaleax committed Sep 21, 2019
1 parent e74f308 commit 8f06773
Show file tree
Hide file tree
Showing 20 changed files with 497 additions and 13 deletions.
62 changes: 53 additions & 9 deletions doc/api/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -1110,9 +1110,19 @@ variable is strongly discouraged.

### `NODE_V8_COVERAGE=dir`

When set, Node.js will begin outputting [V8 JavaScript code coverage][] to the
directory provided as an argument. Coverage is output as an array of
[ScriptCoverage][] objects:
When set, Node.js will begin outputting [V8 JavaScript code coverage][] and
[Source Map][] data to the directory provided as an argument (coverage
information is written as JSON to files with a `coverage` prefix).

`NODE_V8_COVERAGE` will automatically propagate to subprocesses, making it
easier to instrument applications that call the `child_process.spawn()` family
of functions. `NODE_V8_COVERAGE` can be set to an empty string, to prevent
propagation.

#### Coverage Output

Coverage is output as an array of [ScriptCoverage][] objects on the top-level
key `result`:

```json
{
Expand All @@ -1126,13 +1136,46 @@ directory provided as an argument. Coverage is output as an array of
}
```

`NODE_V8_COVERAGE` will automatically propagate to subprocesses, making it
easier to instrument applications that call the `child_process.spawn()` family
of functions. `NODE_V8_COVERAGE` can be set to an empty string, to prevent
propagation.
#### Source Map Cache

> Stability: 1 - Experimental
If found, Source Map data is appended to the top-level key `source-map-cache`
on the JSON coverage object.

`source-map-cache` is an object with keys representing the files source maps
were extracted from, and the values include the raw source-map URL
(in the key `url`) and the parsed Source Map V3 information (in the key `data`).

At this time coverage is only collected in the main thread and will not be
output for code executed by worker threads.
```json
{
"result": [
{
"scriptId": "68",
"url": "file:///absolute/path/to/source.js",
"functions": []
}
],
"source-map-cache": {
"file:///absolute/path/to/source.js": {
"url": "./path-to-map.json",
"data": {
"version": 3,
"sources": [
"file:///absolute/path/to/original.js"
],
"names": [
"Foo",
"console",
"info"
],
"mappings": "MAAMA,IACJC,YAAaC",
"sourceRoot": "./"
}
}
}
}
```

### `OPENSSL_CONF=file`
<!-- YAML
Expand Down Expand Up @@ -1203,6 +1246,7 @@ greater than `4` (its current default value). For more information, see the
[Chrome DevTools Protocol]: https://chromedevtools.github.io/devtools-protocol/
[REPL]: repl.html
[ScriptCoverage]: https://chromedevtools.github.io/devtools-protocol/tot/Profiler#type-ScriptCoverage
[Source Map]: https://sourcemaps.info/spec.html
[Subresource Integrity]: https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity
[V8 JavaScript code coverage]: https://v8project.blogspot.com/2017/12/javascript-code-coverage.html
[customizing esm specifier resolution]: esm.html#esm_customizing_esm_specifier_resolution_algorithm
Expand Down
2 changes: 2 additions & 0 deletions lib/internal/bootstrap/pre_execution.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,9 @@ function setupCoverageHooks(dir) {
const cwd = require('internal/process/execution').tryGetCwd();
const { resolve } = require('path');
const coverageDirectory = resolve(cwd, dir);
const { sourceMapCacheToObject } = require('internal/source_map');
internalBinding('profiler').setCoverageDirectory(coverageDirectory);
internalBinding('profiler').setSourceMapCacheGetter(sourceMapCacheToObject);
return coverageDirectory;
}

Expand Down
7 changes: 5 additions & 2 deletions lib/internal/modules/cjs/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const {
} = primordials;

const { NativeModule } = require('internal/bootstrap/loaders');
const { maybeCacheSourceMap } = require('internal/source_map');
const { pathToFileURL, fileURLToPath, URL } = require('internal/url');
const { deprecate } = require('internal/util');
const vm = require('vm');
Expand Down Expand Up @@ -845,7 +846,9 @@ Module.prototype.require = function(id) {
var resolvedArgv;
let hasPausedEntry = false;

function wrapSafe(filename, content) {
function wrapSafe(filename, content, cjsModuleInstance) {
maybeCacheSourceMap(filename, content, cjsModuleInstance);

if (patched) {
const wrapper = Module.wrap(content);
return vm.runInThisContext(wrapper, {
Expand Down Expand Up @@ -910,7 +913,7 @@ Module.prototype._compile = function(content, filename) {
manifest.assertIntegrity(moduleURL, content);
}

const compiledWrapper = wrapSafe(filename, content);
const compiledWrapper = wrapSafe(filename, content, this);

var inspectorWrapper = null;
if (getOptionValue('--inspect-brk') && process._eval == null) {
Expand Down
2 changes: 2 additions & 0 deletions lib/internal/modules/esm/translators.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const {
} = require('internal/errors').codes;
const readFileAsync = promisify(fs.readFile);
const JsonParse = JSON.parse;
const { maybeCacheSourceMap } = require('internal/source_map');

const debug = debuglog('esm');

Expand Down Expand Up @@ -74,6 +75,7 @@ async function importModuleDynamically(specifier, { url }) {
// Strategy for loading a standard JavaScript module
translators.set('module', async function moduleStrategy(url) {
const source = `${await getSource(url)}`;
maybeCacheSourceMap(url, source);
debug(`Translating StandardModule ${url}`);
const { ModuleWrap, callbackMap } = internalBinding('module_wrap');
const module = new ModuleWrap(source, url);
Expand Down
152 changes: 152 additions & 0 deletions lib/internal/source_map.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
'use strict';

// See https://sourcemaps.info/spec.html for SourceMap V3 specification.
const { Buffer } = require('buffer');
const debug = require('internal/util/debuglog').debuglog('source_map');
const { dirname, resolve } = require('path');
const fs = require('fs');
const {
normalizeReferrerURL,
} = require('internal/modules/cjs/helpers');
const { JSON, Object } = primordials;
// For cjs, since Module._cache is exposed to users, we use a WeakMap
// keyed on module, facilitating garbage collection.
const cjsSourceMapCache = new WeakMap();
// The esm cache is not exposed to users, so we can use a Map keyed
// on filenames.
const esmSourceMapCache = new Map();
const { fileURLToPath, URL } = require('url');

function maybeCacheSourceMap(filename, content, cjsModuleInstance) {
if (!process.env.NODE_V8_COVERAGE) return;

let basePath;
try {
filename = normalizeReferrerURL(filename);
basePath = dirname(fileURLToPath(filename));
} catch (err) {
// This is most likely an [eval]-wrapper, which is currently not
// supported.
debug(err.stack);
return;
}

const match = content.match(/\/[*/]#\s+sourceMappingURL=(?<sourceMappingURL>[^\s]+)/);
if (match) {
if (cjsModuleInstance) {
cjsSourceMapCache.set(cjsModuleInstance, {
url: match.groups.sourceMappingURL,
data: dataFromUrl(basePath, match.groups.sourceMappingURL)
});
} else {
// If there is no cjsModuleInstance assume we are in a
// "modules/esm" context.
esmSourceMapCache.set(filename, {
url: match.groups.sourceMappingURL,
data: dataFromUrl(basePath, match.groups.sourceMappingURL)
});
}
}
}

function dataFromUrl(basePath, sourceMappingURL) {
try {
const url = new URL(sourceMappingURL);
switch (url.protocol) {
case 'data:':
return sourceMapFromDataUrl(basePath, url.pathname);
default:
debug(`unknown protocol ${url.protocol}`);
return null;
}
} catch (err) {
debug(err.stack);
// If no scheme is present, we assume we are dealing with a file path.
const sourceMapFile = resolve(basePath, sourceMappingURL);
return sourceMapFromFile(sourceMapFile);
}
}

function sourceMapFromFile(sourceMapFile) {
try {
const content = fs.readFileSync(sourceMapFile, 'utf8');
const data = JSON.parse(content);
return sourcesToAbsolute(dirname(sourceMapFile), data);
} catch (err) {
debug(err.stack);
return null;
}
}

// data:[<mediatype>][;base64],<data> see:
// https://tools.ietf.org/html/rfc2397#section-2
function sourceMapFromDataUrl(basePath, url) {
const [format, data] = url.split(',');
const splitFormat = format.split(';');
const contentType = splitFormat[0];
const base64 = splitFormat[splitFormat.length - 1] === 'base64';
if (contentType === 'application/json') {
const decodedData = base64 ?
Buffer.from(data, 'base64').toString('utf8') : data;
try {
const parsedData = JSON.parse(decodedData);
return sourcesToAbsolute(basePath, parsedData);
} catch (err) {
debug(err.stack);
return null;
}
} else {
debug(`unknown content-type ${contentType}`);
return null;
}
}

// If the sources are not absolute URLs after prepending of the "sourceRoot",
// the sources are resolved relative to the SourceMap (like resolving script
// src in a html document).
function sourcesToAbsolute(base, data) {
data.sources = data.sources.map((source) => {
source = (data.sourceRoot || '') + source;
if (!/^[\\/]/.test(source[0])) {
source = resolve(base, source);
}
if (!source.startsWith('file://')) source = `file://${source}`;
return source;
});
// The sources array is now resolved to absolute URLs, sourceRoot should
// be updated to noop.
data.sourceRoot = '';
return data;
}

function sourceMapCacheToObject() {
const obj = Object.create(null);

for (const [k, v] of esmSourceMapCache) {
obj[k] = v;
}
appendCJSCache(obj);

if (Object.keys(obj).length === 0) {
return undefined;
} else {
return obj;
}
}

// Since WeakMap can't be iterated over, we use Module._cache's
// keys to facilitate Source Map serialization.
function appendCJSCache(obj) {
const { Module } = require('internal/modules/cjs/loader');
Object.keys(Module._cache).forEach((key) => {
const value = cjsSourceMapCache.get(Module._cache[key]);
if (value) {
obj[`file://${key}`] = value;
}
});
}

module.exports = {
sourceMapCacheToObject,
maybeCacheSourceMap
};
1 change: 1 addition & 0 deletions node.gyp
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@
'lib/internal/repl/history.js',
'lib/internal/repl/utils.js',
'lib/internal/socket_list.js',
'lib/internal/source_map.js',
'lib/internal/test/binding.js',
'lib/internal/timers.js',
'lib/internal/tls.js',
Expand Down
1 change: 1 addition & 0 deletions src/env.h
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,7 @@ constexpr size_t kFsStatsBufferLength =
V(primordials, v8::Object) \
V(promise_reject_callback, v8::Function) \
V(script_data_constructor_function, v8::Function) \
V(source_map_cache_getter, v8::Function) \
V(tick_callback_function, v8::Function) \
V(timers_callback_function, v8::Function) \
V(tls_wrap_constructor_function, v8::Function) \
Expand Down
60 changes: 60 additions & 0 deletions src/inspector_profiler.cc
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,58 @@ void V8ProfilerConnection::WriteProfile(Local<String> message) {
if (!GetProfile(result).ToLocal(&profile)) {
return;
}

Local<String> result_s;
if (!v8::JSON::Stringify(context, profile).ToLocal(&result_s)) {
fprintf(stderr, "Failed to stringify %s profile result\n", type());
return;
}

// Create the directory if necessary.
std::string directory = GetDirectory();
DCHECK(!directory.empty());
if (!EnsureDirectory(directory, type())) {
return;
}

std::string filename = GetFilename();
DCHECK(!filename.empty());
std::string path = directory + kPathSeparator + filename;

WriteResult(env_, path.c_str(), result_s);
}

void V8CoverageConnection::WriteProfile(Local<String> message) {
Isolate* isolate = env_->isolate();
Local<Context> context = env_->context();
HandleScope handle_scope(isolate);
Context::Scope context_scope(context);

// Get message.result from the response.
Local<Object> result;
if (!ParseProfile(env_, message, type()).ToLocal(&result)) {
return;
}
// Generate the profile output from the subclass.
Local<Object> profile;
if (!GetProfile(result).ToLocal(&profile)) {
return;
}

// append source-map cache information to coverage object:
Local<Function> source_map_cache_getter = env_->source_map_cache_getter();
Local<Value> source_map_cache_v;
if (!source_map_cache_getter->Call(env()->context(),
Undefined(isolate), 0, nullptr)
.ToLocal(&source_map_cache_v)) {
return;
}
// Avoid writing to disk if no source-map data:
if (!source_map_cache_v->IsUndefined()) {
profile->Set(context, FIXED_ONE_BYTE_STRING(isolate, "source-map-cache"),
source_map_cache_v);
}

Local<String> result_s;
if (!v8::JSON::Stringify(context, profile).ToLocal(&result_s)) {
fprintf(stderr, "Failed to stringify %s profile result\n", type());
Expand Down Expand Up @@ -385,12 +437,20 @@ static void SetCoverageDirectory(const FunctionCallbackInfo<Value>& args) {
env->set_coverage_directory(*directory);
}


static void SetSourceMapCacheGetter(const FunctionCallbackInfo<Value>& args) {
CHECK(args[0]->IsFunction());
Environment* env = Environment::GetCurrent(args);
env->set_source_map_cache_getter(args[0].As<Function>());
}

static void Initialize(Local<Object> target,
Local<Value> unused,
Local<Context> context,
void* priv) {
Environment* env = Environment::GetCurrent(context);
env->SetMethod(target, "setCoverageDirectory", SetCoverageDirectory);
env->SetMethod(target, "setSourceMapCacheGetter", SetSourceMapCacheGetter);
}

} // namespace profiler
Expand Down
Loading

0 comments on commit 8f06773

Please sign in to comment.