UNPKG

@backtrace/sourcemap-tools

Version:
362 lines (311 loc) 13.5 kB
import path from 'path'; import { DebugIdGenerator } from './DebugIdGenerator'; import { parseJSON, readFile, statFile } from './helpers/common'; import { pipe } from './helpers/flow'; import { appendBeforeWhitespaces } from './helpers/stringHelpers'; import { stringToUuid } from './helpers/stringToUuid'; import { RawSourceMap, RawSourceMapWithDebugId } from './models/RawSourceMap'; import { Err, Ok, R, ResultPromise } from './models/Result'; export interface ProcessResultWithoutSourceMap { readonly debugId: string; readonly source: string; } export interface ProcessResultWithSourceMaps extends ProcessResultWithoutSourceMap { readonly sourceMap: RawSourceMapWithDebugId; } export interface ProcessResultWithPaths extends ProcessResultWithSourceMaps { readonly sourcePath: string; readonly sourceMapPath: string; } export interface AddSourcesResult { readonly sourceMap: RawSourceMap; /** * Source paths that were successfully added. */ readonly succeeded: string[]; /** * Source paths that failed to read, but source content was already in the sourcemap. */ readonly skipped: string[]; /** * Source paths that failed to read and the sources content was not in the sourcemap. */ readonly failed: string[]; } export class SourceProcessor { constructor(private readonly _debugIdGenerator: DebugIdGenerator) {} public isSourceProcessed(source: string): boolean { return !!this._debugIdGenerator.getSourceDebugIdFromComment(source); } public isSourceMapProcessed(sourceMap: RawSourceMap): boolean { return !!this._debugIdGenerator.getSourceMapDebugId(sourceMap); } public async isSourceFileProcessed(sourcePath: string): ResultPromise<boolean, string> { return pipe( sourcePath, readFile, R.map((v) => this.isSourceProcessed(v)), ); } public async isSourceMapFileProcessed(sourceMapPath: string): ResultPromise<boolean, string> { return pipe( sourceMapPath, readFile, R.map(parseJSON<RawSourceMap>), R.map((v) => this.isSourceMapProcessed(v)), ); } public getSourceDebugId(source: string): string | undefined { return this._debugIdGenerator.getSourceDebugIdFromComment(source); } public getSourceMapDebugId(sourceMap: RawSourceMap): string | undefined { return this._debugIdGenerator.getSourceMapDebugId(sourceMap); } public async getSourceMapFileDebugId(sourceMapPath: string): ResultPromise<string | undefined, string> { return pipe( sourceMapPath, readFile, R.map(parseJSON<RawSourceMap>), R.map((sourceMap) => this.getSourceMapDebugId(sourceMap)), ); } /** * Adds required snippets and comments to source * @param source Source content. * @param debugId Debug ID. If not provided, one will be generated from `source`. * @param force Force adding changes. * @returns Used debug ID, new source and new sourcemap. */ public async processSource( source: string, debugId?: string, force?: boolean, ): Promise<ProcessResultWithoutSourceMap> { return await this.processSourceAndAvailableSourceMap(source, undefined, debugId, force); } /** * Adds required snippets and comments to source, and modifies sourcemap to include debug ID. * @param source Source content. * @param sourceMap Sourcemap object or JSON. * @param debugId Debug ID. If not provided, one will be generated from `source`. * @param force Force adding changes. * @returns Used debug ID, new source and new sourcemap. */ public async processSourceAndSourceMap( source: string, sourceMap: RawSourceMap, debugId?: string, force?: boolean, ): Promise<ProcessResultWithSourceMaps> { return await this.processSourceAndAvailableSourceMap(source, sourceMap, debugId, force); } /** * Adds required snippets and comments to source, and modifies sourcemap to include debug ID if available. * @param source Source content. * @param sourceMap Sourcemap object or JSON. * @param debugId Debug ID. If not provided, one will be generated from `source`. * @param force Force adding changes. * @returns Used debug ID, new source and new sourcemap. */ private async processSourceAndAvailableSourceMap( source: string, sourceMap: RawSourceMap, debugId?: string, force?: boolean, ): Promise<ProcessResultWithSourceMaps>; private async processSourceAndAvailableSourceMap( source: string, sourceMap?: undefined, debugId?: string, force?: boolean, ): Promise<ProcessResultWithoutSourceMap>; private async processSourceAndAvailableSourceMap( source: string, sourceMap?: RawSourceMap, debugId?: string, force?: boolean, ): Promise<ProcessResultWithSourceMaps | ProcessResultWithoutSourceMap> { const sourceDebugId = this.getSourceDebugId(source); if (!debugId) { debugId = sourceDebugId ?? stringToUuid(source); } let newSource = source; let offsetSourceMap: RawSourceMap | undefined; // If source has debug ID, but it is different, we need to only replace it if (sourceDebugId && debugId !== sourceDebugId) { newSource = this._debugIdGenerator.replaceDebugId(source, sourceDebugId, debugId); } if (force || !sourceDebugId || !this._debugIdGenerator.hasCodeSnippet(source, debugId)) { const sourceSnippet = this._debugIdGenerator.generateSourceSnippet(debugId); const shebang = source.match(/^(#!.+\n)/)?.[1]; newSource = shebang ? shebang + sourceSnippet + '\n' + source.substring(shebang.length) : sourceSnippet + '\n' + source; if (sourceMap) { // We need to offset the source map by amount of lines that we're inserting to the source code // Sourcemaps map code like this: // original code X:Y => generated code A:B // So if we add any code to generated code, mappings after that code will become invalid // We need to offset the mapping lines by sourceSnippetNewlineCount: // original code X:Y => generated code (A + sourceSnippetNewlineCount):B const sourceSnippetNewlineCount = sourceSnippet.match(/\n/g)?.length ?? 0; offsetSourceMap = await this.offsetSourceMap(sourceMap, sourceSnippetNewlineCount + 1); } } if (force || !sourceDebugId || !this._debugIdGenerator.hasCommentSnippet(source, debugId)) { const sourceComment = this._debugIdGenerator.generateSourceComment(debugId); newSource = appendBeforeWhitespaces(newSource, '\n' + sourceComment); } if (!sourceMap) { return { debugId, source: newSource } as ProcessResultWithoutSourceMap; } const newSourceMap = this._debugIdGenerator.addSourceMapDebugId(offsetSourceMap ?? sourceMap, debugId); return { debugId, source: newSource, sourceMap: newSourceMap }; } /** * Adds required snippets and comments to source, and modifies sourcemap to include debug ID. * Will write modified content to the files. * @param sourcePath Path to the source. * @param sourceMapPath Path to the sourcemap. If not specified, will try to resolve from sourceMapURL. * @param debugId Debug ID. If not provided, one will be generated from `source`. * @returns Used debug ID. */ public async processSourceAndSourceMapFiles( sourcePath: string, sourceMapPath?: string, debugId?: string, force?: boolean, ): ResultPromise<ProcessResultWithPaths, string> { const sourceReadResult = await readFile(sourcePath); if (sourceReadResult.isErr()) { return sourceReadResult; } const source = sourceReadResult.data; if (!sourceMapPath) { const pathFromSource = await this.getSourceMapPathFromSource(source, sourcePath); if (!pathFromSource) { return Err('could not find source map for source'); } sourceMapPath = pathFromSource; } const sourceMapReadResult = await readFile(sourceMapPath); if (sourceMapReadResult.isErr()) { return sourceMapReadResult; } const sourceMapJson = sourceMapReadResult.data; const parseResult = parseJSON<RawSourceMap>(sourceMapJson); if (parseResult.isErr()) { return parseResult; } const sourceMap = parseResult.data; const processResult = await this.processSourceAndSourceMap(source, sourceMap, debugId, force); return Ok({ ...processResult, sourcePath, sourceMapPath, } as ProcessResultWithPaths); } public async getSourceMapPathFromSourceFile(sourcePath: string) { const sourceReadResult = await readFile(sourcePath); if (sourceReadResult.isErr()) { return sourceReadResult; } return Ok(await this.getSourceMapPathFromSource(sourceReadResult.data, sourcePath)); } public async getSourceMapPathFromSource(source: string, sourcePath: string): Promise<string | undefined> { const matchAll = (str: string, regex: RegExp) => { const result: RegExpMatchArray[] = []; // eslint-disable-next-line no-constant-condition while (true) { const match = regex.exec(str); if (!match) { return result; } result.push(match); } }; const checkFile = (filePath: string) => pipe(filePath, statFile, (result) => (result.isOk() && result.data.isFile() ? filePath : undefined)); const sourceMapName = path.basename(sourcePath) + '.map'; const checkFileInDir = (dir: string) => pipe(dir, statFile, (result) => result.isOk() && result.data.isDirectory() ? // If path exists and is a directory, check if file exists in that dir checkFile(path.join(dir, sourceMapName)) : // If path does not exist or is not a directory, check if file exists in dir of that path checkFile(path.join(path.dirname(dir), sourceMapName)), ); const matches = matchAll(source, /^\s*\/\/# sourceMappingURL=(.+)$/gm); if (!matches.length) { return checkFileInDir(sourcePath); } for (const match of matches.reverse()) { const file = match[1]; if (!file) { continue; } const fullPath = path.resolve(path.dirname(sourcePath), file); if (await checkFile(fullPath)) { return fullPath; } const fileInDir = await checkFileInDir(fullPath); if (fileInDir) { return fileInDir; } } return checkFileInDir(sourcePath); } public async addSourcesToSourceMap( sourceMap: string | RawSourceMap, sourceMapPath: string, force: boolean, ): ResultPromise<AddSourcesResult, string> { if (typeof sourceMap === 'string') { const parseResult = parseJSON<RawSourceMap>(sourceMap); if (parseResult.isErr()) { return parseResult; } sourceMap = parseResult.data; } const sourceRoot = sourceMap.sourceRoot ? path.resolve(path.dirname(sourceMapPath), sourceMap.sourceRoot) : path.resolve(path.dirname(sourceMapPath)); const succeeded: string[] = []; const skipped: string[] = []; const failed: string[] = []; const sourcesContent: string[] = sourceMap.sourcesContent ?? []; for (let i = 0; i < sourceMap.sources.length; i++) { const sourcePath = sourceMap.sources[i]; if (sourcesContent[i] && !force) { skipped.push(sourcePath); continue; } const readResult = await readFile(path.resolve(sourceRoot, sourcePath)); if (readResult.isErr()) { failed.push(sourcePath); } else { sourcesContent[i] = readResult.data; succeeded.push(sourcePath); } } return Ok({ sourceMap: { ...sourceMap, sourcesContent, }, succeeded, skipped, failed, }); } public doesSourceMapHaveSources(sourceMap: RawSourceMap): boolean { return sourceMap.sources?.length === sourceMap.sourcesContent?.length; } public async offsetSourceMap(sourceMap: RawSourceMap, count: number): Promise<RawSourceMap> { // Each line in sourcemap is separated by a semicolon. // Offsetting source map lines is just done by prepending semicolons const offset = ';'.repeat(count); const mappings = offset + sourceMap.mappings; return { ...sourceMap, mappings }; } }