firebase-tools
Version:
Command-Line Interface for Firebase
271 lines (270 loc) • 9.99 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.CONCURRENCY = void 0;
exports.checkGoogleAppID = checkGoogleAppID;
exports.getAppVersion = getAppVersion;
exports.getGitCommit = getGitCommit;
exports.getPackageVersion = getPackageVersion;
exports.upsertBucket = upsertBucket;
exports.findSourceMapMappings = findSourceMapMappings;
exports.getLinkedSourceMapPath = getLinkedSourceMapPath;
exports.uploadSourceMaps = uploadSourceMaps;
exports.uploadMap = uploadMap;
exports.normalizeFileName = normalizeFileName;
exports.registerSourceMap = registerSourceMap;
const fs = require("fs");
const path = require("path");
const node_child_process_1 = require("node:child_process");
const pLimit = require("p-limit");
const apiv2_1 = require("../apiv2");
const error_1 = require("../error");
const logger_1 = require("../logger");
const utils_1 = require("../utils");
const gcs = require("../gcp/storage");
const archiveFile_1 = require("../archiveFile");
exports.CONCURRENCY = 25;
function checkGoogleAppID(options) {
if (!options.app) {
throw new error_1.FirebaseError("set --app <appId> to a valid Firebase application id, e.g. 1:00000000:android:0000000");
}
}
function getAppVersion(options) {
if (options.appVersion) {
return options.appVersion;
}
const gitCommit = getGitCommit();
if (gitCommit) {
(0, utils_1.logLabeledBullet)("crashlytics", `Using git commit as app version: ${gitCommit}`);
return gitCommit;
}
const packageVersion = getPackageVersion();
if (packageVersion) {
(0, utils_1.logLabeledBullet)("crashlytics", `Using package version as app version: ${packageVersion}`);
return packageVersion;
}
return "unset";
}
function getGitCommit() {
if (!(0, utils_1.commandExistsSync)("git")) {
return undefined;
}
try {
return (0, node_child_process_1.execSync)("git rev-parse HEAD").toString().trim();
}
catch (error) {
return undefined;
}
}
function getPackageVersion() {
if (!(0, utils_1.commandExistsSync)("npm")) {
return undefined;
}
try {
return (0, node_child_process_1.execSync)("npm pkg get version").toString().trim().replaceAll('"', "");
}
catch (error) {
return undefined;
}
}
async function upsertBucket(projectId, projectNumber, options) {
let loc = "US-CENTRAL1";
if (options.bucketLocation) {
loc = options.bucketLocation.toUpperCase();
}
else {
(0, utils_1.logLabeledBullet)("crashlytics", "No Google Cloud Storage bucket location specified. Defaulting to US-CENTRAL1.");
}
const baseName = `firebasecrashlytics-sourcemaps-${projectNumber}-${loc.toLowerCase()}`;
return await gcs.upsertBucket({
product: "crashlytics",
createMessage: `Creating Cloud Storage bucket in ${loc} to store Crashlytics source maps at ${baseName}...`,
projectId,
req: {
baseName,
purposeLabel: `crashlytics-sourcemaps-${loc.toLowerCase()}`,
location: loc,
lifecycle: {
rule: [
{
action: {
type: "Delete",
},
condition: {
age: 30,
},
},
],
},
},
});
}
async function findSourceMapMappings(files, rootDir) {
const jsFiles = files.filter((f) => f.name.endsWith(".js"));
const mapFiles = files.filter((f) => f.name.endsWith(".js.map"));
const mappings = [];
const mapFilePathsSet = new Set(mapFiles.map((f) => f.name));
const mapFilesLinkedInJsComment = new Set();
const limit = pLimit(exports.CONCURRENCY);
const results = await Promise.all(jsFiles.map((jsFile) => limit(async () => {
const mapFilePath = await getLinkedSourceMapPath(jsFile.name);
return { jsFile, mapFilePath };
})));
for (const { jsFile, mapFilePath } of results) {
if (mapFilePath && mapFilePathsSet.has(mapFilePath)) {
mappings.push({
mapFilePath,
obfuscatedFilePath: path.relative(rootDir, path.resolve(jsFile.name)),
});
mapFilesLinkedInJsComment.add(mapFilePath);
}
}
for (const mapFile of mapFiles) {
if (!mapFilesLinkedInJsComment.has(mapFile.name)) {
mappings.push({
mapFilePath: mapFile.name,
obfuscatedFilePath: path.relative(rootDir, path.resolve(mapFile.name)),
});
}
}
return mappings;
}
async function getLinkedSourceMapPath(jsFilePath) {
let fileHandle;
try {
const stat = await fs.promises.stat(jsFilePath);
const size = stat.size;
const bufferSize = Math.min(size, 4096);
if (bufferSize === 0) {
return undefined;
}
fileHandle = await fs.promises.open(jsFilePath, "r");
const buffer = Buffer.alloc(bufferSize);
const { bytesRead } = await fileHandle.read(buffer, 0, bufferSize, size - bufferSize);
const tail = buffer.toString("utf-8", 0, bytesRead);
const regex = /^\/\/\s*[#@]\s*sourceMappingURL=(?<sourceMappingURL>.+)\s*$/m;
const match = regex.exec(tail);
const sourceMappingURL = match?.groups?.sourceMappingURL?.trim();
if (sourceMappingURL) {
return path.join(path.dirname(jsFilePath), sourceMappingURL);
}
}
catch (e) {
logger_1.logger.debug(`Error reading sourceMappingURL from ${jsFilePath}: ${e instanceof Error ? e.message : String(e)}`);
}
finally {
if (fileHandle) {
try {
await fileHandle.close();
}
catch (e) {
}
}
}
return undefined;
}
async function uploadSourceMaps(mappings, request) {
const { projectId, bucketName, appVersion, options } = request;
const limit = pLimit(exports.CONCURRENCY);
const results = await Promise.all(mappings.map((mapping) => limit(async () => {
const uploadRequest = {
projectId,
mappingFile: mapping.mapFilePath,
obfuscatedFilePath: mapping.obfuscatedFilePath,
bucketName,
appVersion,
options,
};
let success = await uploadMap(uploadRequest, 1);
if (!success) {
await new Promise((res) => setTimeout(res, options.retryDelay || 5000));
success = await uploadMap(uploadRequest);
}
return success;
})));
let successCount = 0;
const failedFiles = [];
for (const [i, success] of results.entries()) {
if (success) {
successCount++;
}
else {
failedFiles.push(mappings[i].mapFilePath);
}
}
return {
successCount,
failedFiles,
};
}
async function uploadMap(request, attemptsRemaining = 0) {
const { projectId, mappingFile, obfuscatedFilePath, bucketName, appVersion, options } = request;
const filePath = path.relative(options.projectRoot ?? process.cwd(), mappingFile);
const obfuscatedPath = obfuscatedFilePath
.split(path.sep)
.map((p) => (p === ".next" ? "_next" : p))
.filter((p) => p !== "dev")
.join("/");
const tmpArchive = await (0, archiveFile_1.archiveFile)(filePath, { archivedFileName: "mapping.js.map" });
const appId = options.app || "";
const gcsFile = `${appId}-${appVersion}-${normalizeFileName(obfuscatedPath)}.zip`;
const uid = (0, utils_1.murmurHashV3)(`${appId}-${appVersion}-${obfuscatedPath}`);
const name = `projects/${projectId}/locations/global/mappingFiles/${uid}`;
const stream = fs.createReadStream(tmpArchive);
stream.on("error", (err) => {
logger_1.logger.debug(`Stream error on tmpArchive: ${err instanceof Error ? err.message : String(err)}`);
});
try {
const { bucket, object } = await gcs.uploadObject({
file: gcsFile,
stream,
}, bucketName);
const fileUri = `gs://${bucket}/${object}`;
logger_1.logger.debug(`Uploaded mapping file ${filePath} to ${fileUri}`);
await registerSourceMap({
name,
appId,
version: appVersion,
obfuscatedFilePath: `/${obfuscatedPath}`,
fileUri,
});
logger_1.logger.debug(`Registered mapping file ${filePath}`);
return true;
}
catch (e) {
if (attemptsRemaining === 0) {
(0, utils_1.logLabeledWarning)("crashlytics", `Failed to upload mapping file ${filePath}:\n${e instanceof Error ? e.message : String(e)}`);
}
return false;
}
finally {
stream.destroy();
try {
fs.rmSync(tmpArchive, { force: true });
}
catch (err) {
logger_1.logger.debug(`Failed to delete temporary archive ${tmpArchive}: ${err instanceof Error ? err.message : String(err)}`);
}
}
}
function normalizeFileName(fileName) {
return fileName.replaceAll(/\//g, "-");
}
async function registerSourceMap(sourceMap) {
const client = new apiv2_1.Client({
urlPrefix: "https://firebasetelemetryadmin.googleapis.com",
auth: true,
apiVersion: "v1",
});
try {
await client.patch(sourceMap.name, sourceMap, { queryParams: { allowMissing: "true" } });
logger_1.logger.debug(`Registered source map ${sourceMap.obfuscatedFilePath} with Firebase Telemetry service`);
}
catch (e) {
if (e instanceof error_1.FirebaseError) {
if (e.status === 409) {
return;
}
}
throw new error_1.FirebaseError(`Failed to register source map ${sourceMap.obfuscatedFilePath} with Firebase Telemetry service:\n${e instanceof Error ? e.message : String(e)}`);
}
}