diff --git a/tools/sourcemap-tools/src/SourceProcessor.ts b/tools/sourcemap-tools/src/SourceProcessor.ts index 0d9e4ce2..4d21be90 100644 --- a/tools/sourcemap-tools/src/SourceProcessor.ts +++ b/tools/sourcemap-tools/src/SourceProcessor.ts @@ -1,10 +1,10 @@ -import fs from 'fs'; import path from 'path'; import { BasicSourceMapConsumer, Position, RawSourceMap, SourceMapConsumer, SourceMapGenerator } from 'source-map'; import { DebugIdGenerator } from './DebugIdGenerator'; +import { parseJSON, readFile } from './helpers/common'; import { appendBeforeWhitespaces } from './helpers/stringHelpers'; import { stringToUuid } from './helpers/stringToUuid'; -import { ResultPromise } from './models/AsyncResult'; +import { AsyncResult, ResultPromise } from './models/AsyncResult'; import { Err, Ok, Result } from './models/Result'; export interface ProcessResult { @@ -30,28 +30,13 @@ export class SourceProcessor { } public async isSourceFileProcessed(sourcePath: string): ResultPromise { - const readResult = await this.readFile(sourcePath); - if (readResult.isErr()) { - return readResult; - } - - return Ok(this.isSourceProcessed(readResult.data)); + return AsyncResult.equip(readFile(sourcePath)).then((v) => this.isSourceProcessed(v)).inner; } public async isSourceMapFileProcessed(sourceMapPath: string): ResultPromise { - const readResult = await this.readFile(sourceMapPath); - if (readResult.isErr()) { - return readResult; - } - - let sourcemap: RawSourceMap; - try { - sourcemap = JSON.parse(readResult.data) as RawSourceMap; - } catch (err) { - return Err('failed to parse sourcemap JSON'); - } - - return Ok(this.isSourceMapProcessed(sourcemap)); + return AsyncResult.equip(readFile(sourceMapPath)) + .then(parseJSON) + .then((v) => this.isSourceMapProcessed(v)).inner; } public getSourceMapDebugId(sourceMap: RawSourceMap): Result { @@ -64,24 +49,9 @@ export class SourceProcessor { } public async getSourceMapFileDebugId(sourceMapPath: string): ResultPromise { - const readResult = await this.readFile(sourceMapPath); - if (readResult.isErr()) { - return readResult; - } - - let sourcemap: RawSourceMap; - try { - sourcemap = JSON.parse(readResult.data) as RawSourceMap; - } catch (err) { - return Err('failed to parse sourcemap JSON'); - } - - const debugId = this._debugIdGenerator.getSourceMapDebugId(sourcemap); - if (!debugId) { - return Err('sourcemap does not have a debug ID'); - } - - return Ok(debugId); + return AsyncResult.equip(readFile(sourceMapPath)) + .then(parseJSON) + .then((sourceMap) => this.getSourceMapDebugId(sourceMap)).inner; } /** @@ -139,7 +109,7 @@ export class SourceProcessor { sourceMapPath?: string, debugId?: string, ): ResultPromise { - const sourceReadResult = await this.readFile(sourcePath); + const sourceReadResult = await readFile(sourcePath); if (sourceReadResult.isErr()) { return sourceReadResult; } @@ -154,7 +124,7 @@ export class SourceProcessor { sourceMapPath = path.resolve(path.dirname(sourcePath), match[1]); } - const sourceMapReadResult = await this.readFile(sourceMapPath); + const sourceMapReadResult = await readFile(sourceMapPath); if (sourceMapReadResult.isErr()) { return sourceMapReadResult; } @@ -178,7 +148,11 @@ export class SourceProcessor { sourceMapPath: string, ): ResultPromise { if (typeof sourceMap === 'string') { - sourceMap = JSON.parse(sourceMap) as RawSourceMap; + const parseResult = parseJSON(sourceMap); + if (parseResult.isErr()) { + return parseResult; + } + sourceMap = parseResult.data; } const sourceRoot = sourceMap.sourceRoot @@ -187,7 +161,7 @@ export class SourceProcessor { const sourcesContent: string[] = []; for (const sourcePath of sourceMap.sources) { - const readResult = await this.readFile(path.resolve(sourceRoot, sourcePath)); + const readResult = await readFile(path.resolve(sourceRoot, sourcePath)); if (readResult.isErr()) { return readResult; } @@ -202,7 +176,7 @@ export class SourceProcessor { } public doesSourceMapHaveSources(sourceMap: RawSourceMap): boolean { - return sourceMap.sources.length === sourceMap.sourcesContent?.length; + return sourceMap.sources?.length === sourceMap.sourcesContent?.length; } private async offsetSourceMap( @@ -210,8 +184,15 @@ export class SourceProcessor { fromLine: number, count: number, ): ResultPromise { - const sourceMapObj = typeof sourceMap === 'string' ? (JSON.parse(sourceMap) as RawSourceMap) : sourceMap; - const consumer = (await new SourceMapConsumer(sourceMapObj)) as BasicSourceMapConsumer; + if (typeof sourceMap === 'string') { + const parseResult = parseJSON(sourceMap); + if (parseResult.isErr()) { + return parseResult; + } + sourceMap = parseResult.data; + } + + const consumer = (await new SourceMapConsumer(sourceMap)) as BasicSourceMapConsumer; const newSourceMap = new SourceMapGenerator({ file: consumer.file, sourceRoot: consumer.sourceRoot, @@ -238,14 +219,6 @@ export class SourceProcessor { }); const newSourceMapJson = newSourceMap.toJSON(); - return Ok({ ...sourceMapObj, ...newSourceMapJson }); - } - - private async readFile(file: string): ResultPromise { - try { - return Ok(await fs.promises.readFile(file, 'utf-8')); - } catch (err) { - return Err(`failed to read file: ${err instanceof Error ? err.message : 'unknown error'}`); - } + return Ok({ ...sourceMap, ...newSourceMapJson }); } } diff --git a/tools/sourcemap-tools/src/commands/archiveSourceMaps.ts b/tools/sourcemap-tools/src/commands/archiveSourceMaps.ts new file mode 100644 index 00000000..9e3b43d8 --- /dev/null +++ b/tools/sourcemap-tools/src/commands/archiveSourceMaps.ts @@ -0,0 +1,42 @@ +import fs from 'fs'; +import path from 'path'; +import { SourceProcessor } from '../SourceProcessor'; +import { ZipArchive } from '../ZipArchive'; +import { AsyncResult, ResultPromise } from '../models/AsyncResult'; +import { Ok, Result, flatMap } from '../models/Result'; + +export function archiveSourceMaps(sourceProcessor: SourceProcessor) { + return function archiveSourceMaps(sourceMaps: string[]) { + return AsyncResult.fromValue(sourceMaps) + .then(readDebugIds(sourceProcessor)) + .then(createArchive).inner; + }; +} + +function readDebugIds(sourceProcessor: SourceProcessor) { + return async function readDebugIds(files: string[]): Promise> { + return flatMap( + await Promise.all( + files.map( + (file) => + AsyncResult.equip(sourceProcessor.getSourceMapFileDebugId(file)).then( + (result) => [file, result] as const, + ).inner, + ), + ), + ); + }; +} + +async function createArchive(pathsToArchive: (readonly [string, string])[]): ResultPromise { + const archive = new ZipArchive(); + + for (const [filePath, debugId] of pathsToArchive) { + const fileName = path.basename(filePath); + const readStream = fs.createReadStream(filePath); + archive.append(`${debugId}-${fileName}`, readStream); + } + + await archive.finalize(); + return Ok(archive); +} diff --git a/tools/sourcemap-tools/src/commands/index.ts b/tools/sourcemap-tools/src/commands/index.ts new file mode 100644 index 00000000..9e39a874 --- /dev/null +++ b/tools/sourcemap-tools/src/commands/index.ts @@ -0,0 +1,5 @@ +export * from './archiveSourceMaps'; +export * from './processAndUploadAssetsCommand'; +export * from './processAsset'; +export * from './uploadArchive'; +export * from './writeAsset'; diff --git a/tools/sourcemap-tools/src/commands/processAndUploadAssetsCommand.ts b/tools/sourcemap-tools/src/commands/processAndUploadAssetsCommand.ts new file mode 100644 index 00000000..4001ebb6 --- /dev/null +++ b/tools/sourcemap-tools/src/commands/processAndUploadAssetsCommand.ts @@ -0,0 +1,109 @@ +import { DebugIdGenerator } from '../DebugIdGenerator'; +import { SourceProcessor } from '../SourceProcessor'; +import { SymbolUploader, SymbolUploaderOptions, UploadResult } from '../SymbolUploader'; +import { ZipArchive } from '../ZipArchive'; +import { inspect, pass } from '../helpers/common'; +import { Asset } from '../models/Asset'; +import { AsyncResult } from '../models/AsyncResult'; +import { ProcessAssetError, ProcessAssetResult } from '../models/ProcessAssetResult'; +import { Result, flatMap, isErr } from '../models/Result'; +import { archiveSourceMaps } from './archiveSourceMaps'; +import { processAsset } from './processAsset'; +import { uploadArchive } from './uploadArchive'; +import { writeAsset } from './writeAsset'; + +export interface BacktracePluginOptions { + /** + * Upload URL for uploading sourcemap files. + * See Source Maps Integration Guide for your instance for more information. + * + * If not set, the sourcemaps will not be uploaded. The sources will be still processed and ready for manual upload. + */ + readonly uploadUrl?: string | URL; + + /** + * Additional upload options. + */ + readonly uploadOptions?: SymbolUploaderOptions; +} + +interface ProcessResult { + readonly assetResults: Result[]; + readonly uploadResult?: Result; +} + +export interface ProcessAndUploadAssetsCommandOptions { + beforeAll?(assets: Asset[]): unknown; + afterAll?(result: ProcessResult): unknown; + beforeProcess?(asset: Asset): unknown; + afterProcess?(asset: ProcessAssetResult): unknown; + beforeWrite?(asset: ProcessAssetResult): unknown; + afterWrite?(asset: ProcessAssetResult): unknown; + assetFinished?(asset: ProcessAssetResult): unknown; + beforeArchive?(paths: string[]): void; + afterArchive?(archive: ZipArchive): void; + beforeUpload?(archive: ZipArchive): void; + afterUpload?(result: UploadResult): void; + assetError?(error: ProcessAssetError): void; + uploadError?(error: string): void; +} + +export function processAndUploadAssetsCommand( + pluginOptions: BacktracePluginOptions, + options?: ProcessAndUploadAssetsCommandOptions, +) { + const sourceProcessor = new SourceProcessor(new DebugIdGenerator()); + const sourceMapUploader = pluginOptions?.uploadUrl + ? new SymbolUploader(pluginOptions.uploadUrl, pluginOptions.uploadOptions) + : undefined; + + const processCommand = processAsset(sourceProcessor); + const archiveCommand = archiveSourceMaps(sourceProcessor); + const uploadCommand = sourceMapUploader ? uploadArchive(sourceMapUploader) : undefined; + + return async function processAndUploadAssets(assets: Asset[]): Promise { + options?.beforeAll && options.beforeAll(assets); + + const assetResults = await Promise.all( + assets.map( + (asset) => + AsyncResult.fromValue(asset) + .then(options?.beforeProcess ? inspect(options.beforeProcess) : pass) + .then(processCommand) + .then(options?.afterProcess ? inspect(options.afterProcess) : pass) + .then(options?.beforeWrite ? inspect(options.beforeWrite) : pass) + .then(writeAsset) + .then(options?.afterWrite ? inspect(options.afterWrite) : pass) + .then(options?.assetFinished ? inspect(options.assetFinished) : pass) + .thenErr(options?.assetError ? inspect(options.assetError) : pass).inner, + ), + ); + + const assetsResult = flatMap(assetResults); + if (isErr(assetsResult)) { + const result: ProcessResult = { assetResults }; + options?.afterAll && options.afterAll(result); + return result; + } + + if (!uploadCommand) { + const result: ProcessResult = { assetResults }; + options?.afterAll && options.afterAll(result); + return result; + } + + const sourceMapPaths = assetsResult.data.map((r) => r.result.sourceMapPath); + const uploadResult = await AsyncResult.fromValue(sourceMapPaths) + .then(options?.beforeArchive ? inspect(options.beforeArchive) : pass) + .then(archiveCommand) + .then(options?.afterArchive ? inspect(options.afterArchive) : pass) + .then(options?.beforeUpload ? inspect(options.beforeUpload) : pass) + .then(uploadCommand) + .then(options?.afterUpload ? inspect(options.afterUpload) : pass) + .thenErr(options?.uploadError ? inspect(options.uploadError) : pass).inner; + + const result: ProcessResult = { assetResults, uploadResult }; + options?.afterAll && options.afterAll(result); + return result; + }; +} diff --git a/tools/sourcemap-tools/src/commands/processAsset.ts b/tools/sourcemap-tools/src/commands/processAsset.ts new file mode 100644 index 00000000..562f268c --- /dev/null +++ b/tools/sourcemap-tools/src/commands/processAsset.ts @@ -0,0 +1,13 @@ +import { SourceProcessor } from '../SourceProcessor'; +import { Asset } from '../models/Asset'; +import { AsyncResult } from '../models/AsyncResult'; +import { ProcessAssetError, ProcessAssetResult } from '../models/ProcessAssetResult'; +import { Result } from '../models/Result'; + +export function processAsset(sourceProcessor: SourceProcessor) { + return function processAsset(asset: Asset): Promise> { + return AsyncResult.equip(sourceProcessor.processSourceAndSourceMapFiles(asset.path)) + .then((result) => ({ asset, result })) + .thenErr((error) => ({ asset, error })).inner; + }; +} diff --git a/tools/sourcemap-tools/src/commands/uploadArchive.ts b/tools/sourcemap-tools/src/commands/uploadArchive.ts new file mode 100644 index 00000000..07bfe073 --- /dev/null +++ b/tools/sourcemap-tools/src/commands/uploadArchive.ts @@ -0,0 +1,8 @@ +import { SymbolUploader } from '../SymbolUploader'; +import { ZipArchive } from '../ZipArchive'; + +export function uploadArchive(symbolUploader: SymbolUploader) { + return function uploadArchive(archive: ZipArchive) { + return symbolUploader.uploadSymbol(archive); + }; +} diff --git a/tools/sourcemap-tools/src/commands/writeAsset.ts b/tools/sourcemap-tools/src/commands/writeAsset.ts new file mode 100644 index 00000000..9c044de4 --- /dev/null +++ b/tools/sourcemap-tools/src/commands/writeAsset.ts @@ -0,0 +1,12 @@ +import { writeFile } from '../helpers/common'; +import { AsyncResult } from '../models/AsyncResult'; +import { ProcessAssetError, ProcessAssetResult } from '../models/ProcessAssetResult'; + +export function writeAsset(result: ProcessAssetResult) { + const { source, sourcePath: path, sourceMap, sourceMapPath } = result.result; + + return AsyncResult.equip(writeFile([source, path])) + .then(() => writeFile([JSON.stringify(sourceMap), sourceMapPath])) + .then(() => result) + .thenErr((error) => ({ asset: result.asset, error })).inner; +} diff --git a/tools/sourcemap-tools/src/helpers/common.ts b/tools/sourcemap-tools/src/helpers/common.ts new file mode 100644 index 00000000..4fbab1ed --- /dev/null +++ b/tools/sourcemap-tools/src/helpers/common.ts @@ -0,0 +1,86 @@ +import fs from 'fs'; +import { Readable } from 'stream'; +import { Logger, LogLevel } from '../Logger'; +import { ResultPromise } from '../models/AsyncResult'; +import { Err, Ok, Result } from '../models/Result'; + +export type ContentFile = readonly [content: string, path: string]; +export type StreamFile = readonly [stream: Readable, path: string]; + +export async function readFile(file: string): ResultPromise { + try { + return Ok(await fs.promises.readFile(file, 'utf-8')); + } catch (err) { + return Err(`failed to read file: ${err instanceof Error ? err.message : 'unknown error'}`); + } +} + +export async function writeFile(file: ContentFile) { + const [content, path] = file; + try { + await fs.promises.writeFile(path, content); + return Ok(file); + } catch (err) { + return Err(`failed to write file: ${err instanceof Error ? err.message : 'unknown error'}`); + } +} + +export async function writeStream(file: StreamFile) { + const [stream, path] = file; + try { + const output = fs.createWriteStream(path); + stream.pipe(output); + return new Promise>((resolve) => { + output.on('error', (err) => { + resolve(Err(`failed to write file: ${err.message}`)); + }); + + output.on('finish', () => resolve(Ok(file))); + }); + } catch (err) { + return Err(`failed to write file: ${err instanceof Error ? err.message : 'unknown error'}`); + } +} + +export function parseJSON(content: string): Result { + try { + return Ok(JSON.parse(content)); + } catch (err) { + return Err(`failed to parse content: ${err instanceof Error ? err.message : 'unknown error'}`); + } +} + +export function pass(t: T): T { + return t; +} + +export function failIfEmpty(error: E) { + return function failIfEmpty(t: T[]): Result { + return t.length ? Ok(t) : Err(error); + }; +} + +export function map(fn: (t: T) => B) { + return function map(t: T[]) { + return t.map(fn); + }; +} + +export function filter(fn: (t: T) => boolean) { + return function filter(t: T[]) { + return t.filter(fn); + }; +} + +export function log(logger: Logger, level: LogLevel) { + return function log(message: string | ((t: T) => string)) { + return inspect((t) => logger[level](typeof message === 'function' ? message(t) : message)); + }; +} + +export function inspect(fn: (t: T) => unknown) { + return function inspect(t: T): T { + fn(t); + return t; + }; +} diff --git a/tools/sourcemap-tools/src/helpers/match.ts b/tools/sourcemap-tools/src/helpers/match.ts new file mode 100644 index 00000000..b1403cf1 --- /dev/null +++ b/tools/sourcemap-tools/src/helpers/match.ts @@ -0,0 +1,8 @@ +function testString(regex: RegExp) { + return function test(str: string) { + return regex.test(str); + }; +} + +export const matchSourceExtension = testString(/\.(c|m)?jsx?$/); +export const matchSourceMapExtension = testString(/\.(c|m)?jsx?\.map$/); diff --git a/tools/sourcemap-tools/src/index.ts b/tools/sourcemap-tools/src/index.ts index 8c861e3b..1210897a 100644 --- a/tools/sourcemap-tools/src/index.ts +++ b/tools/sourcemap-tools/src/index.ts @@ -4,5 +4,10 @@ export * from './Logger'; export * from './SourceProcessor'; export * from './SymbolUploader'; export * from './ZipArchive'; +export * from './commands'; +export * from './helpers/common'; +export * from './helpers/match'; +export * from './models/Asset'; export * from './models/AsyncResult'; +export * from './models/ProcessAssetResult'; export * from './models/Result'; diff --git a/tools/sourcemap-tools/src/models/Asset.ts b/tools/sourcemap-tools/src/models/Asset.ts new file mode 100644 index 00000000..d209d19a --- /dev/null +++ b/tools/sourcemap-tools/src/models/Asset.ts @@ -0,0 +1,4 @@ +export interface Asset { + readonly name: string; + readonly path: string; +} diff --git a/tools/sourcemap-tools/src/models/AsyncResult.ts b/tools/sourcemap-tools/src/models/AsyncResult.ts index b81f593a..4b6be11e 100644 --- a/tools/sourcemap-tools/src/models/AsyncResult.ts +++ b/tools/sourcemap-tools/src/models/AsyncResult.ts @@ -1,4 +1,4 @@ -import { Result, ResultErr, ResultOk, flatMap, wrapErr, wrapOk } from './Result'; +import { Ok, Result, ResultErr, ResultOk, flatMap, wrapErr, wrapOk } from './Result'; export type ResultPromise = Promise>; @@ -24,8 +24,19 @@ export class AsyncResult { return new AsyncResult(new Promise((resolve) => resolve(fnResult))); } + public static fromValue(value: T) { + return new AsyncResult(new Promise((resolve) => resolve(Ok(value)))); + } + public then( - transform: (data: T) => Result[] | Promise[]> | Promise>[], + transform: ( + data: T, + ) => + | Result + | Result[] + | Promise> + | Promise[]> + | Promise>[], ): AsyncResult; public then(transform: (data: T) => Result | Promise>): AsyncResult; public then(transform: (data: T) => N | Promise): AsyncResult; @@ -33,11 +44,11 @@ export class AsyncResult { transform: ( data: T, ) => - | Result - | Promise> - | Promise>[] - | Result[] - | Promise[]> + | Result + | Promise[]> + | Promise>[] + | Result[] + | Promise> | N | Promise, ): AsyncResult { @@ -72,7 +83,7 @@ export class AsyncResult { public thenErr(transform: (data: E) => Promise): AsyncResult; public thenErr(transform: (data: E) => N): AsyncResult; public thenErr(transform: (data: E) => Result | N | Promise>): AsyncResult; - public thenErr(transform: (data: E) => Result | N | Promise>): AsyncResult { + public thenErr(transform: (data: E) => Result | N | Promise>): AsyncResult { return new AsyncResult( this._promise.then((result) => { if (!result.isErr()) { diff --git a/tools/sourcemap-tools/src/models/ProcessAssetResult.ts b/tools/sourcemap-tools/src/models/ProcessAssetResult.ts new file mode 100644 index 00000000..30c59543 --- /dev/null +++ b/tools/sourcemap-tools/src/models/ProcessAssetResult.ts @@ -0,0 +1,12 @@ +import { ProcessResultWithPaths } from '../SourceProcessor'; +import { Asset } from './Asset'; + +export interface ProcessAssetResult { + readonly asset: Asset; + readonly result: ProcessResultWithPaths; +} + +export interface ProcessAssetError { + readonly asset: Asset; + readonly error: string; +}