chromium/tools/code_coverage/js_source_maps/merge_js_source_maps/merge_js_source_maps.js

// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
 * @fileoverview Merges 2 inline sourcemaps for a input file and writes a new
 * file to an output directory.
 */

import fs from 'fs';
import path from 'path';

import {ArgumentParser} from '../../../../third_party/js_code_coverage/node_modules/argparse/argparse.js';
import {SourceMapConsumer, SourceMapGenerator} from '../../../../third_party/js_code_coverage/node_modules/source-map/source-map.js';

/**
 * The prefix comment that indicates a data URL containing the sourcemap.
 */
const SOURCEMAPPING_DATA_URL_PREFIX =
    '//# sourceMappingURL=data:application/json;base64,';

/**
 * The token in the supplied argument files that indicates the start of the
 * manifest files block.
 */
const MANIFEST_FILES_TOKEN = '--manifest-files';

/**
 * The token in the supplied argument files that indicates the start of the
 * msources block.
 */
const SOURCES_TOKEN = '--sources';

/**
 * The token in the supplied argument files that indicates the start of the
 * outputs block.
 */
const OUTPUTS_TOKEN = '--outputs';

/**
 * Decode a base64 encoded string representing a sourcemap to its utf-8
 * equivalent.
 * @param {string} contents Base64 encoded string.
 * @returns Decoded utf-8 string of the sourcemap.
 */
function decodeBase64SourceMap(contents) {
  const removedLeadingComment =
      contents.replace(SOURCEMAPPING_DATA_URL_PREFIX, '');
  const buf = Buffer.from(removedLeadingComment, 'base64');
  return buf.toString('utf-8');
}

/**
 * Helper to identify if a supplied line is an inline sourcemap.
 * @param {string} lineContents Contents of an individual line.
 * @returns True if line is an inline sourcemap, null otherwise.
 */
function isSourceMapComment(lineContents) {
  return lineContents && lineContents.startsWith(SOURCEMAPPING_DATA_URL_PREFIX);
}

/**
 * Convert `contents` into a valid dataURL sourceMappingURL comment.
 * @param {string} contents A string representation of the sourcemap
 * @returns A base64 encoded dataURL with the `SOURCEMAPPING_DATA_URL_PREFIX`
 *     prepended.
 */
function encodeBase64SourceMap(contents) {
  const buf = Buffer.from(contents, 'utf-8');
  return SOURCEMAPPING_DATA_URL_PREFIX + buf.toString('base64');
}

/**
 * Merge multiple sourcemaps to a single file.
 * @param {!Array<string>} sourceMaps An array of stringified sourcemaps.
 * @returns Returns a single sourcemap as a string.
 */
async function mergeSourcemaps(sourceMaps) {
  let generator = null;
  for await (const sourcemap of sourceMaps) {
    const parsedMap = JSON.parse(sourcemap);
    const consumer = await new SourceMapConsumer(parsedMap);
    if (generator) {
      generator.applySourceMap(consumer);
    } else {
      generator = await SourceMapGenerator.fromSourceMap(consumer);
    }
    consumer.destroy();
  }
  return generator.toString();
}

/**
 * Processes all input files for multiple inlined sourcemaps and merges them.
 * @param {!Array<string>} inputFiles The list of TS / JS files to extract
 *     sourcemaps from.
 */
async function processFiles(inputFiles, outputFiles) {
  for (let i = 0; i < inputFiles.length; i++) {
    const inputFile = inputFiles[i];
    const outputFile = outputFiles[i];
    const fileContents = fs.readFileSync(inputFile, 'utf-8');
    const inputLines = fileContents.split('\n');

    // Skip any trailing blank lines to find the last non-null line.
    let lastNonNullLine = inputLines.length - 1;
    while (inputLines[lastNonNullLine].trim().length === 0 &&
           lastNonNullLine > 0) {
      lastNonNullLine--;
    }

    // If the last non-null line identified is not a sourcemap, ignore this file
    // as it may have erroneously been marked for sourcemap merge.
    if (!isSourceMapComment(inputLines[lastNonNullLine])) {
      console.warn('Supplied file has no inline sourcemap', inputFile);
      fs.copyFileSync(inputFile, outputFile);
      continue;
    }

    // Extract out all the inline sourcemaps and decode them to their string
    // equivalent.
    const sourceMaps = [decodeBase64SourceMap(inputLines[lastNonNullLine])];
    let sourceMapLineIdx = lastNonNullLine - 1;
    while (isSourceMapComment(inputLines[sourceMapLineIdx]) &&
           sourceMapLineIdx > 0) {
      const sourceMap = decodeBase64SourceMap(inputLines[sourceMapLineIdx]);
      sourceMaps.push(sourceMap);
      sourceMapLineIdx--;
    }

    let mergedSourceMap = null;
    try {
      mergedSourceMap = await mergeSourcemaps(sourceMaps);
    } catch (e) {
      console.error(`Failed to merge inlined sourcemaps for ${inputFile}:`, e);
      fs.copyFileSync(inputFile, outputFile);
      continue;
    }

    // Drop off the lines that were previously identified as inline sourcemap
    // comments and replace them with the merged sourcemap.
    let finalFileContent =
        inputLines.slice(0, sourceMapLineIdx + 1).join('\n') + '\n';
    if (mergedSourceMap) {
      finalFileContent += encodeBase64SourceMap(mergedSourceMap);
    }
    fs.writeFileSync(outputFile, finalFileContent);
  }
}

/**
 * Take in an arguments string output by GN and convert it into the arguments
 * to be read.
 */
function parseArguments(argumentsString) {
  const args = argumentsString.split(' ');
  const isToken =
      line => [MANIFEST_FILES_TOKEN, OUTPUTS_TOKEN, SOURCES_TOKEN].some(
          token => token === line);
  const tokenToFiles = {
    [MANIFEST_FILES_TOKEN]: [],
    [OUTPUTS_TOKEN]: [],
    [SOURCES_TOKEN]: [],
  };
  let currentToken = null;
  for (const line of args) {
    const trimmedLine = line.trim();
    if (!trimmedLine.length) {
      continue;
    }
    if (isToken(trimmedLine)) {
      currentToken = trimmedLine;
      continue;
    }
    tokenToFiles[currentToken].push(trimmedLine);
  }
  return tokenToFiles;
}

async function main() {
  const parser =
      new ArgumentParser({description: 'Merge multiple inlined sourcemaps'});

  parser.add_argument(
      '--out-dir', {help: 'Dir where all output files reside', required: true});
  parser.add_argument(
      '--response-file-name',
      {help: 'Arguments supplied via command line', nargs: '*'});

  const argv = parser.parse_args();
  const responseFileContents =
      fs.readFileSync(argv.response_file_name[0]).toString();
  const {
    [MANIFEST_FILES_TOKEN]: manifestFiles,
    [OUTPUTS_TOKEN]: outputs,
    [SOURCES_TOKEN]: sources
  } = parseArguments(responseFileContents);

  await processFiles(sources, outputs);
  if (manifestFiles) {
    for (const manifestFile of manifestFiles) {
      try {
        const manifestFileContents =
            fs.readFileSync(manifestFile).toString('utf-8');
        const manifest = JSON.parse(manifestFileContents);
        manifest.base_dir = argv.out_dir;
        const parsedPath = path.parse(manifestFile);
        fs.writeFileSync(
            path.join(
                parsedPath.dir,
                (parsedPath.name + '__processed' + parsedPath.ext)),
            JSON.stringify(manifest));
      } catch (e) {
        console.log(e);
      }
    }
  }
}

(async () => main())();