1import * as NodeFS from "node:fs";
2import * as NodePath from "node:path";
3import { mobileSourceMapKey, type MobilePlatform } from "@t3tools/shared/mobileSourceMaps";
4import { AwsClient } from "aws4fetch";
5
6export interface SourceMapUpload {
7 readonly key: string;
8 readonly path: string;
9}
10
11/** The `MOBILE_SOURCEMAPS_R2` secret: the relay stack's `mobileSourceMapsUploadCredentials`. */
12interface UploadCredentials {
13 readonly endpoint: string;
14 readonly bucketName: string;
15 readonly accessKeyId: string;
16 readonly secretAccessKey: string;
17}
18
19const isPlatform = (value: unknown): value is MobilePlatform =>
20 value === "ios" || value === "android";
21
22const readJson = (path: string): unknown => JSON.parse(NodeFS.readFileSync(path, "utf8"));
23
24const isRecord = (value: unknown): value is Record<string, unknown> =>
25 typeof value === "object" && value !== null && !Array.isArray(value);
26
27export function parseCredentials(value: string | undefined): UploadCredentials | undefined {
28 if (!value?.trim()) return undefined;
29 let parsed: unknown;
30 try {
31 parsed = JSON.parse(value);
32 } catch {
33 // JSON.parse's message quotes the input, which here is the secret.
34 throw new Error("MOBILE_SOURCEMAPS_R2 is not valid JSON.");
35 }
36 if (!isRecord(parsed)) throw new Error("MOBILE_SOURCEMAPS_R2 is not a JSON object.");
37 const { endpoint, bucketName, accessKeyId, secretAccessKey } = parsed;
38 if (
39 typeof endpoint !== "string" ||
40 typeof bucketName !== "string" ||
41 typeof accessKeyId !== "string" ||
42 typeof secretAccessKey !== "string"
43 ) {
44 throw new Error(
45 "MOBILE_SOURCEMAPS_R2 needs endpoint, bucketName, accessKeyId and secretAccessKey.",
46 );
47 }
48 return { endpoint, bucketName, accessKeyId, secretAccessKey };
49}
50
51/** One map per update `eas update --emit-metadata` published, next to the bundle `metadata.json` names. */
52export function otaSourceMapUploads(distDir: string): ReadonlyArray<SourceMapUpload> {
53 const metadata = readJson(NodePath.join(distDir, "metadata.json"));
54 const easMetadata = readJson(NodePath.join(distDir, "eas-update-metadata.json"));
55 const fileMetadata = isRecord(metadata) ? metadata.fileMetadata : undefined;
56 const updates = isRecord(easMetadata) ? easMetadata.updates : undefined;
57 if (!isRecord(fileMetadata) || !Array.isArray(updates)) {
58 throw new Error(`${distDir} is missing Expo export or EAS update metadata.`);
59 }
60 return updates.flatMap((update): Array<SourceMapUpload> => {
61 if (!isRecord(update) || !isPlatform(update.platform) || typeof update.id !== "string") {
62 return [];
63 }
64 const platformFiles = fileMetadata[update.platform];
65 const bundle = isRecord(platformFiles) ? platformFiles.bundle : undefined;
66 if (typeof bundle !== "string") return [];
67 return [
68 {
69 key: mobileSourceMapKey(update.platform, "ota", update.id),
70 path: NodePath.join(distDir, `${bundle}.map`),
71 },
72 ];
73 });
74}
75
76/** Android's Gradle plugin writes the map under the release variant; iOS to `SOURCEMAP_FILE` (eas.json). */
77export function buildSourceMapUpload(
78 env: Readonly<Record<string, string | undefined>>,
79 projectDir: string,
80): SourceMapUpload {
81 const platform = env.EAS_BUILD_PLATFORM;
82 const buildId = env.EAS_BUILD_ID;
83 if (!isPlatform(platform) || !buildId) {
84 throw new Error("EAS_BUILD_PLATFORM and EAS_BUILD_ID are only set on EAS Build.");
85 }
86 if (platform === "ios") {
87 if (!env.SOURCEMAP_FILE) throw new Error("SOURCEMAP_FILE is not set for this iOS build.");
88 return { key: mobileSourceMapKey("ios", "build", buildId), path: env.SOURCEMAP_FILE };
89 }
90 const variantsDir = NodePath.join(projectDir, "android/app/build/generated/sourcemaps/react");
91 const variant = NodeFS.existsSync(variantsDir)
92 ? NodeFS.readdirSync(variantsDir).find((name) => /release$/iu.test(name))
93 : undefined;
94 if (!variant) throw new Error(`No release source map under ${variantsDir}.`);
95 return {
96 key: mobileSourceMapKey("android", "build", buildId),
97 path: NodePath.join(variantsDir, variant, "index.android.bundle.map"),
98 };
99}
100
101/**
102 * EAS writes the app's debug symbols next to the `.ipa`, so native frames in iOS crash
103 * reports can be read with `atos`. Undefined when the build left none.
104 */
105export function buildDsymUpload(
106 env: Readonly<Record<string, string | undefined>>,
107 projectDir: string,
108): SourceMapUpload | undefined {
109 if (env.EAS_BUILD_PLATFORM !== "ios" || !env.EAS_BUILD_ID) return undefined;
110 const buildDir = NodePath.join(projectDir, "ios/build");
111 const dsym = NodeFS.existsSync(buildDir)
112 ? NodeFS.readdirSync(buildDir).find((name) => name.endsWith(".app.dSYM.zip"))
113 : undefined;
114 if (!dsym) return undefined;
115 return { key: `ios/dsym/${env.EAS_BUILD_ID}.zip`, path: NodePath.join(buildDir, dsym) };
116}
117
118/** Only what the relay reads: source text stays out of the bucket, and the rest is dead weight. */
119function stripToMappings(text: string): string {
120 const map: unknown = JSON.parse(text);
121 if (!isRecord(map)) throw new Error("Source map is not a JSON object.");
122 const { version, sources, sourceRoot, mappings } = map;
123 return JSON.stringify({ version, sources, sourceRoot, mappings });
124}
125
126export function uploadSourceMap(
127 credentials: UploadCredentials,
128 upload: SourceMapUpload,
129): Promise<number> {
130 return putObject(
131 credentials,
132 upload.key,
133 stripToMappings(NodeFS.readFileSync(upload.path, "utf8")),
134 "application/json",
135 );
136}
137
138export function uploadDsym(credentials: UploadCredentials, upload: SourceMapUpload) {
139 const body = new Uint8Array(NodeFS.readFileSync(upload.path));
140 return putObject(credentials, upload.key, body, "application/zip");
141}
142
143async function putObject(
144 credentials: UploadCredentials,
145 key: string,
146 body: string | Uint8Array<ArrayBuffer>,
147 contentType: string,
148): Promise<number> {
149 const client = new AwsClient({
150 accessKeyId: credentials.accessKeyId,
151 secretAccessKey: credentials.secretAccessKey,
152 service: "s3",
153 region: "auto",
154 });
155 const url = `${credentials.endpoint.replace(/\/$/u, "")}/${credentials.bucketName}/${key}`;
156 const response = await client.fetch(url, {
157 method: "PUT",
158 body,
159 headers: { "content-type": contentType },
160 });
161 if (!response.ok) {
162 throw new Error(`R2 answered ${response.status}: ${(await response.text()).slice(0, 500)}`);
163 }
164 return body.length;
165}