karma-sourcemap-loader
Version:
Karma plugin that locates and loads existing javascript source map files.
291 lines (260 loc) • 8.77 kB
JavaScript
//@ts-check
const fs = require('graceful-fs');
const path = require('path');
const SOURCEMAP_URL_REGEX = /^\/\/#\s*sourceMappingURL=/;
const CHARSET_REGEX = /^;charset=([^;]+);/;
/**
* @param {*} logger
* @param {karmaSourcemapLoader.Config} config
* @returns {karmaSourcemapLoader.Preprocessor}
*/
function createSourceMapLocatorPreprocessor(logger, config) {
const options = (config && config.sourceMapLoader) || {};
const remapPrefixes = options.remapPrefixes;
const remapSource = options.remapSource;
const useSourceRoot = options.useSourceRoot;
const onlyWithURL = options.onlyWithURL;
const strict = options.strict;
const needsUpdate = remapPrefixes || remapSource || useSourceRoot;
const log = logger.create('preprocessor.sourcemap');
/**
* @param {string[]} sources
*/
function remapSources(sources) {
const all = sources.length;
let remapped = 0;
/** @type {Record<string, boolean>} */
const remappedPrefixes = {};
let remappedSource = false;
/**
* Replaces source path prefixes using a key:value map
* @param {string} source
* @returns {string | undefined}
*/
function handlePrefixes(source) {
if (!remapPrefixes) {
return undefined;
}
let sourcePrefix, targetPrefix, target;
for (sourcePrefix in remapPrefixes) {
targetPrefix = remapPrefixes[sourcePrefix];
if (source.startsWith(sourcePrefix)) {
target = targetPrefix + source.substring(sourcePrefix.length);
++remapped;
// Log only one remapping as an example for each prefix to prevent
// flood of messages on the console
if (!remappedPrefixes[sourcePrefix]) {
remappedPrefixes[sourcePrefix] = true;
log.debug(' ', source, '>>', target);
}
return target;
}
}
}
// Replaces source paths using a custom function
/**
* @param {string} source
* @returns {string | undefined}
*/
function handleMapper(source) {
if (!remapSource) {
return undefined;
}
const target = remapSource(source);
// Remapping is considered happenned only if the handler returns
// a non-empty path different from the existing one
if (target && target !== source) {
++remapped;
// Log only one remapping as an example to prevent flooding the console
if (!remappedSource) {
remappedSource = true;
log.debug(' ', source, '>>', target);
}
return target;
}
}
const result = sources.map((rawSource) => {
const source = rawSource.replace(/\\/g, '/');
const sourceWithRemappedPrefixes = handlePrefixes(source);
if (sourceWithRemappedPrefixes) {
// One remapping is enough; if a prefix was replaced, do not let
// the handler below check the source path any more
return sourceWithRemappedPrefixes;
}
return handleMapper(source) || source;
});
if (remapped) {
log.debug(' ...');
log.debug(' ', remapped, 'sources from', all, 'were remapped');
}
return result;
}
return function karmaSourcemapLoaderPreprocessor(content, file, done) {
/**
* Parses a string with source map as JSON and handles errors
* @param {string} data
* @returns {karmaSourcemapLoader.SourceMap | false | undefined}
*/
function parseMap(data) {
try {
return JSON.parse(data);
} catch (err) {
if (strict) {
done(new Error('malformed source map for' + file.originalPath + '\nError: ' + err));
// Returning `false` will make the caller abort immediately
return false;
}
log.warn('malformed source map for', file.originalPath);
log.warn('Error:', err);
}
}
/**
* Sets the sourceRoot property to a fixed or computed value
* @param {karmaSourcemapLoader.SourceMap} sourceMap
*/
function setSourceRoot(sourceMap) {
const sourceRoot = typeof useSourceRoot === 'function' ? useSourceRoot(file) : useSourceRoot;
if (sourceRoot) {
sourceMap.sourceRoot = sourceRoot;
}
}
/**
* Performs configured updates of the source map content
* @param {karmaSourcemapLoader.SourceMap} sourceMap
*/
function updateSourceMap(sourceMap) {
if (remapPrefixes || remapSource) {
sourceMap.sources = remapSources(sourceMap.sources);
}
if (useSourceRoot) {
setSourceRoot(sourceMap);
}
}
/**
* @param {string} data
* @returns {void}
*/
function sourceMapData(data) {
const sourceMap = parseMap(data);
if (sourceMap) {
// Perform the remapping only if there is a configuration for it
if (needsUpdate) {
updateSourceMap(sourceMap);
}
file.sourceMap = sourceMap;
} else if (sourceMap === false) {
return;
}
done(content);
}
/**
* @param {string} inlineData
*/
function inlineMap(inlineData) {
let charset = 'utf-8';
if (CHARSET_REGEX.test(inlineData)) {
const matches = inlineData.match(CHARSET_REGEX);
if (matches && matches.length === 2) {
charset = matches[1];
inlineData = inlineData.slice(matches[0].length - 1);
}
}
if (/^;base64,/.test(inlineData)) {
// base64-encoded JSON string
log.debug('base64-encoded source map for', file.originalPath);
const buffer = Buffer.from(inlineData.slice(';base64,'.length), 'base64');
//@ts-ignore Assume the parsed charset is supported by Buffer.
sourceMapData(buffer.toString(charset));
} else if (inlineData.startsWith(',')) {
// straight-up URL-encoded JSON string
log.debug('raw inline source map for', file.originalPath);
sourceMapData(decodeURIComponent(inlineData.slice(1)));
} else {
if (strict) {
done(new Error('invalid source map in ' + file.originalPath));
} else {
log.warn('invalid source map in', file.originalPath);
done(content);
}
}
}
/**
* @param {string} mapPath
* @param {boolean} optional
*/
function fileMap(mapPath, optional) {
fs.readFile(mapPath, function (err, data) {
// File does not exist
if (err && err.code === 'ENOENT') {
if (!optional) {
if (strict) {
done(new Error('missing external source map for ' + file.originalPath));
return;
} else {
log.warn('missing external source map for', file.originalPath);
}
}
done(content);
return;
}
// Error while reading the file
if (err) {
if (strict) {
done(
new Error('reading external source map failed for ' + file.originalPath + '\n' + err)
);
} else {
log.warn('reading external source map failed for', file.originalPath);
log.warn(err);
done(content);
}
return;
}
log.debug('external source map exists for', file.originalPath);
sourceMapData(data.toString());
});
}
// Remap source paths in a directly served source map
function convertMap() {
let sourceMap;
// Perform the remapping only if there is a configuration for it
if (needsUpdate) {
log.debug('processing source map', file.originalPath);
sourceMap = parseMap(content);
if (sourceMap) {
updateSourceMap(sourceMap);
content = JSON.stringify(sourceMap);
} else if (sourceMap === false) {
return;
}
}
done(content);
}
if (file.path.endsWith('.map')) {
return convertMap();
}
const lines = content.split(/\n/);
let lastLine = lines.pop();
while (typeof lastLine === 'string' && /^\s*$/.test(lastLine)) {
lastLine = lines.pop();
}
const mapUrl =
lastLine && SOURCEMAP_URL_REGEX.test(lastLine) && lastLine.replace(SOURCEMAP_URL_REGEX, '');
if (!mapUrl) {
if (onlyWithURL) {
done(content);
} else {
fileMap(file.path + '.map', true);
}
} else if (/^data:application\/json/.test(mapUrl)) {
inlineMap(mapUrl.slice('data:application/json'.length));
} else {
fileMap(path.resolve(path.dirname(file.path), mapUrl), false);
}
};
}
createSourceMapLocatorPreprocessor.$inject = ['logger', 'config'];
// PUBLISH DI MODULE
module.exports = {
'preprocessor:sourcemap': ['factory', createSourceMapLocatorPreprocessor],
};