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