From 9cdd664bc6b4aaddf643f79dc3cb6fcd1bb12524 Mon Sep 17 00:00:00 2001 From: Max Virgil Date: Thu, 24 Oct 2024 16:42:07 -0700 Subject: [PATCH 1/8] Implement "sourcemaps inject" command "sourcemaps inject" is given a directory, then: - determines which js and js.map files belong with each other - generates a "sourceMapId" for each of these pairs - injects a code snippet to the js file to indicate its "sourceMapId" --dry-run option is also added, to preview which files will be touched by the command Will cleanup logging code in a future PR --- src/ValidationError.ts | 29 +++++ src/commands/sourcemaps.ts | 33 +++++- src/filesystem/index.ts | 63 ++++++++++ src/sourcemaps/index.ts | 96 +++++++++++++++ src/sourcemaps/utils.ts | 217 ++++++++++++++++++++++++++++++++++ test/sourcemaps/utils.test.ts | 190 +++++++++++++++++++++++++++++ 6 files changed, 623 insertions(+), 5 deletions(-) create mode 100644 src/ValidationError.ts create mode 100644 src/filesystem/index.ts create mode 100644 src/sourcemaps/index.ts create mode 100644 src/sourcemaps/utils.ts create mode 100644 test/sourcemaps/utils.test.ts diff --git a/src/ValidationError.ts b/src/ValidationError.ts new file mode 100644 index 0000000..5c55020 --- /dev/null +++ b/src/ValidationError.ts @@ -0,0 +1,29 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +/** + * Throw this error from a command's action when the action has custom validation logic + * that has detected an invalid option. + * + * Catch the error in the command and use Command.error() to display the message and + * exit the process. + */ +export class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ValidationError'; + } +} diff --git a/src/commands/sourcemaps.ts b/src/commands/sourcemaps.ts index ab3b865..d3e27ef 100644 --- a/src/commands/sourcemaps.ts +++ b/src/commands/sourcemaps.ts @@ -15,16 +15,39 @@ */ import { Command } from 'commander'; +import { runSourcemapInject } from '../sourcemaps'; +import { ValidationError } from '../ValidationError'; export const sourcemapsCommand = new Command('sourcemaps'); sourcemapsCommand .command('inject') - .requiredOption('--directory ', 'Path to the directory for injection') - .description('Inject source maps into the specified directory') - .action((options) => { - console.log(`Injecting source maps into directory: ${options.directory}`); - }); + .usage('--directory path/to/dist') + .requiredOption( + '--directory ', + 'Folder containing JavaScript files and their source maps (required)' + ) + .option( + '--dry-run', + 'Use --dry-run to preview the files that will be injected for the given options. Does not modify any files on the file system. (optional)', + false + ) + .description( + `Traverses the --directory to locate JavaScript files (.js, .cjs, .mjs) and their source map files (.js.map, .cjs.map, .mjs.map). This command will inject code into the JavaScript files with information about their corresponding map file. This injected code is used to perform automatic source mapping for any JavaScript errors that occur in your app.\n\n` + + `After running "sourcemaps inject", make sure to run "sourcemaps upload".`) + .action( + async (options) => { + try { + await runSourcemapInject(options); + } catch (e) { + if (e instanceof ValidationError) { + sourcemapsCommand.error(`error: ${e.message}`); + } else { + throw e; + } + } + } + ); sourcemapsCommand .command('upload') diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts new file mode 100644 index 0000000..20b3df7 --- /dev/null +++ b/src/filesystem/index.ts @@ -0,0 +1,63 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import { createReadStream, createWriteStream, ReadStream } from 'node:fs'; +import { readdir } from 'node:fs/promises'; +import path from 'node:path'; +import readline from 'node:readline'; +import os from 'node:os'; + +/** + * Returns a list of paths to all files within the given directory. + * + * If dir is "path/to/dist", then the returned file paths will look like: + * - path/to/dist/main.js + * - path/to/dist/main.js.map + * - path/to/dist/nested/folder/page1.js + */ +export async function readdirRecursive(dir: string) { + const dirents = await readdir( + dir, + { + encoding: 'utf-8', + recursive: true, + withFileTypes: true + } + ); + const filePaths = dirents + .filter(dirent => dirent.isFile()) + .map(dirent => path.join(dirent.parentPath, dirent.name)); + return filePaths; +} + +export function readlines(stream: ReadStream): AsyncIterable { + return readline.createInterface({ + input: stream, + crlfDelay: Infinity, // recognize all instances of CR LF ('\r\n') as a single line break + }); +} + +export function makeReadStream(filePath: string) { + return createReadStream(filePath, { encoding: 'utf-8' }); +} + +export function overwriteFileContents(filePath: string, lines: string[]) { + const outStream = createWriteStream(filePath, { encoding: 'utf-8' }); + for (const line of lines) { + outStream.write(line + os.EOL, err => { if (err) throw err; }); + } + outStream.end(); +} diff --git a/src/sourcemaps/index.ts b/src/sourcemaps/index.ts new file mode 100644 index 0000000..76ef814 --- /dev/null +++ b/src/sourcemaps/index.ts @@ -0,0 +1,96 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import { readdirRecursive } from '../filesystem'; +import { + computeSourceMapId, + discoverJsMapFilePath, + info, + injectFile, + isJsFilePath, + isJsMapFilePath, + warn +} from './utils'; +import { ValidationError } from '../ValidationError'; + +export type SourceMapInjectOptions = { + directory: string; + dryRun: boolean; +}; + +/** + * Inject sourceMapIds into all applicable JavaScript files inside the given directory. + * + * For each JS file in the directory: + * 1. Determine where its source map file lives + * 2. Compute the sourceMapId (by hashing its source map file) + * 3. Inject the sourceMapId into the JS file + */ +export async function runSourcemapInject(options: SourceMapInjectOptions) { + const { directory } = options; + + /* + * Read the provided directory to collect a list of all possible files the script will be working with. + */ + let filePaths; + try { + filePaths = await readdirRecursive(directory); + } catch (err) { + throwDirectoryValidationError(err, directory); + } + + const jsFilePaths = filePaths.filter(isJsFilePath); + const jsMapFilePaths = filePaths.filter(isJsMapFilePath); + + info(`Found ${jsFilePaths.length} JavaScript file(s) in ${directory}`); + + /* + * Inject a code snippet into each JS file, whenever applicable. + */ + const injectedJsFilePaths = []; + for (const jsFilePath of jsFilePaths) { + const matchingSourceMapFilePath = await discoverJsMapFilePath(jsFilePath, jsMapFilePaths); + if (!matchingSourceMapFilePath) { + info(`No source map was detected for ${jsFilePath}. Skipping injection.`); + continue; + } + + const sourceMapId = await computeSourceMapId(matchingSourceMapFilePath); + await injectFile(jsFilePath, sourceMapId, options.dryRun); + + injectedJsFilePaths.push(jsFilePath); + } + + /* + * Print summary of results + */ + info(`Finished source map injection for ${injectedJsFilePaths.length} JavaScript file(s) in ${directory}`); + if (jsFilePaths.length === 0) { + warn(`No JavaScript files were found. Verify that ${directory} is the correct directory for your JavaScript files.`); + } else if (injectedJsFilePaths.length === 0) { + warn(`No JavaScript files were injected. Verify that your build is configured to generate source maps for your JavaScript files.`); + } +} + +function throwDirectoryValidationError(err: unknown, directory: string): never { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + throw new ValidationError(`${directory} does not exist`); + } else if ((err as NodeJS.ErrnoException).code === 'ENOTDIR') { + throw new ValidationError(`${directory} is not a directory`); + } else { + throw err; + } +} diff --git a/src/sourcemaps/utils.ts b/src/sourcemaps/utils.ts new file mode 100644 index 0000000..2d0f296 --- /dev/null +++ b/src/sourcemaps/utils.ts @@ -0,0 +1,217 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import { makeReadStream, overwriteFileContents, readlines } from '../filesystem'; +import { createHash } from 'node:crypto'; +import path from 'node:path'; + +const SOURCE_MAPPING_URL_COMMENT_PREFIX = '//# sourceMappingURL='; +const SNIPPET_PREFIX = `;/* olly sourcemaps inject */`; +const SNIPPET_TEMPLATE = `${SNIPPET_PREFIX}if (typeof window === 'object') { window.sourceMapIds = window.sourceMapIds || {}; let s = ''; try { throw new Error(); } catch (e) { s = (e.stack.match(/https?:\\/\\/[^\\s]+?(?::\\d+)?(?=:[\\d]+:[\\d]+)/) || [])[0]; } if (s) {window.sourceMapIds[s] = '__SOURCE_MAP_ID_PLACEHOLDER__';}};`; + + +/** + * Determine the corresponding ".map" file for the given jsFilePath. + * + * Strategy: + * + * 1) Append ".map" to the jsFilePath. If we already know this file exists, return it as the match. + * This is a common naming convention for source map files. + * + * 2) Fallback to the "//# sourceMappingURL=..." comment in the JS file. + * If this comment is present, and we detect it is a relative file path, return this value as the match. + */ +export async function discoverJsMapFilePath(jsFilePath: string, allJsMapFilePaths: string[]): Promise { + /* + * Check if we already know about the map file by adding ".map" extension. This is a common convention. + */ + if (allJsMapFilePaths.includes(`${jsFilePath}.map`)) { + const result = `${jsFilePath}.map`; + + debug(`found source map pair (using standard naming convention):`); + debug(` - ${jsFilePath}`); + debug(` - ${result}`); + + return result; + } + + /* + * Fallback to reading the JS file and parsing its "//# sourceMappingURL=..." comment + */ + const fileStream = makeReadStream(jsFilePath); + + let result: string | null = null; + for await (const line of readlines(fileStream)) { + if (line.startsWith(SOURCE_MAPPING_URL_COMMENT_PREFIX)) { + const url = line.slice(SOURCE_MAPPING_URL_COMMENT_PREFIX.length).trim(); + + if (path.isAbsolute(url) + || url.startsWith('http://') + || url.startsWith('https://') + || url.startsWith('data:')) { + debug(`skipping source map pair (unsupported sourceMappingURL comment):`); + debug(` - ${jsFilePath}`); + debug(` - ${url}`); + + result = null; + } else { + const matchingJsMapFilePath = path.join(path.dirname(jsFilePath), url); + + if (!allJsMapFilePaths.includes(matchingJsMapFilePath)) { + debug(`skipping source map pair (file not in provided directory):`); + debug(` - ${jsFilePath}`); + debug(` - ${url}`); + + warn(`skipping ${jsFilePath}, which is requesting a source map file outside of the provided --directory`); + + result = null; + } else { + debug(`found source map pair (using sourceMappingURL comment):`); + debug(` - ${jsFilePath}`); + debug(` - ${matchingJsMapFilePath}`); + + result = matchingJsMapFilePath; + } + } + + break; + } + } + + if (result === null) { + debug(`no source map found for ${jsFilePath}`); + } + + return result; +} + +/** + * sourceMapId is computed by hashing the contents of the ".map" file, and then + * formatting the hash to like a GUID. + */ +export async function computeSourceMapId(sourceMapFilePath: string): Promise { + const hash = createHash('sha256').setEncoding('hex'); + const fileStream = makeReadStream(sourceMapFilePath); + for await (const chunk of fileStream) { + hash.update(chunk); + } + const sha = hash.digest('hex'); + return shaToSourceMapId(sha); +} + +/** + * Injects the code snippet into the JS file to permanently associate the JS file with its sourceMapId. + * + * The code snippet will be injected at the end of the file, or just before the + * "//# sourceMappingURL=" comment if it exists. + * + * This operation is idempotent. + * + * If dryRun is true, this function will not write to the file system. + */ +export async function injectFile(jsFilePath: string, sourceMapId: string, dryRun: boolean): Promise { + if (dryRun) { + info(`sourceMapId ${sourceMapId} would be injected to ${jsFilePath}`); + return; + } + + const lines = []; + let sourceMappingUrlIndex = -1; + let existingSnippetIndex = -1; + let existingSnippet = ''; + + /* + * Read the file into memory, and record any significant line indexes + */ + let readlinesIndex = 0; + const fileStream = makeReadStream(jsFilePath); + for await (const line of readlines(fileStream)) { + if (line.startsWith(SOURCE_MAPPING_URL_COMMENT_PREFIX)) { + sourceMappingUrlIndex = readlinesIndex; + } + if (line.startsWith(SNIPPET_PREFIX)) { + existingSnippetIndex = readlinesIndex; + existingSnippet = line; + } + + lines.push(line); + readlinesIndex++; + } + + const snippet = getCodeSnippet(sourceMapId); + + /* + * No work required if the snippet already exists in the file (i.e. from a previous manual run) + */ + if (existingSnippet === snippet) { + debug(`sourceMapId ${sourceMapId} already injected into ${jsFilePath}`); + return; + } + + /* + * Determine where to insert the code snippet + */ + if (existingSnippetIndex >= 0) { + lines.splice(existingSnippetIndex, 1, snippet); // overwrite the existing snippet + } else if (sourceMappingUrlIndex >= 0) { + lines.splice(sourceMappingUrlIndex, 0, snippet); + } else { + lines.push(snippet); + } + + /* + * Write to the file system + */ + debug(`injecting sourceMapId ${sourceMapId} into ${jsFilePath}`); + overwriteFileContents(jsFilePath, lines); +} + +function getCodeSnippet(sourceMapId: string): string { + return SNIPPET_TEMPLATE.replace('__SOURCE_MAP_ID_PLACEHOLDER__', sourceMapId); +} + +function shaToSourceMapId(sha: string) { + return [ + sha.slice(0, 8), + sha.slice(8, 12), + sha.slice(12, 16), + sha.slice(16, 20), + sha.slice(20, 32), + ].join('-'); +} + +export function isJsFilePath(filePath: string) { + return filePath.match(/\.(js|cjs|mjs)$/); +} + +export function isJsMapFilePath(filePath: string) { + return filePath.match(/\.(js|cjs|mjs)\.map$/); +} + +// TODO extract to a configurable, shared logger with improved styling +export function debug(str: string) { + console.log('[debug] ' + str); +} + +// TODO extract to a configurable, shared logger with improved styling +export function info(str: string) { + console.log(str); +} + +// TODO extract to a configurable, shared logger with improved styling +export function warn(str: string) { + console.log('[warn] ' + str); +} diff --git a/test/sourcemaps/utils.test.ts b/test/sourcemaps/utils.test.ts new file mode 100644 index 0000000..8b45a49 --- /dev/null +++ b/test/sourcemaps/utils.test.ts @@ -0,0 +1,190 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import { equal } from 'node:assert/strict'; +import { Readable } from 'node:stream'; +import { describe, it, mock } from 'node:test'; +import { computeSourceMapId, discoverJsMapFilePath, injectFile } from '../../src/sourcemaps/utils'; +import * as filesystem from '../../src/filesystem'; +import { deepEqual } from 'assert'; + +describe('discoverJsMapFilePath', () => { + function mockJsFileContents(contents: string) { + mock.method(filesystem, 'makeReadStream', () => Readable.from(contents)); + } + + it('should return a match if we already know the file name with ".map" is present in the directory', async () => { + const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/to/file.js.map' ]); + equal(path, 'path/to/file.js.map'); + }); + + it('should return a match if "//# sourceMappingURL=" comment has a relative path', async () => { + mockJsFileContents('//# sourceMappingURL=mappings/file.js.map\n'); + + const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/to/mappings/file.js.map' ]); + + equal(path, 'path/to/mappings/file.js.map'); + }); + + it('should return a match if "//# sourceMappingURL=" comment has a relative path with ..', async () => { + mockJsFileContents('//# sourceMappingURL=../mappings/file.js.map\n'); + + const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/mappings/file.js.map' ]); + + equal(path, 'path/mappings/file.js.map'); + }); + + it('should not return a match if "//# sourceMappingURL=" comment points to a file outside of our directory', async () => { + mockJsFileContents('//# sourceMappingURL=../../../some/other/folder/file.js.map'); + + const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/to/mappings/file.js.map' ]); + + equal(path, null); + }); + + it('should not return a match if "//# sourceMappingURL=" comment has a data URL', async () => { + mockJsFileContents('//# sourceMappingURL=data:application/json;base64,abcd\n'); + + const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/to/data:application/json;base64,abcd' ]); + + equal(path, null); + }); + + it('should not return a match if "//# sourceMappingURL=" comment has an HTTP URL', async () => { + mockJsFileContents('//# sourceMappingURL=http://www.splunk.com/dist/file.js.map\n'); + + const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/to/http://www.splunk.com/dist/file.js.map' ]); + + equal(path, null); + }); + + it('should not return a match if "//# sourceMappingURL=" comment has an HTTPS URL', async () => { + mockJsFileContents('//# sourceMappingURL=https://www.splunk.com/dist/file.js.map\n'); + + const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/to/https://www.splunk.com/dist/file.js.map' ]); + + equal(path, null); + }); + + it('should not return a match if file is not already known and sourceMappingURL comment is absent', async () => { + mockJsFileContents('console.log("hello world!");'); + + const path = await discoverJsMapFilePath('path/to/file.js', [ 'file.map.js' ]); + + equal(path, null); + }); +}); + +describe('computeSourceMapId', () => { + it('returns truncated sha256 formatted like a GUID', async () => { + mock.method(filesystem, 'makeReadStream', () => Readable.from([ + 'line 1\n', + 'line 2\n' + ])); + + const sourceMapId = await computeSourceMapId('file.js.map'); + equal(sourceMapId, '90605548-63a6-2b9d-b5f7-26216876654e'); + }); +}); + +describe('injectFile', () => { + function mockJsFileContentBeforeInjection(lines: string[]) { + mock.method(filesystem, 'makeReadStream', () => Readable.from(lines.join('\n'))); + } + + function mockJsFileOverwrite() { + return mock.method(filesystem, 'overwriteFileContents', () => { /* noop */ }); + } + + it('will insert the code snippet at the end of file when there is no "//# sourceMappingURL=" comment', async () => { + mockJsFileContentBeforeInjection([ + 'line 1', + 'line 2' + ]); + const mockOverwriteFn = mockJsFileOverwrite(); + + await injectFile('file.js', '647366e7-d3db-6cf4-8693-2c321c377d5a', false); + + deepEqual(mockOverwriteFn.mock.calls[0].arguments[1], [ + 'line 1', + 'line 2', + `;/* olly sourcemaps inject */if (typeof window === 'object') { window.sourceMapIds = window.sourceMapIds || {}; let s = ''; try { throw new Error(); } catch (e) { s = (e.stack.match(/https?:\\/\\/[^\\s]+?(?::\\d+)?(?=:[\\d]+:[\\d]+)/) || [])[0]; } if (s) {window.sourceMapIds[s] = '647366e7-d3db-6cf4-8693-2c321c377d5a';}};` + ]); + }); + + it('will insert the code snippet just before the "//# sourceMappingURL=" comment', async () => { + mockJsFileContentBeforeInjection([ + 'line 1', + 'line 2', + '//# sourceMappingURL=file.js.map' + ]); + const mockOverwriteFn = mockJsFileOverwrite(); + + await injectFile('file.js', '647366e7-d3db-6cf4-8693-2c321c377d5a', false); + + deepEqual(mockOverwriteFn.mock.calls[0].arguments[1], [ + 'line 1', + 'line 2', + `;/* olly sourcemaps inject */if (typeof window === 'object') { window.sourceMapIds = window.sourceMapIds || {}; let s = ''; try { throw new Error(); } catch (e) { s = (e.stack.match(/https?:\\/\\/[^\\s]+?(?::\\d+)?(?=:[\\d]+:[\\d]+)/) || [])[0]; } if (s) {window.sourceMapIds[s] = '647366e7-d3db-6cf4-8693-2c321c377d5a';}};`, + '//# sourceMappingURL=file.js.map' + ]); + }); + + it('will overwrite the code snippet if an existing code snippet with a different sourceMapId is detected', async () => { + mockJsFileContentBeforeInjection([ + 'line 1', + 'line 2', + `;/* olly sourcemaps inject */if (typeof window === 'object') { window.sourceMapIds = window.sourceMapIds || {}; let s = ''; try { throw new Error(); } catch (e) { s = (e.stack.match(/https?:\\/\\/[^\\s]+?(?::\\d+)?(?=:[\\d]+:[\\d]+)/) || [])[0]; } if (s) {window.sourceMapIds[s] = '88888888-8888-8888-8888-888888888888';}};`, + '//# sourceMappingURL=file.js.map', + ]); + const mockOverwriteFn = mockJsFileOverwrite(); + + await injectFile('file.js', '647366e7-d3db-6cf4-8693-2c321c377d5a', false); + + deepEqual(mockOverwriteFn.mock.calls[0].arguments[1], [ + 'line 1', + 'line 2', + `;/* olly sourcemaps inject */if (typeof window === 'object') { window.sourceMapIds = window.sourceMapIds || {}; let s = ''; try { throw new Error(); } catch (e) { s = (e.stack.match(/https?:\\/\\/[^\\s]+?(?::\\d+)?(?=:[\\d]+:[\\d]+)/) || [])[0]; } if (s) {window.sourceMapIds[s] = '647366e7-d3db-6cf4-8693-2c321c377d5a';}};`, + '//# sourceMappingURL=file.js.map' + ]); + }); + + it('will not write to the file system if an existing code snippet with the same sourceMapId is detected', async () => { + mockJsFileContentBeforeInjection([ + 'line 1', + 'line 2', + `;/* olly sourcemaps inject */if (typeof window === 'object') { window.sourceMapIds = window.sourceMapIds || {}; let s = ''; try { throw new Error(); } catch (e) { s = (e.stack.match(/https?:\\/\\/[^\\s]+?(?::\\d+)?(?=:[\\d]+:[\\d]+)/) || [])[0]; } if (s) {window.sourceMapIds[s] = '647366e7-d3db-6cf4-8693-2c321c377d5a';}};`, + '//# sourceMappingURL=file.js.map' + ]); + const mockOverwriteFn = mockJsFileOverwrite(); + + await injectFile('file.js', '647366e7-d3db-6cf4-8693-2c321c377d5a', false); + + equal(mockOverwriteFn.mock.callCount(), 0); + }); + + it('will not write to the file system if --dry-run was provided', async () => { + mockJsFileContentBeforeInjection([ + 'line 1\n', + 'line 2\n' + ]); + const mockOverwriteFn = mockJsFileOverwrite(); + + await injectFile('file.js', '647366e7-d3db-6cf4-8693-2c321c377d5a', true); + + equal(mockOverwriteFn.mock.callCount(), 0); + }); +}); From eaebfecfd5bae5efb85cd86d75ba8329b363de7d Mon Sep 17 00:00:00 2001 From: Max Virgil Date: Mon, 28 Oct 2024 21:15:42 -0700 Subject: [PATCH 2/8] Update overwriteFileContents to use a temp file --- src/filesystem/index.ts | 61 ++++++++++++++++++++++++++++++++++++++--- src/sourcemaps/index.ts | 8 +++++- 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 20b3df7..570ba7c 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -15,10 +15,13 @@ */ import { createReadStream, createWriteStream, ReadStream } from 'node:fs'; -import { readdir } from 'node:fs/promises'; +import { readdir, rename, rm } from 'node:fs/promises'; import path from 'node:path'; import readline from 'node:readline'; import os from 'node:os'; +import { finished } from 'node:stream/promises'; + +const TEMP_FILE_EXTENSION: string = '.olly.tmp'; /** * Returns a list of paths to all files within the given directory. @@ -54,10 +57,60 @@ export function makeReadStream(filePath: string) { return createReadStream(filePath, { encoding: 'utf-8' }); } -export function overwriteFileContents(filePath: string, lines: string[]) { - const outStream = createWriteStream(filePath, { encoding: 'utf-8' }); +/** + * Safely overwrite the contents of filePath by writing to a temporary + * file and replacing filePath. This avoids destructive edits to filePath + * if the process exits before this function has completed. + * + * If this method is used by a command, the command must always invoke + * cleanupTemporaryFiles before exiting successfully. + */ +export async function overwriteFileContents(filePath: string, lines: string[]) { + const tempFilePath = getTempFilePath(filePath); + await writeLinesToFile(tempFilePath, lines); + try { + await rename(tempFilePath, filePath); + } catch (e) { + // try to ensure that this method doesn't return/throw until the temp file is removed + await rm(tempFilePath); + + throw e; + } +} + +/** + * Recursively remove any temporary files that may still be present in the directory. + */ +export async function cleanupTemporaryFiles(dir: string) { + const paths = await readdirRecursive(dir); + for (const path of paths) { + if (path.endsWith(TEMP_FILE_EXTENSION)) { + await rm(path); + } + } +} + +/** + * Return a tempFilePath based on the input filePath: + * + * - path/to/file.js -> path/to/.file.js.olly.tmp + */ +function getTempFilePath(filePath: string) { + const fileName = path.basename(filePath); + const tempFileName = `.${fileName}${TEMP_FILE_EXTENSION}`; + return path.join( + path.dirname(filePath), + tempFileName + ); + +} + +async function writeLinesToFile(path: string, lines: string[]) { + const outStream = createWriteStream(path, { encoding: 'utf-8' }); for (const line of lines) { - outStream.write(line + os.EOL, err => { if (err) throw err; }); + outStream.write(line, err => { if (err) throw err; }); + outStream.write(os.EOL, err => { if (err) throw err; }); } outStream.end(); + return finished(outStream); } diff --git a/src/sourcemaps/index.ts b/src/sourcemaps/index.ts index 76ef814..9f033a5 100644 --- a/src/sourcemaps/index.ts +++ b/src/sourcemaps/index.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { readdirRecursive } from '../filesystem'; +import { cleanupTemporaryFiles, readdirRecursive } from '../filesystem'; import { computeSourceMapId, discoverJsMapFilePath, @@ -74,6 +74,11 @@ export async function runSourcemapInject(options: SourceMapInjectOptions) { injectedJsFilePaths.push(jsFilePath); } + // If we reach here, the only reason for temporary files to be leftover is if a previous invocation of + // sourcemaps inject had terminated unexpectedly in the middle of writing to a temp file. + // But we should make sure to clean up those older files, too, before exiting this successful run. + await cleanupTemporaryFiles(directory); + /* * Print summary of results */ @@ -83,6 +88,7 @@ export async function runSourcemapInject(options: SourceMapInjectOptions) { } else if (injectedJsFilePaths.length === 0) { warn(`No JavaScript files were injected. Verify that your build is configured to generate source maps for your JavaScript files.`); } + } function throwDirectoryValidationError(err: unknown, directory: string): never { From f9deb7a08a2da1e51ee040649801a2093be97b26 Mon Sep 17 00:00:00 2001 From: Max Virgil Date: Mon, 28 Oct 2024 21:57:36 -0700 Subject: [PATCH 3/8] Add extra test case that this will not break source mappings --- test/sourcemaps/utils.test.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/sourcemaps/utils.test.ts b/test/sourcemaps/utils.test.ts index 8b45a49..2b9ac5d 100644 --- a/test/sourcemaps/utils.test.ts +++ b/test/sourcemaps/utils.test.ts @@ -105,6 +105,10 @@ describe('injectFile', () => { mock.method(filesystem, 'makeReadStream', () => Readable.from(lines.join('\n'))); } + function mockJsFileContentBeforeInjectionRaw(content: string) { + mock.method(filesystem, 'makeReadStream', () => Readable.from(content)); + } + function mockJsFileOverwrite() { return mock.method(filesystem, 'overwriteFileContents', () => { /* noop */ }); } @@ -162,6 +166,29 @@ describe('injectFile', () => { ]); }); + it('will not strip out extra lines or whitespace characters', async () => { + mockJsFileContentBeforeInjectionRaw( + `\n\n\nline 4\n\n line6\n line7 \n\nline9 \n//# sourceMappingURL=file.js.map` + ); + const mockOverwriteFn = mockJsFileOverwrite(); + + await injectFile('file.js', '647366e7-d3db-6cf4-8693-2c321c377d5a', false); + + deepEqual(mockOverwriteFn.mock.calls[0].arguments[1], [ + '', + '', + '', + 'line 4', + '', + ' line6', + ' line7 ', + '', + 'line9 ', + `;/* olly sourcemaps inject */if (typeof window === 'object') { window.sourceMapIds = window.sourceMapIds || {}; let s = ''; try { throw new Error(); } catch (e) { s = (e.stack.match(/https?:\\/\\/[^\\s]+?(?::\\d+)?(?=:[\\d]+:[\\d]+)/) || [])[0]; } if (s) {window.sourceMapIds[s] = '647366e7-d3db-6cf4-8693-2c321c377d5a';}};`, + '//# sourceMappingURL=file.js.map' + ]); + }); + it('will not write to the file system if an existing code snippet with the same sourceMapId is detected', async () => { mockJsFileContentBeforeInjection([ 'line 1', From 141b71ddcab8c697d83c98cae4004c6700f27902 Mon Sep 17 00:00:00 2001 From: Max Virgil Date: Mon, 28 Oct 2024 22:13:39 -0700 Subject: [PATCH 4/8] Fix dangling promise Will look into enabling eslint rule in future, because node:test runner does not play well with it --- src/sourcemaps/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sourcemaps/utils.ts b/src/sourcemaps/utils.ts index 2d0f296..c5bdc0a 100644 --- a/src/sourcemaps/utils.ts +++ b/src/sourcemaps/utils.ts @@ -176,7 +176,7 @@ export async function injectFile(jsFilePath: string, sourceMapId: string, dryRun * Write to the file system */ debug(`injecting sourceMapId ${sourceMapId} into ${jsFilePath}`); - overwriteFileContents(jsFilePath, lines); + await overwriteFileContents(jsFilePath, lines); } function getCodeSnippet(sourceMapId: string): string { From f8574c1ddcf364e45a89c56633a3e82bb87a6d12 Mon Sep 17 00:00:00 2001 From: Max Virgil Date: Tue, 29 Oct 2024 00:27:44 -0700 Subject: [PATCH 5/8] Improve error handling --- src/ValidationError.ts | 29 ------- src/commands/sourcemaps.ts | 12 ++- src/filesystem/index.ts | 13 +-- src/sourcemaps/index.ts | 28 ++++--- src/sourcemaps/utils.ts | 150 +++++++++++++++++++++++----------- src/userFriendlyErrors.ts | 65 +++++++++++++++ test/sourcemaps/utils.test.ts | 141 ++++++++++++++++++++++++++------ 7 files changed, 308 insertions(+), 130 deletions(-) delete mode 100644 src/ValidationError.ts create mode 100644 src/userFriendlyErrors.ts diff --git a/src/ValidationError.ts b/src/ValidationError.ts deleted file mode 100644 index 5c55020..0000000 --- a/src/ValidationError.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Splunk Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. -*/ - -/** - * Throw this error from a command's action when the action has custom validation logic - * that has detected an invalid option. - * - * Catch the error in the command and use Command.error() to display the message and - * exit the process. - */ -export class ValidationError extends Error { - constructor(message: string) { - super(message); - this.name = 'ValidationError'; - } -} diff --git a/src/commands/sourcemaps.ts b/src/commands/sourcemaps.ts index d3e27ef..dd47300 100644 --- a/src/commands/sourcemaps.ts +++ b/src/commands/sourcemaps.ts @@ -16,7 +16,8 @@ import { Command } from 'commander'; import { runSourcemapInject } from '../sourcemaps'; -import { ValidationError } from '../ValidationError'; +import { debug, error } from '../sourcemaps/utils'; +import { UserFriendlyError } from '../sourcemaps/userFriendlyError'; export const sourcemapsCommand = new Command('sourcemaps'); @@ -40,11 +41,14 @@ sourcemapsCommand try { await runSourcemapInject(options); } catch (e) { - if (e instanceof ValidationError) { - sourcemapsCommand.error(`error: ${e.message}`); + if (e instanceof UserFriendlyError) { + debug(e.originalError); + error(e.message); } else { - throw e; + error('Exiting due to an unexpected error:'); + error(e); } + sourcemapsCommand.error(''); } } ); diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 570ba7c..2edbf1f 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -68,14 +68,7 @@ export function makeReadStream(filePath: string) { export async function overwriteFileContents(filePath: string, lines: string[]) { const tempFilePath = getTempFilePath(filePath); await writeLinesToFile(tempFilePath, lines); - try { - await rename(tempFilePath, filePath); - } catch (e) { - // try to ensure that this method doesn't return/throw until the temp file is removed - await rm(tempFilePath); - - throw e; - } + await rename(tempFilePath, filePath); } /** @@ -108,8 +101,8 @@ function getTempFilePath(filePath: string) { async function writeLinesToFile(path: string, lines: string[]) { const outStream = createWriteStream(path, { encoding: 'utf-8' }); for (const line of lines) { - outStream.write(line, err => { if (err) throw err; }); - outStream.write(os.EOL, err => { if (err) throw err; }); + outStream.write(line); + outStream.write(os.EOL); } outStream.end(); return finished(outStream); diff --git a/src/sourcemaps/index.ts b/src/sourcemaps/index.ts index 9f033a5..e08701b 100644 --- a/src/sourcemaps/index.ts +++ b/src/sourcemaps/index.ts @@ -24,7 +24,7 @@ import { isJsMapFilePath, warn } from './utils'; -import { ValidationError } from '../ValidationError'; +import { throwAsUserFriendlyErrnoException } from '../userFriendlyErrors'; export type SourceMapInjectOptions = { directory: string; @@ -49,7 +49,7 @@ export async function runSourcemapInject(options: SourceMapInjectOptions) { try { filePaths = await readdirRecursive(directory); } catch (err) { - throwDirectoryValidationError(err, directory); + throwDirectoryReadError(err, directory); } const jsFilePaths = filePaths.filter(isJsFilePath); @@ -62,14 +62,14 @@ export async function runSourcemapInject(options: SourceMapInjectOptions) { */ const injectedJsFilePaths = []; for (const jsFilePath of jsFilePaths) { - const matchingSourceMapFilePath = await discoverJsMapFilePath(jsFilePath, jsMapFilePaths); + const matchingSourceMapFilePath = await discoverJsMapFilePath(jsFilePath, jsMapFilePaths, options); if (!matchingSourceMapFilePath) { info(`No source map was detected for ${jsFilePath}. Skipping injection.`); continue; } - const sourceMapId = await computeSourceMapId(matchingSourceMapFilePath); - await injectFile(jsFilePath, sourceMapId, options.dryRun); + const sourceMapId = await computeSourceMapId(matchingSourceMapFilePath, options); + await injectFile(jsFilePath, sourceMapId, options); injectedJsFilePaths.push(jsFilePath); } @@ -91,12 +91,14 @@ export async function runSourcemapInject(options: SourceMapInjectOptions) { } -function throwDirectoryValidationError(err: unknown, directory: string): never { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - throw new ValidationError(`${directory} does not exist`); - } else if ((err as NodeJS.ErrnoException).code === 'ENOTDIR') { - throw new ValidationError(`${directory} is not a directory`); - } else { - throw err; - } +function throwDirectoryReadError(err: unknown, directory: string): never { + throwAsUserFriendlyErrnoException( + err, + { + EACCES: `Failed to inject JavaScript files in "${directory} because of missing permissions.\nMake sure that the CLI tool will have "read" and "write" access to the directory and all files inside it, then rerun the inject command.`, + ENOENT: `Unable to start the inject command because the directory "${directory}" does not exist.\nMake sure the correct path is being passed to --directory, then rerun the inject command.`, + ENOTDIR: `Unable to start the inject command because the path "${directory}" is not a directory.\nMake sure a valid directory path is being passed to --directory, then rerun the inject command.`, + } + ); } + diff --git a/src/sourcemaps/utils.ts b/src/sourcemaps/utils.ts index c5bdc0a..5835cc9 100644 --- a/src/sourcemaps/utils.ts +++ b/src/sourcemaps/utils.ts @@ -17,12 +17,13 @@ import { makeReadStream, overwriteFileContents, readlines } from '../filesystem'; import { createHash } from 'node:crypto'; import path from 'node:path'; +import { SourceMapInjectOptions } from './index'; +import { throwAsUserFriendlyErrnoException } from '../userFriendlyErrors'; const SOURCE_MAPPING_URL_COMMENT_PREFIX = '//# sourceMappingURL='; const SNIPPET_PREFIX = `;/* olly sourcemaps inject */`; const SNIPPET_TEMPLATE = `${SNIPPET_PREFIX}if (typeof window === 'object') { window.sourceMapIds = window.sourceMapIds || {}; let s = ''; try { throw new Error(); } catch (e) { s = (e.stack.match(/https?:\\/\\/[^\\s]+?(?::\\d+)?(?=:[\\d]+:[\\d]+)/) || [])[0]; } if (s) {window.sourceMapIds[s] = '__SOURCE_MAP_ID_PLACEHOLDER__';}};`; - /** * Determine the corresponding ".map" file for the given jsFilePath. * @@ -34,7 +35,7 @@ const SNIPPET_TEMPLATE = `${SNIPPET_PREFIX}if (typeof window === 'object') { win * 2) Fallback to the "//# sourceMappingURL=..." comment in the JS file. * If this comment is present, and we detect it is a relative file path, return this value as the match. */ -export async function discoverJsMapFilePath(jsFilePath: string, allJsMapFilePaths: string[]): Promise { +export async function discoverJsMapFilePath(jsFilePath: string, allJsMapFilePaths: string[], options: SourceMapInjectOptions): Promise { /* * Check if we already know about the map file by adding ".map" extension. This is a common convention. */ @@ -51,44 +52,47 @@ export async function discoverJsMapFilePath(jsFilePath: string, allJsMapFilePath /* * Fallback to reading the JS file and parsing its "//# sourceMappingURL=..." comment */ - const fileStream = makeReadStream(jsFilePath); - let result: string | null = null; - for await (const line of readlines(fileStream)) { - if (line.startsWith(SOURCE_MAPPING_URL_COMMENT_PREFIX)) { - const url = line.slice(SOURCE_MAPPING_URL_COMMENT_PREFIX.length).trim(); + try { + const fileStream = makeReadStream(jsFilePath); + for await (const line of readlines(fileStream)) { + if (line.startsWith(SOURCE_MAPPING_URL_COMMENT_PREFIX)) { + const url = line.slice(SOURCE_MAPPING_URL_COMMENT_PREFIX.length).trim(); - if (path.isAbsolute(url) + if (path.isAbsolute(url) || url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) { - debug(`skipping source map pair (unsupported sourceMappingURL comment):`); - debug(` - ${jsFilePath}`); - debug(` - ${url}`); - - result = null; - } else { - const matchingJsMapFilePath = path.join(path.dirname(jsFilePath), url); - - if (!allJsMapFilePaths.includes(matchingJsMapFilePath)) { - debug(`skipping source map pair (file not in provided directory):`); + debug(`skipping source map pair (unsupported sourceMappingURL comment):`); debug(` - ${jsFilePath}`); debug(` - ${url}`); - warn(`skipping ${jsFilePath}, which is requesting a source map file outside of the provided --directory`); - result = null; } else { - debug(`found source map pair (using sourceMappingURL comment):`); - debug(` - ${jsFilePath}`); - debug(` - ${matchingJsMapFilePath}`); + const matchingJsMapFilePath = path.join(path.dirname(jsFilePath), url); + + if (!allJsMapFilePaths.includes(matchingJsMapFilePath)) { + debug(`skipping source map pair (file not in provided directory):`); + debug(` - ${jsFilePath}`); + debug(` - ${url}`); + + warn(`skipping ${jsFilePath}, which is requesting a source map file outside of the provided --directory`); - result = matchingJsMapFilePath; + result = null; + } else { + debug(`found source map pair (using sourceMappingURL comment):`); + debug(` - ${jsFilePath}`); + debug(` - ${matchingJsMapFilePath}`); + + result = matchingJsMapFilePath; + } } - } - break; + break; + } } + } catch (e) { + throwJsFileReadError(e, jsFilePath, options); } if (result === null) { @@ -102,12 +106,18 @@ export async function discoverJsMapFilePath(jsFilePath: string, allJsMapFilePath * sourceMapId is computed by hashing the contents of the ".map" file, and then * formatting the hash to like a GUID. */ -export async function computeSourceMapId(sourceMapFilePath: string): Promise { +export async function computeSourceMapId(sourceMapFilePath: string, options: SourceMapInjectOptions): Promise { const hash = createHash('sha256').setEncoding('hex'); - const fileStream = makeReadStream(sourceMapFilePath); - for await (const chunk of fileStream) { - hash.update(chunk); + + try { + const fileStream = makeReadStream(sourceMapFilePath); + for await (const chunk of fileStream) { + hash.update(chunk); + } + } catch (e) { + throwJsMapFileReadError(e, sourceMapFilePath, options); } + const sha = hash.digest('hex'); return shaToSourceMapId(sha); } @@ -122,8 +132,8 @@ export async function computeSourceMapId(sourceMapFilePath: string): Promise { - if (dryRun) { +export async function injectFile(jsFilePath: string, sourceMapId: string, options: SourceMapInjectOptions): Promise { + if (options.dryRun) { info(`sourceMapId ${sourceMapId} would be injected to ${jsFilePath}`); return; } @@ -137,18 +147,22 @@ export async function injectFile(jsFilePath: string, sourceMapId: string, dryRun * Read the file into memory, and record any significant line indexes */ let readlinesIndex = 0; - const fileStream = makeReadStream(jsFilePath); - for await (const line of readlines(fileStream)) { - if (line.startsWith(SOURCE_MAPPING_URL_COMMENT_PREFIX)) { - sourceMappingUrlIndex = readlinesIndex; - } - if (line.startsWith(SNIPPET_PREFIX)) { - existingSnippetIndex = readlinesIndex; - existingSnippet = line; - } + try { + const fileStream = makeReadStream(jsFilePath); + for await (const line of readlines(fileStream)) { + if (line.startsWith(SOURCE_MAPPING_URL_COMMENT_PREFIX)) { + sourceMappingUrlIndex = readlinesIndex; + } + if (line.startsWith(SNIPPET_PREFIX)) { + existingSnippetIndex = readlinesIndex; + existingSnippet = line; + } - lines.push(line); - readlinesIndex++; + lines.push(line); + readlinesIndex++; + } + } catch (e) { + throwJsFileReadError(e, jsFilePath, options); } const snippet = getCodeSnippet(sourceMapId); @@ -176,7 +190,11 @@ export async function injectFile(jsFilePath: string, sourceMapId: string, dryRun * Write to the file system */ debug(`injecting sourceMapId ${sourceMapId} into ${jsFilePath}`); - await overwriteFileContents(jsFilePath, lines); + try { + await overwriteFileContents(jsFilePath, lines); + } catch (e) { + throwJsFileOverwriteError(e, jsFilePath, options); + } } function getCodeSnippet(sourceMapId: string): string { @@ -201,17 +219,51 @@ export function isJsMapFilePath(filePath: string) { return filePath.match(/\.(js|cjs|mjs)\.map$/); } +function throwJsMapFileReadError(err: unknown, sourceMapFilePath: string, options: SourceMapInjectOptions): never { + throwAsUserFriendlyErrnoException( + err, + { + ENOENT: `Failed to open the source map file "${sourceMapFilePath}" because the file does not exist.\nMake sure that your source map files are being emitted to "${options.directory}". Regenerate your source map files, then rerun the inject command.`, + EACCES: `Failed to open the source map file "${sourceMapFilePath}" because of missing file permissions.\nMake sure that the CLI tool will have both "read" and "write" access to all files inside "${options.directory}", then rerun the inject command.` + } + ); +} + +function throwJsFileReadError(err: unknown, jsFilePath: string, options: SourceMapInjectOptions): never { + throwAsUserFriendlyErrnoException( + err, + { + ENOENT: `Failed to open the JavaScript file "${jsFilePath}" because the file no longer exists.\nMake sure that no other processes are removing files in "${options.directory}" while the CLI tool is running. Regenerate your JavaScript files, then re-run the inject command.`, + EACCES: `Failed to open the JavaScript file "${jsFilePath}" because of missing file permissions.\nMake sure that the CLI tool will have both "read" and "write" access to all files inside "${options.directory}", then rerun the inject command.`, + } + ); +} + +function throwJsFileOverwriteError(err: unknown, jsFilePath: string, options: SourceMapInjectOptions): never { + throwAsUserFriendlyErrnoException( + err, + { + EACCES: `Failed to inject "${jsFilePath}" with its sourceMapId because of missing permissions.\nMake sure that the CLI tool will have "read" and "write" access to the "${options.directory}" directory and all files inside it, then rerun the inject command.`, + } + ); +} + // TODO extract to a configurable, shared logger with improved styling -export function debug(str: string) { - console.log('[debug] ' + str); +export function debug(str: unknown) { + console.log('[debug]', str); } // TODO extract to a configurable, shared logger with improved styling -export function info(str: string) { +export function info(str: unknown) { console.log(str); } // TODO extract to a configurable, shared logger with improved styling -export function warn(str: string) { - console.log('[warn] ' + str); +export function warn(str: unknown) { + console.log('[warn]', str); +} + +// TODO extract to a configurable, shared logger with improved styling +export function error(str: unknown) { + console.log('[error]', str); } diff --git a/src/userFriendlyErrors.ts b/src/userFriendlyErrors.ts new file mode 100644 index 0000000..6d2eac9 --- /dev/null +++ b/src/userFriendlyErrors.ts @@ -0,0 +1,65 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +/** + * Wraps an Error with a user-friendly message. + * + * The user-friendly message should inform the user: + * - what operation failed to happen when the error occurred + * - why the operation failed + * - what the user can do to fix the error + * - which actions the user should take after they think they have fixed the error + * + * UserFriendlyError.userFriendlyMessage must be logged to the user when this error type is caught. + * UserFriendlyError.originalError can be logged to see the expected stack trace (i.e., in debug logs) + */ +export class UserFriendlyError extends Error { + constructor(public originalError: unknown, userFriendlyMessage: string) { + super(userFriendlyMessage); + this.name = 'UserFriendlyError'; + } +} + +/** + * Use this function to throw errors from operations that will use system calls (e.g., opening a file). + * + * Pass a mapping of user-friendly messages for each error code you could expect from the operation. + * + * If err is not a ErrnoException, then this function will simply re-throw err as-is. + */ +export function throwAsUserFriendlyErrnoException(err: unknown, messagesByErrCode: ErrCodeMessageTable): never { + // @ts-expect-error indexing messagesByErrCode with an arbitrary string is okay. we only use the value when it exists. + if (isErrnoException(err) && err.code && messagesByErrCode[err.code]) { + throw new UserFriendlyError(err, messagesByErrCode[err.code as ErrCode]!); + } else { + // re-throw the original error + throw err; + } +} + +// add more codes as needed +type ErrCode = 'EACCES' | 'ENOENT' | 'ENOTDIR' | 'EMFILE'; + +/** + * A lookup table for ErrnoException error messages. + * Example value: { ENOENT: 'message to display on ENOENT', 'ENOTDIR': 'message to display on ENOTDIR' } + */ +type ErrCodeMessageTable = Partial>; + +function isErrnoException(err: unknown): err is NodeJS.ErrnoException { + return err instanceof Error + && Boolean((err as NodeJS.ErrnoException).code); +} diff --git a/test/sourcemaps/utils.test.ts b/test/sourcemaps/utils.test.ts index 2b9ac5d..e44a501 100644 --- a/test/sourcemaps/utils.test.ts +++ b/test/sourcemaps/utils.test.ts @@ -14,27 +14,41 @@ * limitations under the License. */ -import { equal } from 'node:assert/strict'; +import { equal, deepEqual, fail } from 'node:assert/strict'; import { Readable } from 'node:stream'; -import { describe, it, mock } from 'node:test'; +import { afterEach, describe, it, mock } from 'node:test'; import { computeSourceMapId, discoverJsMapFilePath, injectFile } from '../../src/sourcemaps/utils'; import * as filesystem from '../../src/filesystem'; -import { deepEqual } from 'assert'; +import { SourceMapInjectOptions } from '../../src/sourcemaps'; +import { UserFriendlyError } from '../../src/userFriendlyErrors'; describe('discoverJsMapFilePath', () => { function mockJsFileContents(contents: string) { mock.method(filesystem, 'makeReadStream', () => Readable.from(contents)); } + function mockJsFileError() { + mock.method(filesystem, 'readlines', + () => throwErrnoException('EACCES') + ); + } + + afterEach(() => { + mock.restoreAll(); + mock.reset(); + }); + + const opts = getMockCommandOptions(); + it('should return a match if we already know the file name with ".map" is present in the directory', async () => { - const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/to/file.js.map' ]); + const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/to/file.js.map' ], opts); equal(path, 'path/to/file.js.map'); }); it('should return a match if "//# sourceMappingURL=" comment has a relative path', async () => { mockJsFileContents('//# sourceMappingURL=mappings/file.js.map\n'); - const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/to/mappings/file.js.map' ]); + const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/to/mappings/file.js.map' ], opts); equal(path, 'path/to/mappings/file.js.map'); }); @@ -42,7 +56,7 @@ describe('discoverJsMapFilePath', () => { it('should return a match if "//# sourceMappingURL=" comment has a relative path with ..', async () => { mockJsFileContents('//# sourceMappingURL=../mappings/file.js.map\n'); - const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/mappings/file.js.map' ]); + const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/mappings/file.js.map' ], opts); equal(path, 'path/mappings/file.js.map'); }); @@ -50,7 +64,7 @@ describe('discoverJsMapFilePath', () => { it('should not return a match if "//# sourceMappingURL=" comment points to a file outside of our directory', async () => { mockJsFileContents('//# sourceMappingURL=../../../some/other/folder/file.js.map'); - const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/to/mappings/file.js.map' ]); + const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/to/mappings/file.js.map' ], opts); equal(path, null); }); @@ -58,7 +72,7 @@ describe('discoverJsMapFilePath', () => { it('should not return a match if "//# sourceMappingURL=" comment has a data URL', async () => { mockJsFileContents('//# sourceMappingURL=data:application/json;base64,abcd\n'); - const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/to/data:application/json;base64,abcd' ]); + const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/to/data:application/json;base64,abcd' ], opts); equal(path, null); }); @@ -66,7 +80,7 @@ describe('discoverJsMapFilePath', () => { it('should not return a match if "//# sourceMappingURL=" comment has an HTTP URL', async () => { mockJsFileContents('//# sourceMappingURL=http://www.splunk.com/dist/file.js.map\n'); - const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/to/http://www.splunk.com/dist/file.js.map' ]); + const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/to/http://www.splunk.com/dist/file.js.map' ], opts); equal(path, null); }); @@ -74,7 +88,7 @@ describe('discoverJsMapFilePath', () => { it('should not return a match if "//# sourceMappingURL=" comment has an HTTPS URL', async () => { mockJsFileContents('//# sourceMappingURL=https://www.splunk.com/dist/file.js.map\n'); - const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/to/https://www.splunk.com/dist/file.js.map' ]); + const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/to/https://www.splunk.com/dist/file.js.map' ], opts); equal(path, null); }); @@ -82,22 +96,48 @@ describe('discoverJsMapFilePath', () => { it('should not return a match if file is not already known and sourceMappingURL comment is absent', async () => { mockJsFileContents('console.log("hello world!");'); - const path = await discoverJsMapFilePath('path/to/file.js', [ 'file.map.js' ]); + const path = await discoverJsMapFilePath('path/to/file.js', [ 'file.map.js' ], opts); equal(path, null); }); + + it('should throw UserFriendlyError when file operations fail due to known error code', async () => { + mockJsFileContents('console.log("hello world!");'); + + mockJsFileError(); + + try { + await discoverJsMapFilePath('path/to/file.js', [], opts); + fail('no error was thrown'); + } catch (e) { + equal(e instanceof UserFriendlyError, true); + } + }); }); describe('computeSourceMapId', () => { - it('returns truncated sha256 formatted like a GUID', async () => { + const opts = getMockCommandOptions(); + + it('should return truncated sha256 formatted like a GUID', async () => { mock.method(filesystem, 'makeReadStream', () => Readable.from([ 'line 1\n', 'line 2\n' ])); - const sourceMapId = await computeSourceMapId('file.js.map'); + const sourceMapId = await computeSourceMapId('file.js.map', opts); equal(sourceMapId, '90605548-63a6-2b9d-b5f7-26216876654e'); }); + + it('should throw UserFriendlyError when file operations fail due to known error code', async () => { + mock.method(filesystem, 'makeReadStream', () => throwErrnoException('EACCES')); + + try { + await computeSourceMapId('file.js.map', opts); + fail('no error was thrown'); + } catch (e) { + equal(e instanceof UserFriendlyError, true); + } + }); }); describe('injectFile', () => { @@ -109,18 +149,29 @@ describe('injectFile', () => { mock.method(filesystem, 'makeReadStream', () => Readable.from(content)); } + function mockJsFileReadError() { + mock.method(filesystem, 'makeReadStream', () => throwErrnoException('EACCES')); + } + function mockJsFileOverwrite() { return mock.method(filesystem, 'overwriteFileContents', () => { /* noop */ }); } - it('will insert the code snippet at the end of file when there is no "//# sourceMappingURL=" comment', async () => { + function mockJsFileOverwriteError() { + mock.method(filesystem, 'overwriteFileContents', () => throwErrnoException('EACCES')); + } + + const opts = getMockCommandOptions(); + const dryRunOpts = getMockCommandOptions({ dryRun: true }); + + it('should insert the code snippet at the end of file when there is no "//# sourceMappingURL=" comment', async () => { mockJsFileContentBeforeInjection([ 'line 1', 'line 2' ]); const mockOverwriteFn = mockJsFileOverwrite(); - await injectFile('file.js', '647366e7-d3db-6cf4-8693-2c321c377d5a', false); + await injectFile('file.js', '647366e7-d3db-6cf4-8693-2c321c377d5a', opts); deepEqual(mockOverwriteFn.mock.calls[0].arguments[1], [ 'line 1', @@ -129,7 +180,7 @@ describe('injectFile', () => { ]); }); - it('will insert the code snippet just before the "//# sourceMappingURL=" comment', async () => { + it('should insert the code snippet just before the "//# sourceMappingURL=" comment', async () => { mockJsFileContentBeforeInjection([ 'line 1', 'line 2', @@ -137,7 +188,7 @@ describe('injectFile', () => { ]); const mockOverwriteFn = mockJsFileOverwrite(); - await injectFile('file.js', '647366e7-d3db-6cf4-8693-2c321c377d5a', false); + await injectFile('file.js', '647366e7-d3db-6cf4-8693-2c321c377d5a', opts); deepEqual(mockOverwriteFn.mock.calls[0].arguments[1], [ 'line 1', @@ -147,7 +198,7 @@ describe('injectFile', () => { ]); }); - it('will overwrite the code snippet if an existing code snippet with a different sourceMapId is detected', async () => { + it('should overwrite the code snippet if an existing code snippet with a different sourceMapId is detected', async () => { mockJsFileContentBeforeInjection([ 'line 1', 'line 2', @@ -156,7 +207,7 @@ describe('injectFile', () => { ]); const mockOverwriteFn = mockJsFileOverwrite(); - await injectFile('file.js', '647366e7-d3db-6cf4-8693-2c321c377d5a', false); + await injectFile('file.js', '647366e7-d3db-6cf4-8693-2c321c377d5a', opts); deepEqual(mockOverwriteFn.mock.calls[0].arguments[1], [ 'line 1', @@ -166,13 +217,13 @@ describe('injectFile', () => { ]); }); - it('will not strip out extra lines or whitespace characters', async () => { + it('should not strip out extra lines or whitespace characters', async () => { mockJsFileContentBeforeInjectionRaw( `\n\n\nline 4\n\n line6\n line7 \n\nline9 \n//# sourceMappingURL=file.js.map` ); const mockOverwriteFn = mockJsFileOverwrite(); - await injectFile('file.js', '647366e7-d3db-6cf4-8693-2c321c377d5a', false); + await injectFile('file.js', '647366e7-d3db-6cf4-8693-2c321c377d5a', opts); deepEqual(mockOverwriteFn.mock.calls[0].arguments[1], [ '', @@ -189,7 +240,7 @@ describe('injectFile', () => { ]); }); - it('will not write to the file system if an existing code snippet with the same sourceMapId is detected', async () => { + it('should not write to the file system if an existing code snippet with the same sourceMapId is detected', async () => { mockJsFileContentBeforeInjection([ 'line 1', 'line 2', @@ -198,20 +249,60 @@ describe('injectFile', () => { ]); const mockOverwriteFn = mockJsFileOverwrite(); - await injectFile('file.js', '647366e7-d3db-6cf4-8693-2c321c377d5a', false); + await injectFile('file.js', '647366e7-d3db-6cf4-8693-2c321c377d5a', opts); equal(mockOverwriteFn.mock.callCount(), 0); }); - it('will not write to the file system if --dry-run was provided', async () => { + it('should not write to the file system if --dry-run was provided', async () => { mockJsFileContentBeforeInjection([ 'line 1\n', 'line 2\n' ]); const mockOverwriteFn = mockJsFileOverwrite(); - await injectFile('file.js', '647366e7-d3db-6cf4-8693-2c321c377d5a', true); + await injectFile('file.js', '647366e7-d3db-6cf4-8693-2c321c377d5a', dryRunOpts); equal(mockOverwriteFn.mock.callCount(), 0); }); + + it('should throw a UserFriendlyError if reading jsFilePath fails due to known error code', async () => { + mockJsFileReadError(); + + try { + await injectFile('file.js', '647366e7-d3db-6cf4-8693-2c321c377d5a', opts); + fail('no error thrown'); + } catch (e) { + equal(e instanceof UserFriendlyError, true); + } + }); + + it('should throw a UserFriendlyError if overwriting jsFilePath fails due to known error code', async () => { + mockJsFileContentBeforeInjection([ + 'line 1\n', + 'line 2\n' + ]); + mockJsFileOverwriteError(); + + try { + await injectFile('file.js', '647366e7-d3db-6cf4-8693-2c321c377d5a', opts); + fail('no error thrown'); + } catch (e) { + equal(e instanceof UserFriendlyError, true); + } + }); }); + +function getMockCommandOptions(overrides?: Partial): SourceMapInjectOptions { + const defaults = { + directory: 'path/', + dryRun: false + }; + return { ...defaults, ... overrides }; +} + +function throwErrnoException(code: string): never { + const err = new Error('mock error') as NodeJS.ErrnoException; + err.code = code; + throw err; +} From 7a01325068195662ad3b8124708052f002706bf6 Mon Sep 17 00:00:00 2001 From: Max Virgil Date: Tue, 29 Oct 2024 11:54:19 -0700 Subject: [PATCH 6/8] Code cleanup and extract to multiple files --- src/sourcemaps/computeSourceMapId.ts | 50 +++++ src/sourcemaps/discoverJsMapFilePath.ts | 116 ++++++++++ src/sourcemaps/index.ts | 6 +- src/sourcemaps/injectFile.ts | 106 +++++++++ src/sourcemaps/utils.ts | 202 +----------------- test/sourcemaps/computeSourceMapId.test.ts | 62 ++++++ test/sourcemaps/discoverJsMapFilePath.test.ts | 130 +++++++++++ .../{utils.test.ts => injectFile.test.ts} | 128 +---------- test/userFriendlyErrors.test.ts | 59 +++++ 9 files changed, 537 insertions(+), 322 deletions(-) create mode 100644 src/sourcemaps/computeSourceMapId.ts create mode 100644 src/sourcemaps/discoverJsMapFilePath.ts create mode 100644 src/sourcemaps/injectFile.ts create mode 100644 test/sourcemaps/computeSourceMapId.test.ts create mode 100644 test/sourcemaps/discoverJsMapFilePath.test.ts rename test/sourcemaps/{utils.test.ts => injectFile.test.ts} (63%) create mode 100644 test/userFriendlyErrors.test.ts diff --git a/src/sourcemaps/computeSourceMapId.ts b/src/sourcemaps/computeSourceMapId.ts new file mode 100644 index 0000000..8f6a9bc --- /dev/null +++ b/src/sourcemaps/computeSourceMapId.ts @@ -0,0 +1,50 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import { SourceMapInjectOptions } from './index'; +import { createHash } from 'node:crypto'; +import { makeReadStream } from '../filesystem'; +import { throwJsMapFileReadError } from './utils'; + +/** + * sourceMapId is computed by hashing the contents of the ".map" file, and then + * formatting the hash to like a GUID. + */ +export async function computeSourceMapId(sourceMapFilePath: string, options: SourceMapInjectOptions): Promise { + const hash = createHash('sha256').setEncoding('hex'); + + try { + const fileStream = makeReadStream(sourceMapFilePath); + for await (const chunk of fileStream) { + hash.update(chunk); + } + } catch (e) { + throwJsMapFileReadError(e, sourceMapFilePath, options); + } + + const sha = hash.digest('hex'); + return shaToSourceMapId(sha); +} + +function shaToSourceMapId(sha: string) { + return [ + sha.slice(0, 8), + sha.slice(8, 12), + sha.slice(12, 16), + sha.slice(16, 20), + sha.slice(20, 32), + ].join('-'); +} diff --git a/src/sourcemaps/discoverJsMapFilePath.ts b/src/sourcemaps/discoverJsMapFilePath.ts new file mode 100644 index 0000000..c46527b --- /dev/null +++ b/src/sourcemaps/discoverJsMapFilePath.ts @@ -0,0 +1,116 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import { SourceMapInjectOptions } from './index'; +import { makeReadStream, readlines } from '../filesystem'; +import path from 'node:path'; +import { debug, SOURCE_MAPPING_URL_COMMENT_PREFIX, throwJsFileReadError, warn } from './utils'; + +/** + * Determine the corresponding ".map" file for the given jsFilePath. + * + * Strategy: + * + * 1) Append ".map" to the jsFilePath. If we already know this file exists, return it as the match. + * This is a common naming convention for source map files. + * + * 2) Fallback to the "//# sourceMappingURL=..." comment in the JS file. + * If this comment is present, and we detect it is a relative file path, return this value as the match. + */ +export async function discoverJsMapFilePath(jsFilePath: string, allJsMapFilePaths: string[], options: SourceMapInjectOptions): Promise { + /* + * Check if we already know about the map file by adding ".map" extension. This is a common convention. + */ + if (allJsMapFilePaths.includes(`${jsFilePath}.map`)) { + const result = `${jsFilePath}.map`; + + debug(`found source map pair (using standard naming convention):`); + debug(` - ${jsFilePath}`); + debug(` - ${result}`); + + return result; + } + + /* + * Fallback to reading the JS file and parsing its "//# sourceMappingURL=..." comment + */ + let sourceMappingUrlLine: string | null = null; + try { + const fileStream = makeReadStream(jsFilePath); + for await (const line of readlines(fileStream)) { + if (line.startsWith(SOURCE_MAPPING_URL_COMMENT_PREFIX)) { + sourceMappingUrlLine = line; + break; + } + } + } catch (e) { + throwJsFileReadError(e, jsFilePath, options); + } + + let result: string | null = null; + if (sourceMappingUrlLine) { + result = resolveSourceMappingUrlToFilePath(sourceMappingUrlLine, jsFilePath, allJsMapFilePaths); + } + + if (result === null) { + debug(`no source map found for ${jsFilePath}`); + } + + return result; +} + +/** + * Parse the sourceMappingURL comment to a file path, or return null if the value is unsupported by our inject tool. + * + * Given the jsFilePath "path/file.js": + * - "//# sourceMappingURL=file.map.js" is a relative path, and "path/file.map.js" will be returned + * - "//# sourceMappingURL=http://..." is not a relative path, and null will be returned + */ +function resolveSourceMappingUrlToFilePath(line: string, jsFilePath: string, allJsMapFilePaths: string[]): string | null { + const url = line.slice(SOURCE_MAPPING_URL_COMMENT_PREFIX.length).trim(); + + if (path.isAbsolute(url) + || url.startsWith('http://') + || url.startsWith('https://') + || url.startsWith('data:')) { + debug(`skipping source map pair (unsupported sourceMappingURL comment):`); + debug(` - ${jsFilePath}`); + debug(` - ${url}`); + + return null; + } + + const matchingJsMapFilePath = path.join( + path.dirname(jsFilePath), + url + ); + + if (!allJsMapFilePaths.includes(matchingJsMapFilePath)) { + debug(`skipping source map pair (file not in provided directory):`); + debug(` - ${jsFilePath}`); + debug(` - ${url}`); + + warn(`skipping ${jsFilePath}, which is requesting a source map file outside of the provided --directory`); + + return null; + } else { + debug(`found source map pair (using sourceMappingURL comment):`); + debug(` - ${jsFilePath}`); + debug(` - ${matchingJsMapFilePath}`); + + return matchingJsMapFilePath; + } +} diff --git a/src/sourcemaps/index.ts b/src/sourcemaps/index.ts index e08701b..11f5ace 100644 --- a/src/sourcemaps/index.ts +++ b/src/sourcemaps/index.ts @@ -16,15 +16,15 @@ import { cleanupTemporaryFiles, readdirRecursive } from '../filesystem'; import { - computeSourceMapId, - discoverJsMapFilePath, info, - injectFile, isJsFilePath, isJsMapFilePath, warn } from './utils'; import { throwAsUserFriendlyErrnoException } from '../userFriendlyErrors'; +import { discoverJsMapFilePath } from './discoverJsMapFilePath'; +import { computeSourceMapId } from './computeSourceMapId'; +import { injectFile } from './injectFile'; export type SourceMapInjectOptions = { directory: string; diff --git a/src/sourcemaps/injectFile.ts b/src/sourcemaps/injectFile.ts new file mode 100644 index 0000000..cd97f84 --- /dev/null +++ b/src/sourcemaps/injectFile.ts @@ -0,0 +1,106 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import { SourceMapInjectOptions } from './index'; +import { makeReadStream, overwriteFileContents, readlines } from '../filesystem'; +import { + debug, + info, + SNIPPET_PREFIX, + SNIPPET_TEMPLATE, + SOURCE_MAPPING_URL_COMMENT_PREFIX, + throwJsFileOverwriteError, + throwJsFileReadError +} from './utils'; + +/** + * Injects the code snippet into the JS file to permanently associate the JS file with its sourceMapId. + * + * The code snippet will be injected at the end of the file, or just before the + * "//# sourceMappingURL=" comment if it exists. + * + * This operation is idempotent. + * + * If dryRun is true, this function will not write to the file system. + */ +export async function injectFile(jsFilePath: string, sourceMapId: string, options: SourceMapInjectOptions): Promise { + if (options.dryRun) { + info(`sourceMapId ${sourceMapId} would be injected to ${jsFilePath}`); + return; + } + + const lines = []; + let sourceMappingUrlIndex = -1; + let existingSnippetIndex = -1; + let existingSnippet = ''; + + /* + * Read the file into memory, and record any significant line indexes + */ + let readlinesIndex = 0; + try { + const fileStream = makeReadStream(jsFilePath); + for await (const line of readlines(fileStream)) { + if (line.startsWith(SOURCE_MAPPING_URL_COMMENT_PREFIX)) { + sourceMappingUrlIndex = readlinesIndex; + } + if (line.startsWith(SNIPPET_PREFIX)) { + existingSnippetIndex = readlinesIndex; + existingSnippet = line; + } + + lines.push(line); + readlinesIndex++; + } + } catch (e) { + throwJsFileReadError(e, jsFilePath, options); + } + + const snippet = getCodeSnippet(sourceMapId); + + /* + * No work required if the snippet already exists in the file (i.e. from a previous manual run) + */ + if (existingSnippet === snippet) { + debug(`sourceMapId ${sourceMapId} already injected into ${jsFilePath}`); + return; + } + + /* + * Insert the code snippet at the correct location + */ + if (existingSnippetIndex >= 0) { + lines.splice(existingSnippetIndex, 1, snippet); // overwrite the existing snippet + } else if (sourceMappingUrlIndex >= 0) { + lines.splice(sourceMappingUrlIndex, 0, snippet); + } else { + lines.push(snippet); + } + + /* + * Write new JavaScript file contents to the file system + */ + debug(`injecting sourceMapId ${sourceMapId} into ${jsFilePath}`); + try { + await overwriteFileContents(jsFilePath, lines); + } catch (e) { + throwJsFileOverwriteError(e, jsFilePath, options); + } +} + +function getCodeSnippet(sourceMapId: string): string { + return SNIPPET_TEMPLATE.replace('__SOURCE_MAP_ID_PLACEHOLDER__', sourceMapId); +} diff --git a/src/sourcemaps/utils.ts b/src/sourcemaps/utils.ts index 5835cc9..3005453 100644 --- a/src/sourcemaps/utils.ts +++ b/src/sourcemaps/utils.ts @@ -14,202 +14,12 @@ * limitations under the License. */ -import { makeReadStream, overwriteFileContents, readlines } from '../filesystem'; -import { createHash } from 'node:crypto'; -import path from 'node:path'; import { SourceMapInjectOptions } from './index'; import { throwAsUserFriendlyErrnoException } from '../userFriendlyErrors'; -const SOURCE_MAPPING_URL_COMMENT_PREFIX = '//# sourceMappingURL='; -const SNIPPET_PREFIX = `;/* olly sourcemaps inject */`; -const SNIPPET_TEMPLATE = `${SNIPPET_PREFIX}if (typeof window === 'object') { window.sourceMapIds = window.sourceMapIds || {}; let s = ''; try { throw new Error(); } catch (e) { s = (e.stack.match(/https?:\\/\\/[^\\s]+?(?::\\d+)?(?=:[\\d]+:[\\d]+)/) || [])[0]; } if (s) {window.sourceMapIds[s] = '__SOURCE_MAP_ID_PLACEHOLDER__';}};`; - -/** - * Determine the corresponding ".map" file for the given jsFilePath. - * - * Strategy: - * - * 1) Append ".map" to the jsFilePath. If we already know this file exists, return it as the match. - * This is a common naming convention for source map files. - * - * 2) Fallback to the "//# sourceMappingURL=..." comment in the JS file. - * If this comment is present, and we detect it is a relative file path, return this value as the match. - */ -export async function discoverJsMapFilePath(jsFilePath: string, allJsMapFilePaths: string[], options: SourceMapInjectOptions): Promise { - /* - * Check if we already know about the map file by adding ".map" extension. This is a common convention. - */ - if (allJsMapFilePaths.includes(`${jsFilePath}.map`)) { - const result = `${jsFilePath}.map`; - - debug(`found source map pair (using standard naming convention):`); - debug(` - ${jsFilePath}`); - debug(` - ${result}`); - - return result; - } - - /* - * Fallback to reading the JS file and parsing its "//# sourceMappingURL=..." comment - */ - let result: string | null = null; - try { - const fileStream = makeReadStream(jsFilePath); - for await (const line of readlines(fileStream)) { - if (line.startsWith(SOURCE_MAPPING_URL_COMMENT_PREFIX)) { - const url = line.slice(SOURCE_MAPPING_URL_COMMENT_PREFIX.length).trim(); - - if (path.isAbsolute(url) - || url.startsWith('http://') - || url.startsWith('https://') - || url.startsWith('data:')) { - debug(`skipping source map pair (unsupported sourceMappingURL comment):`); - debug(` - ${jsFilePath}`); - debug(` - ${url}`); - - result = null; - } else { - const matchingJsMapFilePath = path.join(path.dirname(jsFilePath), url); - - if (!allJsMapFilePaths.includes(matchingJsMapFilePath)) { - debug(`skipping source map pair (file not in provided directory):`); - debug(` - ${jsFilePath}`); - debug(` - ${url}`); - - warn(`skipping ${jsFilePath}, which is requesting a source map file outside of the provided --directory`); - - result = null; - } else { - debug(`found source map pair (using sourceMappingURL comment):`); - debug(` - ${jsFilePath}`); - debug(` - ${matchingJsMapFilePath}`); - - result = matchingJsMapFilePath; - } - } - - break; - } - } - } catch (e) { - throwJsFileReadError(e, jsFilePath, options); - } - - if (result === null) { - debug(`no source map found for ${jsFilePath}`); - } - - return result; -} - -/** - * sourceMapId is computed by hashing the contents of the ".map" file, and then - * formatting the hash to like a GUID. - */ -export async function computeSourceMapId(sourceMapFilePath: string, options: SourceMapInjectOptions): Promise { - const hash = createHash('sha256').setEncoding('hex'); - - try { - const fileStream = makeReadStream(sourceMapFilePath); - for await (const chunk of fileStream) { - hash.update(chunk); - } - } catch (e) { - throwJsMapFileReadError(e, sourceMapFilePath, options); - } - - const sha = hash.digest('hex'); - return shaToSourceMapId(sha); -} - -/** - * Injects the code snippet into the JS file to permanently associate the JS file with its sourceMapId. - * - * The code snippet will be injected at the end of the file, or just before the - * "//# sourceMappingURL=" comment if it exists. - * - * This operation is idempotent. - * - * If dryRun is true, this function will not write to the file system. - */ -export async function injectFile(jsFilePath: string, sourceMapId: string, options: SourceMapInjectOptions): Promise { - if (options.dryRun) { - info(`sourceMapId ${sourceMapId} would be injected to ${jsFilePath}`); - return; - } - - const lines = []; - let sourceMappingUrlIndex = -1; - let existingSnippetIndex = -1; - let existingSnippet = ''; - - /* - * Read the file into memory, and record any significant line indexes - */ - let readlinesIndex = 0; - try { - const fileStream = makeReadStream(jsFilePath); - for await (const line of readlines(fileStream)) { - if (line.startsWith(SOURCE_MAPPING_URL_COMMENT_PREFIX)) { - sourceMappingUrlIndex = readlinesIndex; - } - if (line.startsWith(SNIPPET_PREFIX)) { - existingSnippetIndex = readlinesIndex; - existingSnippet = line; - } - - lines.push(line); - readlinesIndex++; - } - } catch (e) { - throwJsFileReadError(e, jsFilePath, options); - } - - const snippet = getCodeSnippet(sourceMapId); - - /* - * No work required if the snippet already exists in the file (i.e. from a previous manual run) - */ - if (existingSnippet === snippet) { - debug(`sourceMapId ${sourceMapId} already injected into ${jsFilePath}`); - return; - } - - /* - * Determine where to insert the code snippet - */ - if (existingSnippetIndex >= 0) { - lines.splice(existingSnippetIndex, 1, snippet); // overwrite the existing snippet - } else if (sourceMappingUrlIndex >= 0) { - lines.splice(sourceMappingUrlIndex, 0, snippet); - } else { - lines.push(snippet); - } - - /* - * Write to the file system - */ - debug(`injecting sourceMapId ${sourceMapId} into ${jsFilePath}`); - try { - await overwriteFileContents(jsFilePath, lines); - } catch (e) { - throwJsFileOverwriteError(e, jsFilePath, options); - } -} - -function getCodeSnippet(sourceMapId: string): string { - return SNIPPET_TEMPLATE.replace('__SOURCE_MAP_ID_PLACEHOLDER__', sourceMapId); -} - -function shaToSourceMapId(sha: string) { - return [ - sha.slice(0, 8), - sha.slice(8, 12), - sha.slice(12, 16), - sha.slice(16, 20), - sha.slice(20, 32), - ].join('-'); -} +export const SOURCE_MAPPING_URL_COMMENT_PREFIX = '//# sourceMappingURL='; +export const SNIPPET_PREFIX = `;/* olly sourcemaps inject */`; +export const SNIPPET_TEMPLATE = `${SNIPPET_PREFIX}if (typeof window === 'object') { window.sourceMapIds = window.sourceMapIds || {}; let s = ''; try { throw new Error(); } catch (e) { s = (e.stack.match(/https?:\\/\\/[^\\s]+?(?::\\d+)?(?=:[\\d]+:[\\d]+)/) || [])[0]; } if (s) {window.sourceMapIds[s] = '__SOURCE_MAP_ID_PLACEHOLDER__';}};`; export function isJsFilePath(filePath: string) { return filePath.match(/\.(js|cjs|mjs)$/); @@ -219,7 +29,7 @@ export function isJsMapFilePath(filePath: string) { return filePath.match(/\.(js|cjs|mjs)\.map$/); } -function throwJsMapFileReadError(err: unknown, sourceMapFilePath: string, options: SourceMapInjectOptions): never { +export function throwJsMapFileReadError(err: unknown, sourceMapFilePath: string, options: SourceMapInjectOptions): never { throwAsUserFriendlyErrnoException( err, { @@ -229,7 +39,7 @@ function throwJsMapFileReadError(err: unknown, sourceMapFilePath: string, option ); } -function throwJsFileReadError(err: unknown, jsFilePath: string, options: SourceMapInjectOptions): never { +export function throwJsFileReadError(err: unknown, jsFilePath: string, options: SourceMapInjectOptions): never { throwAsUserFriendlyErrnoException( err, { @@ -239,7 +49,7 @@ function throwJsFileReadError(err: unknown, jsFilePath: string, options: SourceM ); } -function throwJsFileOverwriteError(err: unknown, jsFilePath: string, options: SourceMapInjectOptions): never { +export function throwJsFileOverwriteError(err: unknown, jsFilePath: string, options: SourceMapInjectOptions): never { throwAsUserFriendlyErrnoException( err, { diff --git a/test/sourcemaps/computeSourceMapId.test.ts b/test/sourcemaps/computeSourceMapId.test.ts new file mode 100644 index 0000000..d7f011c --- /dev/null +++ b/test/sourcemaps/computeSourceMapId.test.ts @@ -0,0 +1,62 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import { describe, it, mock } from 'node:test'; +import * as filesystem from '../../src/filesystem'; +import { Readable } from 'node:stream'; +import { computeSourceMapId } from '../../src/sourcemaps/computeSourceMapId'; +import { equal, fail } from 'node:assert/strict'; +import { UserFriendlyError } from '../../src/userFriendlyErrors'; +import { SourceMapInjectOptions } from '../../src/sourcemaps'; + +describe('computeSourceMapId', () => { + const opts = getMockCommandOptions(); + + it('should return truncated sha256 formatted like a GUID', async () => { + mock.method(filesystem, 'makeReadStream', () => Readable.from([ + 'line 1\n', + 'line 2\n' + ])); + + const sourceMapId = await computeSourceMapId('file.js.map', opts); + equal(sourceMapId, '90605548-63a6-2b9d-b5f7-26216876654e'); + }); + + it('should throw UserFriendlyError when file operations fail due to known error code', async () => { + mock.method(filesystem, 'makeReadStream', () => throwErrnoException('EACCES')); + + try { + await computeSourceMapId('file.js.map', opts); + fail('no error was thrown'); + } catch (e) { + equal(e instanceof UserFriendlyError, true); + } + }); +}); + +function getMockCommandOptions(overrides?: Partial): SourceMapInjectOptions { + const defaults = { + directory: 'path/', + dryRun: false + }; + return { ...defaults, ... overrides }; +} + +function throwErrnoException(code: string): never { + const err = new Error('mock error') as NodeJS.ErrnoException; + err.code = code; + throw err; +} diff --git a/test/sourcemaps/discoverJsMapFilePath.test.ts b/test/sourcemaps/discoverJsMapFilePath.test.ts new file mode 100644 index 0000000..e6817d7 --- /dev/null +++ b/test/sourcemaps/discoverJsMapFilePath.test.ts @@ -0,0 +1,130 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import { afterEach, describe, it, mock } from 'node:test'; +import * as filesystem from '../../src/filesystem'; +import { Readable } from 'node:stream'; +import { discoverJsMapFilePath } from '../../src/sourcemaps/discoverJsMapFilePath'; +import { equal, fail } from 'node:assert/strict'; +import { UserFriendlyError } from '../../src/userFriendlyErrors'; +import { SourceMapInjectOptions } from '../../src/sourcemaps'; + +describe('discoverJsMapFilePath', () => { + function mockJsFileContents(contents: string) { + mock.method(filesystem, 'makeReadStream', () => Readable.from(contents)); + } + + function mockJsFileError() { + mock.method(filesystem, 'readlines', + () => throwErrnoException('EACCES') + ); + } + + afterEach(() => { + mock.restoreAll(); + mock.reset(); + }); + + const opts = getMockCommandOptions(); + + it('should return a match if we already know the file name with ".map" is present in the directory', async () => { + const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/to/file.js.map' ], opts); + equal(path, 'path/to/file.js.map'); + }); + + it('should return a match if "//# sourceMappingURL=" comment has a relative path', async () => { + mockJsFileContents('//# sourceMappingURL=mappings/file.js.map\n'); + + const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/to/mappings/file.js.map' ], opts); + + equal(path, 'path/to/mappings/file.js.map'); + }); + + it('should return a match if "//# sourceMappingURL=" comment has a relative path with ..', async () => { + mockJsFileContents('//# sourceMappingURL=../mappings/file.js.map\n'); + + const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/mappings/file.js.map' ], opts); + + equal(path, 'path/mappings/file.js.map'); + }); + + it('should not return a match if "//# sourceMappingURL=" comment points to a file outside of our directory', async () => { + mockJsFileContents('//# sourceMappingURL=../../../some/other/folder/file.js.map'); + + const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/to/mappings/file.js.map' ], opts); + + equal(path, null); + }); + + it('should not return a match if "//# sourceMappingURL=" comment has a data URL', async () => { + mockJsFileContents('//# sourceMappingURL=data:application/json;base64,abcd\n'); + + const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/to/data:application/json;base64,abcd' ], opts); + + equal(path, null); + }); + + it('should not return a match if "//# sourceMappingURL=" comment has an HTTP URL', async () => { + mockJsFileContents('//# sourceMappingURL=http://www.splunk.com/dist/file.js.map\n'); + + const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/to/http://www.splunk.com/dist/file.js.map' ], opts); + + equal(path, null); + }); + + it('should not return a match if "//# sourceMappingURL=" comment has an HTTPS URL', async () => { + mockJsFileContents('//# sourceMappingURL=https://www.splunk.com/dist/file.js.map\n'); + + const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/to/https://www.splunk.com/dist/file.js.map' ], opts); + + equal(path, null); + }); + + it('should not return a match if file is not already known and sourceMappingURL comment is absent', async () => { + mockJsFileContents('console.log("hello world!");'); + + const path = await discoverJsMapFilePath('path/to/file.js', [ 'file.map.js' ], opts); + + equal(path, null); + }); + + it('should throw UserFriendlyError when file operations fail due to known error code', async () => { + mockJsFileContents('console.log("hello world!");'); + + mockJsFileError(); + + try { + await discoverJsMapFilePath('path/to/file.js', [], opts); + fail('no error was thrown'); + } catch (e) { + equal(e instanceof UserFriendlyError, true); + } + }); +}); + +function getMockCommandOptions(overrides?: Partial): SourceMapInjectOptions { + const defaults = { + directory: 'path/', + dryRun: false + }; + return { ...defaults, ... overrides }; +} + +function throwErrnoException(code: string): never { + const err = new Error('mock error') as NodeJS.ErrnoException; + err.code = code; + throw err; +} diff --git a/test/sourcemaps/utils.test.ts b/test/sourcemaps/injectFile.test.ts similarity index 63% rename from test/sourcemaps/utils.test.ts rename to test/sourcemaps/injectFile.test.ts index e44a501..db9c89d 100644 --- a/test/sourcemaps/utils.test.ts +++ b/test/sourcemaps/injectFile.test.ts @@ -14,131 +14,13 @@ * limitations under the License. */ -import { equal, deepEqual, fail } from 'node:assert/strict'; -import { Readable } from 'node:stream'; -import { afterEach, describe, it, mock } from 'node:test'; -import { computeSourceMapId, discoverJsMapFilePath, injectFile } from '../../src/sourcemaps/utils'; +import { describe, it, mock } from 'node:test'; import * as filesystem from '../../src/filesystem'; -import { SourceMapInjectOptions } from '../../src/sourcemaps'; +import { Readable } from 'node:stream'; +import { injectFile } from '../../src/sourcemaps/injectFile'; +import { deepEqual, equal, fail } from 'node:assert/strict'; import { UserFriendlyError } from '../../src/userFriendlyErrors'; - -describe('discoverJsMapFilePath', () => { - function mockJsFileContents(contents: string) { - mock.method(filesystem, 'makeReadStream', () => Readable.from(contents)); - } - - function mockJsFileError() { - mock.method(filesystem, 'readlines', - () => throwErrnoException('EACCES') - ); - } - - afterEach(() => { - mock.restoreAll(); - mock.reset(); - }); - - const opts = getMockCommandOptions(); - - it('should return a match if we already know the file name with ".map" is present in the directory', async () => { - const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/to/file.js.map' ], opts); - equal(path, 'path/to/file.js.map'); - }); - - it('should return a match if "//# sourceMappingURL=" comment has a relative path', async () => { - mockJsFileContents('//# sourceMappingURL=mappings/file.js.map\n'); - - const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/to/mappings/file.js.map' ], opts); - - equal(path, 'path/to/mappings/file.js.map'); - }); - - it('should return a match if "//# sourceMappingURL=" comment has a relative path with ..', async () => { - mockJsFileContents('//# sourceMappingURL=../mappings/file.js.map\n'); - - const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/mappings/file.js.map' ], opts); - - equal(path, 'path/mappings/file.js.map'); - }); - - it('should not return a match if "//# sourceMappingURL=" comment points to a file outside of our directory', async () => { - mockJsFileContents('//# sourceMappingURL=../../../some/other/folder/file.js.map'); - - const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/to/mappings/file.js.map' ], opts); - - equal(path, null); - }); - - it('should not return a match if "//# sourceMappingURL=" comment has a data URL', async () => { - mockJsFileContents('//# sourceMappingURL=data:application/json;base64,abcd\n'); - - const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/to/data:application/json;base64,abcd' ], opts); - - equal(path, null); - }); - - it('should not return a match if "//# sourceMappingURL=" comment has an HTTP URL', async () => { - mockJsFileContents('//# sourceMappingURL=http://www.splunk.com/dist/file.js.map\n'); - - const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/to/http://www.splunk.com/dist/file.js.map' ], opts); - - equal(path, null); - }); - - it('should not return a match if "//# sourceMappingURL=" comment has an HTTPS URL', async () => { - mockJsFileContents('//# sourceMappingURL=https://www.splunk.com/dist/file.js.map\n'); - - const path = await discoverJsMapFilePath('path/to/file.js', [ 'path/to/https://www.splunk.com/dist/file.js.map' ], opts); - - equal(path, null); - }); - - it('should not return a match if file is not already known and sourceMappingURL comment is absent', async () => { - mockJsFileContents('console.log("hello world!");'); - - const path = await discoverJsMapFilePath('path/to/file.js', [ 'file.map.js' ], opts); - - equal(path, null); - }); - - it('should throw UserFriendlyError when file operations fail due to known error code', async () => { - mockJsFileContents('console.log("hello world!");'); - - mockJsFileError(); - - try { - await discoverJsMapFilePath('path/to/file.js', [], opts); - fail('no error was thrown'); - } catch (e) { - equal(e instanceof UserFriendlyError, true); - } - }); -}); - -describe('computeSourceMapId', () => { - const opts = getMockCommandOptions(); - - it('should return truncated sha256 formatted like a GUID', async () => { - mock.method(filesystem, 'makeReadStream', () => Readable.from([ - 'line 1\n', - 'line 2\n' - ])); - - const sourceMapId = await computeSourceMapId('file.js.map', opts); - equal(sourceMapId, '90605548-63a6-2b9d-b5f7-26216876654e'); - }); - - it('should throw UserFriendlyError when file operations fail due to known error code', async () => { - mock.method(filesystem, 'makeReadStream', () => throwErrnoException('EACCES')); - - try { - await computeSourceMapId('file.js.map', opts); - fail('no error was thrown'); - } catch (e) { - equal(e instanceof UserFriendlyError, true); - } - }); -}); +import { SourceMapInjectOptions } from '../../src/sourcemaps'; describe('injectFile', () => { function mockJsFileContentBeforeInjection(lines: string[]) { diff --git a/test/userFriendlyErrors.test.ts b/test/userFriendlyErrors.test.ts new file mode 100644 index 0000000..9ad04b6 --- /dev/null +++ b/test/userFriendlyErrors.test.ts @@ -0,0 +1,59 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import { describe, it } from 'node:test'; +import { throwAsUserFriendlyErrnoException, UserFriendlyError } from '../src/userFriendlyErrors'; +import { equal, fail } from 'node:assert/strict'; + +describe('throwAsUserFriendlyErrnoException', () => { + it('should throw a UserFriendlyError when it receives a ErrnoException with a matching code in the message lookup table', () => { + const errnoException = getErrnoException('EACCES'); + try { + throwAsUserFriendlyErrnoException(errnoException, { 'EACCES': 'user-friendly message' }); + fail('no error thrown'); + } catch (e) { + equal(e instanceof UserFriendlyError, true); + equal((e as UserFriendlyError).message, 'user-friendly message'); + equal((e as UserFriendlyError).originalError, errnoException); + } + }); + + it('should re-throw the given error when it receives an ErrnoException without a matching code in the message lookup table', () => { + const errnoException = getErrnoException('OTHER'); + try { + throwAsUserFriendlyErrnoException(errnoException, { 'EACCES': 'user-friendly message' }); + fail('no error thrown'); + } catch (e) { + equal(e, errnoException); + } + }); + + it('should re-throw the given error if it is not an ErrnoException', () => { + const error = new Error('a normal JS error'); + try { + throwAsUserFriendlyErrnoException(error, { 'EACCES': 'user-friendly message' }); + fail('no error thrown'); + } catch (e) { + equal(e, error); + } + }); +}); + +function getErrnoException(code: string): Error { + const err = new Error('mock error') as NodeJS.ErrnoException; + err.code = code; + return err; +} From 86146aaee21601ac7310549663409ffb8b728fb2 Mon Sep 17 00:00:00 2001 From: Max Virgil Date: Tue, 29 Oct 2024 12:26:22 -0700 Subject: [PATCH 7/8] Fix build --- src/commands/sourcemaps.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/sourcemaps.ts b/src/commands/sourcemaps.ts index dd47300..b1d923f 100644 --- a/src/commands/sourcemaps.ts +++ b/src/commands/sourcemaps.ts @@ -17,7 +17,7 @@ import { Command } from 'commander'; import { runSourcemapInject } from '../sourcemaps'; import { debug, error } from '../sourcemaps/utils'; -import { UserFriendlyError } from '../sourcemaps/userFriendlyError'; +import { UserFriendlyError } from '../userFriendlyErrors'; export const sourcemapsCommand = new Command('sourcemaps'); From facb6fe6205f4da49d968fcc8cef97d2a39f8e7d Mon Sep 17 00:00:00 2001 From: Max Virgil Date: Tue, 29 Oct 2024 15:19:12 -0700 Subject: [PATCH 8/8] Improve help output --- src/commands/sourcemaps.ts | 35 +++++++++++++++++++++++++++++------ src/index.ts | 2 +- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/commands/sourcemaps.ts b/src/commands/sourcemaps.ts index b1d923f..55eee59 100644 --- a/src/commands/sourcemaps.ts +++ b/src/commands/sourcemaps.ts @@ -21,21 +21,44 @@ import { UserFriendlyError } from '../userFriendlyErrors'; export const sourcemapsCommand = new Command('sourcemaps'); +const injectDescription = +`Inject a code snippet into your JavaScript bundles to enable automatic source mapping of your application's JavaScript errors. + +Before running this command: + - verify your production build tool is configured to generate source maps + - run the production build for your project + - verify your production JavaScript bundles and source maps were emitted to the same output directory + +Pass the path of your build output folder as the --directory. This command will recursively search the path +to locate all JavaScript files (.js, .cjs, .mjs) and source map files (.js.map, .cjs.map, .mjs.map) +from your production build. + +When this command detects that a JavaScript file (example: main.min.js) has a source map (example: main.min.js.map), +a code snippet will be injected into the JavaScript file. This code snippet contains a "sourceMapId" that +is needed to successfully perform automatic source mapping. + +This is the first of multiple steps for enabling automatic source mapping of your application's JavaScript errors. + +After running this command successfully: + - run "sourcemaps upload" to send source map files to Splunk Observability Cloud + - deploy the injected JavaScript files to your production environment +`; + sourcemapsCommand .command('inject') + .showHelpAfterError(true) .usage('--directory path/to/dist') + .summary(`Inject a code snippet into your JavaScript bundles to allow for automatic source mapping of errors`) + .description(injectDescription) .requiredOption( - '--directory ', - 'Folder containing JavaScript files and their source maps (required)' + '--directory ', + 'Path to the directory containing your both JavaScript files and source map files (required)' ) .option( '--dry-run', - 'Use --dry-run to preview the files that will be injected for the given options. Does not modify any files on the file system. (optional)', + 'Use --dry-run to preview the files that will be injected for the given options, without modifying any files on the file system (optional)', false ) - .description( - `Traverses the --directory to locate JavaScript files (.js, .cjs, .mjs) and their source map files (.js.map, .cjs.map, .mjs.map). This command will inject code into the JavaScript files with information about their corresponding map file. This injected code is used to perform automatic source mapping for any JavaScript errors that occur in your app.\n\n` + - `After running "sourcemaps inject", make sure to run "sourcemaps upload".`) .action( async (options) => { try { diff --git a/src/index.ts b/src/index.ts index 2a7c4bb..95b65a9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,4 +35,4 @@ program.addCommand(androidCommand); program.addCommand(sourcemapsCommand); program.addCommand(sourcefilesCommand); -program.parse(process.argv); +program.parseAsync(process.argv);