1import type { ExpoConfig, Platform } from '@expo/config';
2import spawnAsync from '@expo/spawn-async';
3import fs from 'fs-extra';
4import os from 'os';
5import path from 'path';
6import process from 'process';
7
8import {
9  importMetroSourceMapComposeSourceMapsFromProject,
10  resolveFromProject,
11} from '../start/server/metro/resolveFromProject';
12
13export function importHermesCommandFromProject(projectRoot: string): string {
14  const platformExecutable = getHermesCommandPlatform();
15  const hermescLocations = [
16    // Override hermesc dir by environment variables
17    process.env['REACT_NATIVE_OVERRIDE_HERMES_DIR']
18      ? `${process.env['REACT_NATIVE_OVERRIDE_HERMES_DIR']}/build/bin/hermesc`
19      : '',
20
21    // Building hermes from source
22    'react-native/ReactAndroid/hermes-engine/build/hermes/bin/hermesc',
23
24    // Prebuilt hermesc in official react-native 0.69+
25    `react-native/sdks/hermesc/${platformExecutable}`,
26
27    // Legacy hermes-engine package
28    `hermes-engine/${platformExecutable}`,
29  ];
30
31  for (const location of hermescLocations) {
32    try {
33      return resolveFromProject(projectRoot, location);
34    } catch {}
35  }
36  throw new Error('Cannot find the hermesc executable.');
37}
38
39function getHermesCommandPlatform(): string {
40  switch (os.platform()) {
41    case 'darwin':
42      return 'osx-bin/hermesc';
43    case 'linux':
44      return 'linux64-bin/hermesc';
45    case 'win32':
46      return 'win64-bin/hermesc.exe';
47    default:
48      throw new Error(`Unsupported host platform for Hermes compiler: ${os.platform()}`);
49  }
50}
51
52export function isEnableHermesManaged(expoConfig: ExpoConfig, platform: Platform): boolean {
53  switch (platform) {
54    case 'android': {
55      return (expoConfig.android?.jsEngine ?? expoConfig.jsEngine) !== 'jsc';
56    }
57    case 'ios': {
58      return (expoConfig.ios?.jsEngine ?? expoConfig.jsEngine) !== 'jsc';
59    }
60    default:
61      return false;
62  }
63}
64
65interface HermesBundleOutput {
66  hbc: Uint8Array;
67  sourcemap: string;
68}
69export async function buildHermesBundleAsync(
70  projectRoot: string,
71  code: string,
72  map: string,
73  optimize: boolean = false
74): Promise<HermesBundleOutput> {
75  const tempDir = path.join(os.tmpdir(), `expo-bundler-${process.pid}`);
76  await fs.ensureDir(tempDir);
77  try {
78    const tempBundleFile = path.join(tempDir, 'index.bundle');
79    const tempSourcemapFile = path.join(tempDir, 'index.bundle.map');
80    await fs.writeFile(tempBundleFile, code);
81    await fs.writeFile(tempSourcemapFile, map);
82
83    const tempHbcFile = path.join(tempDir, 'index.hbc');
84    const hermesCommand = importHermesCommandFromProject(projectRoot);
85    const args = ['-emit-binary', '-out', tempHbcFile, tempBundleFile, '-output-source-map'];
86    if (optimize) {
87      args.push('-O');
88    }
89    await spawnAsync(hermesCommand, args);
90
91    const [hbc, sourcemap] = await Promise.all([
92      fs.readFile(tempHbcFile),
93      createHermesSourcemapAsync(projectRoot, map, `${tempHbcFile}.map`),
94    ]);
95    return {
96      hbc,
97      sourcemap,
98    };
99  } finally {
100    await fs.remove(tempDir);
101  }
102}
103
104export async function createHermesSourcemapAsync(
105  projectRoot: string,
106  sourcemap: string,
107  hermesMapFile: string
108): Promise<string> {
109  const composeSourceMaps = importMetroSourceMapComposeSourceMapsFromProject(projectRoot);
110  const bundlerSourcemap = JSON.parse(sourcemap);
111  const hermesSourcemap = await fs.readJSON(hermesMapFile);
112  return JSON.stringify(composeSourceMaps([bundlerSourcemap, hermesSourcemap]));
113}
114
115export function parseGradleProperties(content: string): Record<string, string> {
116  const result: Record<string, string> = {};
117  for (let line of content.split('\n')) {
118    line = line.trim();
119    if (!line || line.startsWith('#')) {
120      continue;
121    }
122
123    const sepIndex = line.indexOf('=');
124    const key = line.substr(0, sepIndex);
125    const value = line.substr(sepIndex + 1);
126    result[key] = value;
127  }
128  return result;
129}
130
131export async function maybeThrowFromInconsistentEngineAsync(
132  projectRoot: string,
133  configFilePath: string,
134  platform: string,
135  isHermesManaged: boolean
136): Promise<void> {
137  const configFileName = path.basename(configFilePath);
138  if (
139    platform === 'android' &&
140    (await maybeInconsistentEngineAndroidAsync(projectRoot, isHermesManaged))
141  ) {
142    throw new Error(
143      `JavaScript engine configuration is inconsistent between ${configFileName} and Android native project.\n` +
144        `In ${configFileName}: Hermes is ${isHermesManaged ? 'enabled' : 'not enabled'}\n` +
145        `In Android native project: Hermes is ${isHermesManaged ? 'not enabled' : 'enabled'}\n` +
146        `Please check the following files for inconsistencies:\n` +
147        `  - ${configFilePath}\n` +
148        `  - ${path.join(projectRoot, 'android', 'gradle.properties')}\n` +
149        `  - ${path.join(projectRoot, 'android', 'app', 'build.gradle')}\n` +
150        'Learn more: https://expo.fyi/hermes-android-config'
151    );
152  }
153
154  if (platform === 'ios' && (await maybeInconsistentEngineIosAsync(projectRoot, isHermesManaged))) {
155    throw new Error(
156      `JavaScript engine configuration is inconsistent between ${configFileName} and iOS native project.\n` +
157        `In ${configFileName}: Hermes is ${isHermesManaged ? 'enabled' : 'not enabled'}\n` +
158        `In iOS native project: Hermes is ${isHermesManaged ? 'not enabled' : 'enabled'}\n` +
159        `Please check the following files for inconsistencies:\n` +
160        `  - ${configFilePath}\n` +
161        `  - ${path.join(projectRoot, 'ios', 'Podfile')}\n` +
162        `  - ${path.join(projectRoot, 'ios', 'Podfile.properties.json')}\n` +
163        'Learn more: https://expo.fyi/hermes-ios-config'
164    );
165  }
166}
167
168export async function maybeInconsistentEngineAndroidAsync(
169  projectRoot: string,
170  isHermesManaged: boolean
171): Promise<boolean> {
172  // Trying best to check android native project if by chance to be consistent between app config
173
174  // Check gradle.properties from prebuild template
175  const gradlePropertiesPath = path.join(projectRoot, 'android', 'gradle.properties');
176  if (fs.existsSync(gradlePropertiesPath)) {
177    const props = parseGradleProperties(await fs.readFile(gradlePropertiesPath, 'utf8'));
178    const isHermesBare = props['hermesEnabled'] === 'true';
179    if (isHermesManaged !== isHermesBare) {
180      return true;
181    }
182  }
183
184  return false;
185}
186
187export async function maybeInconsistentEngineIosAsync(
188  projectRoot: string,
189  isHermesManaged: boolean
190): Promise<boolean> {
191  // Trying best to check ios native project if by chance to be consistent between app config
192
193  // Check ios/Podfile for ":hermes_enabled => true"
194  const podfilePath = path.join(projectRoot, 'ios', 'Podfile');
195  if (fs.existsSync(podfilePath)) {
196    const content = await fs.readFile(podfilePath, 'utf8');
197    const isPropsReference =
198      content.search(
199        /^\s*:hermes_enabled\s*=>\s*podfile_properties\['expo.jsEngine'\]\s*==\s*nil\s*\|\|\s*podfile_properties\['expo.jsEngine'\]\s*==\s*'hermes',?/m
200      ) >= 0;
201    const isHermesBare = content.search(/^\s*:hermes_enabled\s*=>\s*true,?\s+/m) >= 0;
202    if (!isPropsReference && isHermesManaged !== isHermesBare) {
203      return true;
204    }
205  }
206
207  // Check Podfile.properties.json from prebuild template
208  const podfilePropertiesPath = path.join(projectRoot, 'ios', 'Podfile.properties.json');
209  if (fs.existsSync(podfilePropertiesPath)) {
210    const props = await parsePodfilePropertiesAsync(podfilePropertiesPath);
211    const isHermesBare = props['expo.jsEngine'] === 'hermes';
212    if (isHermesManaged !== isHermesBare) {
213      return true;
214    }
215  }
216
217  return false;
218}
219
220// https://github.com/facebook/hermes/blob/release-v0.5/include/hermes/BCGen/HBC/BytecodeFileFormat.h#L24-L25
221const HERMES_MAGIC_HEADER = 'c61fbc03c103191f';
222
223export async function isHermesBytecodeBundleAsync(file: string): Promise<boolean> {
224  const header = await readHermesHeaderAsync(file);
225  return header.slice(0, 8).toString('hex') === HERMES_MAGIC_HEADER;
226}
227
228export async function getHermesBytecodeBundleVersionAsync(file: string): Promise<number> {
229  const header = await readHermesHeaderAsync(file);
230  if (header.slice(0, 8).toString('hex') !== HERMES_MAGIC_HEADER) {
231    throw new Error('Invalid hermes bundle file');
232  }
233  return header.readUInt32LE(8);
234}
235
236async function readHermesHeaderAsync(file: string): Promise<Buffer> {
237  const fd = await fs.open(file, 'r');
238  const buffer = Buffer.alloc(12);
239  await fs.read(fd, buffer, 0, 12, null);
240  await fs.close(fd);
241  return buffer;
242}
243
244async function parsePodfilePropertiesAsync(
245  podfilePropertiesPath: string
246): Promise<Record<string, string>> {
247  try {
248    return JSON.parse(await fs.readFile(podfilePropertiesPath, 'utf8'));
249  } catch {
250    return {};
251  }
252}
253