diff --git a/src/commands/sourcemaps.ts b/src/commands/sourcemaps.ts index ab3b865..55eee59 100644 --- a/src/commands/sourcemaps.ts +++ b/src/commands/sourcemaps.ts @@ -15,16 +15,66 @@ */ import { Command } from 'commander'; +import { runSourcemapInject } from '../sourcemaps'; +import { debug, error } from '../sourcemaps/utils'; +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') - .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}`); - }); + .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 ', + '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, without modifying any files on the file system (optional)', + false + ) + .action( + async (options) => { + try { + await runSourcemapInject(options); + } catch (e) { + if (e instanceof UserFriendlyError) { + debug(e.originalError); + error(e.message); + } else { + error('Exiting due to an unexpected error:'); + error(e); + } + sourcemapsCommand.error(''); + } + } + ); sourcemapsCommand .command('upload') diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts new file mode 100644 index 0000000..2edbf1f --- /dev/null +++ b/src/filesystem/index.ts @@ -0,0 +1,109 @@ +/* + * 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, 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. + * + * 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' }); +} + +/** + * 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); + await rename(tempFilePath, filePath); +} + +/** + * 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); + outStream.write(os.EOL); + } + outStream.end(); + return finished(outStream); +} 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); 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 new file mode 100644 index 0000000..11f5ace --- /dev/null +++ b/src/sourcemaps/index.ts @@ -0,0 +1,104 @@ +/* + * 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 { cleanupTemporaryFiles, readdirRecursive } from '../filesystem'; +import { + info, + 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; + 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) { + throwDirectoryReadError(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, options); + if (!matchingSourceMapFilePath) { + info(`No source map was detected for ${jsFilePath}. Skipping injection.`); + continue; + } + + const sourceMapId = await computeSourceMapId(matchingSourceMapFilePath, options); + await injectFile(jsFilePath, sourceMapId, options); + + 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 + */ + 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 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/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 new file mode 100644 index 0000000..3005453 --- /dev/null +++ b/src/sourcemaps/utils.ts @@ -0,0 +1,79 @@ +/* + * 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 { throwAsUserFriendlyErrnoException } from '../userFriendlyErrors'; + +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)$/); +} + +export function isJsMapFilePath(filePath: string) { + return filePath.match(/\.(js|cjs|mjs)\.map$/); +} + +export 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.` + } + ); +} + +export 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.`, + } + ); +} + +export 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: unknown) { + console.log('[debug]', str); +} + +// TODO extract to a configurable, shared logger with improved styling +export function info(str: unknown) { + console.log(str); +} + +// TODO extract to a configurable, shared logger with improved styling +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/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/injectFile.test.ts b/test/sourcemaps/injectFile.test.ts new file mode 100644 index 0000000..db9c89d --- /dev/null +++ b/test/sourcemaps/injectFile.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 { describe, it, mock } from 'node:test'; +import * as filesystem from '../../src/filesystem'; +import { Readable } from 'node:stream'; +import { injectFile } from '../../src/sourcemaps/injectFile'; +import { deepEqual, equal, fail } from 'node:assert/strict'; +import { UserFriendlyError } from '../../src/userFriendlyErrors'; +import { SourceMapInjectOptions } from '../../src/sourcemaps'; + +describe('injectFile', () => { + function mockJsFileContentBeforeInjection(lines: string[]) { + mock.method(filesystem, 'makeReadStream', () => Readable.from(lines.join('\n'))); + } + + function mockJsFileContentBeforeInjectionRaw(content: string) { + mock.method(filesystem, 'makeReadStream', () => Readable.from(content)); + } + + function mockJsFileReadError() { + mock.method(filesystem, 'makeReadStream', () => throwErrnoException('EACCES')); + } + + function mockJsFileOverwrite() { + return mock.method(filesystem, 'overwriteFileContents', () => { /* noop */ }); + } + + 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', opts); + + 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('should 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', opts); + + 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('should 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', opts); + + 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('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', opts); + + 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('should 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', opts); + + equal(mockOverwriteFn.mock.callCount(), 0); + }); + + 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', 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; +} 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; +}