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  useWebSSG,
42}: {
43  bundles: Partial<Record<Platform, Pick<BundleOutput, 'hermesBytecodeBundle' | 'code'>>>;
44  outputDir: string;
45  useWebSSG?: boolean;
46}) {
47  const hashes: Partial<Record<Platform, string>> = {};
48  const fileNames: Partial<Record<Platform, string>> = {};
49
50  for (const [platform, bundleOutput] of Object.entries(bundles) as [
51    Platform,
52    Pick<BundleOutput, 'hermesBytecodeBundle' | 'code'>,
53  ][]) {
54    // TODO: Move native to use the newer `_expo/...` bundle writing system.
55    if (platform === 'web' && useWebSSG) {
56      continue;
57    }
58    const bundle = bundleOutput.hermesBytecodeBundle ?? bundleOutput.code;
59    const hash = createBundleHash(bundle);
60    const fileName = createBundleFileName({
61      platform,
62      format: bundleOutput.hermesBytecodeBundle ? 'bytecode' : 'javascript',
63      hash,
64    });
65
66    hashes[platform] = hash;
67    fileNames[platform] = fileName;
68    await fs.writeFile(path.join(outputDir, fileName), bundle);
69  }
70
71  return { hashes, fileNames };
72}
73
74type SourceMapWriteResult = {
75  platform: string;
76  fileName: string;
77  hash: string;
78  map: string;
79  comment: string;
80};
81
82export async function writeSourceMapsAsync({
83  bundles,
84  hashes,
85  fileNames,
86  outputDir,
87}: {
88  bundles: Record<
89    string,
90    Pick<BundleOutput, 'hermesSourcemap' | 'map' | 'hermesBytecodeBundle' | 'code'>
91  >;
92  hashes?: Record<string, string>;
93  fileNames?: Record<string, string>;
94  outputDir: string;
95}): Promise<SourceMapWriteResult[]> {
96  return (
97    await Promise.all(
98      Object.entries(bundles).map(async ([platform, bundle]) => {
99        const sourceMap = bundle.hermesSourcemap ?? bundle.map;
100        if (!sourceMap) {
101          debug(`Skip writing sourcemap (platform: ${platform})`);
102          return null;
103        }
104
105        const hash =
106          hashes?.[platform] ?? createBundleHash(bundle.hermesBytecodeBundle ?? bundle.code);
107        const mapName = `${platform}-${hash}.map`;
108        await fs.writeFile(path.join(outputDir, mapName), sourceMap);
109
110        const jsBundleFileName =
111          fileNames?.[platform] ??
112          createBundleFileName({
113            platform,
114            format: bundle.hermesBytecodeBundle ? 'bytecode' : 'javascript',
115            hash,
116          });
117        const jsPath = path.join(outputDir, jsBundleFileName);
118
119        // Add correct mapping to sourcemap paths
120        const mappingComment = `\n//# sourceMappingURL=${mapName}`;
121        await fs.appendFile(jsPath, mappingComment);
122        return {
123          platform,
124          fileName: mapName,
125          hash,
126          map: sourceMap,
127          comment: mappingComment,
128        };
129      })
130    )
131  ).filter(Boolean) as SourceMapWriteResult[];
132}
133
134export async function writeMetadataJsonAsync({
135  outputDir,
136  bundles,
137  fileNames,
138}: {
139  outputDir: string;
140  bundles: Record<string, Pick<BundleOutput, 'assets'>>;
141  fileNames: Record<string, string>;
142}) {
143  const contents = createMetadataJson({
144    bundles,
145    fileNames,
146  });
147  const metadataPath = path.join(outputDir, 'metadata.json');
148  debug(`Writing metadata.json to ${metadataPath}`);
149  await fs.writeFile(metadataPath, JSON.stringify(contents));
150  return contents;
151}
152
153export async function writeAssetMapAsync({
154  outputDir,
155  assets,
156}: {
157  outputDir: string;
158  assets: Asset[];
159}) {
160  // Convert the assets array to a k/v pair where the asset hash is the key and the asset is the value.
161  const contents = Object.fromEntries(assets.map((asset) => [asset.hash, asset]));
162  await fs.writeFile(path.join(outputDir, 'assetmap.json'), JSON.stringify(contents));
163  return contents;
164}
165
166export async function writeDebugHtmlAsync({
167  outputDir,
168  fileNames,
169}: {
170  outputDir: string;
171  fileNames: Record<string, string>;
172}) {
173  // Make a debug html so user can debug their bundles
174  const contents = `
175      ${Object.values(fileNames)
176        .map((fileName) => `<script src="${path.join('bundles', fileName)}"></script>`)
177        .join('\n      ')}
178      Open up this file in Chrome. In the JavaScript developer console, navigate to the Source tab.
179      You can see a red colored folder containing the original source code from your bundle.
180      `;
181
182  await fs.writeFile(path.join(outputDir, 'debug.html'), contents);
183  return contents;
184}
185