1import { Platform } from '@expo/config';
2import crypto from 'crypto';
3import fs from 'fs/promises';
4import path from 'path';
5
6import { createMetadataJson } from './createMetadataJson';
7import { BundleOutput } from './fork-bundleAsync';
8import { Asset } from './saveAssets';
9
10const debug = require('debug')('expo:export:write') as typeof console.log;
11
12/**
13 * @param props.platform native platform for the bundle
14 * @param props.format extension to use for the name
15 * @param props.hash crypto hash for the bundle contents
16 * @returns filename for the JS bundle.
17 */
18function createBundleFileName({
19  platform,
20  format,
21  hash,
22}: {
23  platform: string;
24  format: 'javascript' | 'bytecode';
25  hash: string;
26}): string {
27  return `${platform}-${hash}.${format === 'javascript' ? 'js' : 'hbc'}`;
28}
29
30/**
31 * @param bundle JS bundle as a string
32 * @returns crypto hash for the provided bundle
33 */
34function createBundleHash(bundle: string | Uint8Array): string {
35  return crypto.createHash('md5').update(bundle).digest('hex');
36}
37
38export async function writeBundlesAsync({
39  bundles,
40  outputDir,
41}: {
42  bundles: Partial<Record<Platform, Pick<BundleOutput, 'hermesBytecodeBundle' | 'code'>>>;
43  outputDir: string;
44}) {
45  const hashes: Partial<Record<Platform, string>> = {};
46  const fileNames: Partial<Record<Platform, string>> = {};
47
48  for (const [platform, bundleOutput] of Object.entries(bundles) as [
49    Platform,
50    Pick<BundleOutput, 'hermesBytecodeBundle' | 'code'>
51  ][]) {
52    const bundle = bundleOutput.hermesBytecodeBundle ?? bundleOutput.code;
53    const hash = createBundleHash(bundle);
54    const fileName = createBundleFileName({
55      platform,
56      format: bundleOutput.hermesBytecodeBundle ? 'bytecode' : 'javascript',
57      hash,
58    });
59
60    hashes[platform] = hash;
61    fileNames[platform] = fileName;
62    await fs.writeFile(path.join(outputDir, fileName), bundle);
63  }
64
65  return { hashes, fileNames };
66}
67
68type SourceMapWriteResult = {
69  platform: string;
70  fileName: string;
71  hash: string;
72  map: string;
73  comment: string;
74};
75
76export async function writeSourceMapsAsync({
77  bundles,
78  hashes,
79  fileNames,
80  outputDir,
81}: {
82  bundles: Record<
83    string,
84    Pick<BundleOutput, 'hermesSourcemap' | 'map' | 'hermesBytecodeBundle' | 'code'>
85  >;
86  hashes?: Record<string, string>;
87  fileNames?: Record<string, string>;
88  outputDir: string;
89}): Promise<SourceMapWriteResult[]> {
90  return (
91    await Promise.all(
92      Object.entries(bundles).map(async ([platform, bundle]) => {
93        const sourceMap = bundle.hermesSourcemap ?? bundle.map;
94        if (!sourceMap) {
95          debug(`Skip writing sourcemap (platform: ${platform})`);
96          return null;
97        }
98
99        const hash =
100          hashes?.[platform] ?? createBundleHash(bundle.hermesBytecodeBundle ?? bundle.code);
101        const mapName = `${platform}-${hash}.map`;
102        await fs.writeFile(path.join(outputDir, mapName), sourceMap);
103
104        const jsBundleFileName =
105          fileNames?.[platform] ??
106          createBundleFileName({
107            platform,
108            format: bundle.hermesBytecodeBundle ? 'bytecode' : 'javascript',
109            hash,
110          });
111        const jsPath = path.join(outputDir, jsBundleFileName);
112
113        // Add correct mapping to sourcemap paths
114        const mappingComment = `\n//# sourceMappingURL=${mapName}`;
115        await fs.appendFile(jsPath, mappingComment);
116        return {
117          platform,
118          fileName: mapName,
119          hash,
120          map: sourceMap,
121          comment: mappingComment,
122        };
123      })
124    )
125  ).filter(Boolean) as SourceMapWriteResult[];
126}
127
128export async function writeMetadataJsonAsync({
129  outputDir,
130  bundles,
131  fileNames,
132}: {
133  outputDir: string;
134  bundles: Record<string, Pick<BundleOutput, 'assets'>>;
135  fileNames: Record<string, string>;
136}) {
137  const contents = createMetadataJson({
138    bundles,
139    fileNames,
140  });
141  const metadataPath = path.join(outputDir, 'metadata.json');
142  debug(`Writing metadata.json to ${metadataPath}`);
143  await fs.writeFile(metadataPath, JSON.stringify(contents));
144  return contents;
145}
146
147export async function writeAssetMapAsync({
148  outputDir,
149  assets,
150}: {
151  outputDir: string;
152  assets: Asset[];
153}) {
154  // Convert the assets array to a k/v pair where the asset hash is the key and the asset is the value.
155  const contents = Object.fromEntries(assets.map((asset) => [asset.hash, asset]));
156  await fs.writeFile(path.join(outputDir, 'assetmap.json'), JSON.stringify(contents));
157  return contents;
158}
159
160export async function writeDebugHtmlAsync({
161  outputDir,
162  fileNames,
163}: {
164  outputDir: string;
165  fileNames: Record<string, string>;
166}) {
167  // Make a debug html so user can debug their bundles
168  const contents = `
169      ${Object.values(fileNames)
170        .map((fileName) => `<script src="${path.join('bundles', fileName)}"></script>`)
171        .join('\n      ')}
172      Open up this file in Chrome. In the JavaScript developer console, navigate to the Source tab.
173      You can see a red colored folder containing the original source code from your bundle.
174      `;
175
176  await fs.writeFile(path.join(outputDir, 'debug.html'), contents);
177  return contents;
178}
179