diff --git a/.gitignore b/.gitignore index 8887207..96375a0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ node_modules ../api/node_modules /bin/ /___automation___.npmrc___ +.DS_Store diff --git a/script/react-native-utils.ts b/script/react-native-utils.ts index eca3e43..dd2c518 100644 --- a/script/react-native-utils.ts +++ b/script/react-native-utils.ts @@ -5,6 +5,7 @@ import * as childProcess from "child_process"; import { valid } from "semver"; import { downloadBlob, extractArchive } from "./utils/file-utils"; import { findHermesc, isBaseBytecodeCompatible } from "./utils/hermes-utils"; +import { restoreSourceMapMetadata } from "./utils/source-map-utils"; import * as dotenv from "dotenv"; import * as cli from "./types/cli"; import { log, sdk } from "./command-executor"; @@ -109,9 +110,13 @@ async function composeHermesSourceMaps( outputFolder: string, sourcemapOutputFolder: string ): Promise { - const composeSourceMaps = path.join(getReactNativePackagePath(), "scripts", "compose-source-maps.js"); + let composeSourceMaps = path.join(getReactNativePackagePath(), "scripts", "compose-source-maps.js"); + // On iOS, react-native-xcode.sh lets COMPOSE_SOURCEMAP_PATH replace RN's script; an empty value is ignored + if (command.platform === "ios") { + composeSourceMaps = getXcodeDotEnvValue("COMPOSE_SOURCEMAP_PATH") || composeSourceMaps; + } if (!fs.existsSync(composeSourceMaps)) { - throw new Error("react-native compose-source-maps.js script is not found"); + throw new Error(`Source map compose script ${composeSourceMaps} is not found`); } const hermesSourceMap = path.join(outputFolder, bundleName + ".hbc.map"); @@ -126,7 +131,13 @@ async function composeHermesSourceMaps( combinedSourceMap = command.platform === "android" ? path.join(sourcemapOutputFolder, bundleName + ".map") : packagerSourceMap; } + // Read before composing: on iOS the composed map overwrites the packager map + const packagerSourceMapJson = JSON.parse(await fs.promises.readFile(packagerSourceMap, "utf8")); await runCommand("compose-source-maps", "node", [composeSourceMaps, packagerSourceMap, hermesSourceMap, "-o", combinedSourceMap]); + const restoredFields = await restoreSourceMapMetadata(combinedSourceMap, packagerSourceMapJson); + if (restoredFields.length > 0) { + console.log(`Restored packager source map metadata: ${restoredFields.join(", ")}`); + } // Not part of the release await fs.promises.unlink(hermesSourceMap); } diff --git a/script/utils/source-map-utils.ts b/script/utils/source-map-utils.ts new file mode 100644 index 0000000..2537244 --- /dev/null +++ b/script/utils/source-map-utils.ts @@ -0,0 +1,37 @@ +import * as fs from "fs"; + +// Fields tied to the bundle layout, which compose rewrites; packager values would point at the wrong sources +const POSITIONAL_FIELDS = new Set([ + "version", + "file", + "sourceRoot", + "sources", + "sourcesContent", + "names", + "mappings", + "ignoreList", + "sections", + "x_google_ignoreList", + "x_facebook_sources", + "x_facebook_offsets", + "x_facebook_segments", + "x_hermes_function_offsets", +]); + +/** compose-source-maps.js drops fields it doesn't know, such as `debugId`. Copies them back; returns their names. */ +export async function restoreSourceMapMetadata( + composedSourceMap: string, + packagerSourceMap: Record +): Promise { + const fields = Object.keys(packagerSourceMap).filter((field) => !POSITIONAL_FIELDS.has(field)); + // Skip reading the (possibly huge) composed map when there is nothing to copy + if (fields.length === 0) return []; + + const composed = JSON.parse(await fs.promises.readFile(composedSourceMap, "utf8")); + const restored = fields.filter((field) => !Object.hasOwn(composed, field)); + if (restored.length === 0) return []; + + for (const field of restored) composed[field] = packagerSourceMap[field]; + await fs.promises.writeFile(composedSourceMap, JSON.stringify(composed)); + return restored; +} diff --git a/test/source-map-utils.ts b/test/source-map-utils.ts new file mode 100644 index 0000000..9974862 --- /dev/null +++ b/test/source-map-utils.ts @@ -0,0 +1,76 @@ +import * as assert from "assert"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { restoreSourceMapMetadata } from "../script/utils/source-map-utils"; + +describe("Source map metadata", () => { + let testDirectory: string; + + beforeEach(() => { + testDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "revopush-source-map-")); + }); + + afterEach(() => { + fs.rmSync(testDirectory, { recursive: true, force: true }); + }); + + function createSourceMap(fields: Record): string { + const sourceMap = path.join(testDirectory, "composed.map"); + fs.writeFileSync(sourceMap, JSON.stringify(fields)); + return sourceMap; + } + + function readSourceMap(sourceMap: string): unknown { + return JSON.parse(fs.readFileSync(sourceMap, "utf8")); + } + + it("restores fields the composer dropped without touching the ones it produced", async () => { + const composed = createSourceMap({ version: 3, file: "main.jsbundle.hbc", mappings: "AAAA;AACA", x_google_ignoreList: [] }); + + const restored = await restoreSourceMapMetadata(composed, { + version: 3, + file: "main.jsbundle", + mappings: "AAAA;ACAA", + debugId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + x_google_ignoreList: [1], + x_custom_vendor: { nested: true }, + }); + + assert.deepStrictEqual(restored, ["debugId", "x_custom_vendor"]); + assert.deepStrictEqual(readSourceMap(composed), { + version: 3, + file: "main.jsbundle.hbc", + mappings: "AAAA;AACA", + x_google_ignoreList: [], + debugId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + x_custom_vendor: { nested: true }, + }); + }); + + it("never restores positional fields, which index into the packager's sources", async () => { + const composed = createSourceMap({ version: 3, sources: ["/abs/a.js"], mappings: "AAAA" }); + + const restored = await restoreSourceMapMetadata(composed, { + sourceRoot: "/abs/", + sections: [], + ignoreList: [1], + x_google_ignoreList: [1], + x_facebook_sources: [null], + x_hermes_function_offsets: { "0": [0] }, + debugId: "aaaa-bbbb", + }); + + assert.deepStrictEqual(restored, ["debugId"]); + assert.deepStrictEqual(readSourceMap(composed), { version: 3, sources: ["/abs/a.js"], mappings: "AAAA", debugId: "aaaa-bbbb" }); + }); + + it("leaves the composed source map untouched when there is nothing to restore", async () => { + const composed = createSourceMap({ version: 3, file: "main.jsbundle.hbc", debugId: "from-compose" }); + const contents = fs.readFileSync(composed, "utf8"); + + assert.deepStrictEqual(await restoreSourceMapMetadata(composed, { version: 3 }), []); + assert.deepStrictEqual(await restoreSourceMapMetadata(composed, { debugId: "from-packager" }), []); + assert.strictEqual(fs.readFileSync(composed, "utf8"), contents); + }); +});