Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement "sourcemaps inject" command #26

Merged
merged 8 commits into from
Oct 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 55 additions & 5 deletions src/commands/sourcemaps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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>',
'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')
Expand Down
109 changes: 109 additions & 0 deletions src/filesystem/index.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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);
}
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,4 @@ program.addCommand(androidCommand);
program.addCommand(sourcemapsCommand);
program.addCommand(sourcefilesCommand);

program.parse(process.argv);
program.parseAsync(process.argv);
50 changes: 50 additions & 0 deletions src/sourcemaps/computeSourceMapId.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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('-');
}
116 changes: 116 additions & 0 deletions src/sourcemaps/discoverJsMapFilePath.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
/*
* 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;
}
}
Loading