xref: /expo/tools/src/versioning/ios/versionHermes.ts (revision 7c3fd962)
1import spawnAsync from '@expo/spawn-async';
2import fs from 'fs-extra';
3import os from 'os';
4import path from 'path';
5
6import { EXPO_DIR, REACT_NATIVE_SUBMODULE_DIR } from '../../Constants';
7import { GitDirectory } from '../../Git';
8import logger from '../../Logger';
9import { transformFilesAsync } from '../../Transforms';
10import type { FileTransform } from '../../Transforms.types';
11import { searchFilesAsync } from '../../Utils';
12
13const TRANSFORM_HEADERS_API = ['hermes', 'DebuggerAPI'];
14const TRANSFORM_HEADERS_PUBLIC = [
15  'RuntimeConfig',
16  'CrashManager',
17  'CtorConfig',
18  'DebuggerTypes',
19  'GCConfig',
20  'GCTripwireContext',
21];
22
23const VERSIONED_JSI_DIR = 'versioned-jsi';
24
25interface VersionHermesOptions {
26  // true to show verbose building log
27  verbose?: boolean;
28
29  // specify custom hermes download dir, use temp dir by default
30  hermesDir?: string;
31
32  // specify hermes git ref, use the version from *react-native-lab/react-native/sdks/.hermesversion* by default
33  hermesGitRef?: string;
34}
35
36function createHermesTransforms(versionName: string, versionedJsiDir: string): FileTransform[] {
37  return [
38    {
39      find: /\b(facebook|hermes)::/g,
40      replaceWith: `${versionName}$1::`,
41    },
42    {
43      find: /\bnamespace (facebook|hermes)/g,
44      replaceWith: `namespace ${versionName}$1`,
45    },
46    {
47      find: /#include <jsi\/([^>]+)\.h>/g,
48      replaceWith: `#include <${versionName}jsi/${versionName}$1.h>`,
49    },
50    {
51      find: /\b(HERMES_NON_CONSTEXPR|_HERMES_CTORCONFIG_)/g,
52      replaceWith: `${versionName}$1`,
53    },
54    {
55      find: new RegExp(
56        `(#include ["<](hermes\\/)?)((${TRANSFORM_HEADERS_API.join('|')})\\.h[">])`,
57        'g'
58      ),
59      replaceWith: `$1${versionName}$3`,
60    },
61    {
62      find: new RegExp(
63        `(#include ["<]hermes\\/Public\\/)((${TRANSFORM_HEADERS_PUBLIC.join('|')})\\.h[">])`,
64        'g'
65      ),
66      replaceWith: `$1${versionName}$2`,
67    },
68    {
69      paths: `${VERSIONED_JSI_DIR}/${versionName}jsi/CMakeLists.txt`,
70      find: /\b(jsi\.cpp)\b/g,
71      replaceWith: `${versionName}$1`,
72    },
73    {
74      paths: 'CMakeLists.txt',
75      find: 'add_subdirectory(${HERMES_JSI_DIR}/jsi ${CMAKE_CURRENT_BINARY_DIR}/jsi)',
76      replaceWith: `add_subdirectory(\${HERMES_JSI_DIR}/${versionName}jsi \${CMAKE_CURRENT_BINARY_DIR}/jsi)`,
77    },
78    {
79      paths: 'utils/build-apple-framework.sh',
80      find: 'cmake -S . -B build_host_hermesc',
81      replaceWith: `cmake -S . -B build_host_hermesc -DJSI_DIR=${versionedJsiDir}`,
82    },
83    {
84      // support specifying JSI_PATH by environment variable
85      paths: 'utils/build-apple-framework.sh',
86      find: 'JSI_PATH="$REACT_NATIVE_PATH/ReactCommon/jsi"',
87      replaceWith: 'JSI_PATH="${JSI_PATH:-$REACT_NATIVE_PATH/ReactCommon/jsi}"',
88    },
89    // framework versioning
90    {
91      paths: 'API/hermes/CMakeLists.txt',
92      find: 'OUTPUT_NAME hermes',
93      replaceWith: `OUTPUT_NAME ${versionName}hermes`,
94    },
95    {
96      paths: 'API/hermes/CMakeLists.txt',
97      find: 'MACOSX_FRAMEWORK_IDENTIFIER dev.hermesengine.',
98      // CFBundleIdentifier does not support underscores, replacing with hyphens.
99      // https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
100      replaceWith: `MACOSX_FRAMEWORK_IDENTIFIER dev.${versionName.replace(/_/g, '-')}hermesengine.`,
101    },
102    {
103      paths: 'utils/*.sh',
104      find: /\b(hermes.(xc)?framework)/g,
105      replaceWith: `${versionName}$1`,
106    },
107  ];
108}
109
110async function transformHermesAsync(
111  hermesRoot: string,
112  reactNativeRoot: string,
113  versionName: string
114) {
115  // use the build scripts from react-native to build hermes
116  await Promise.all(
117    [
118      'utils/build-apple-framework.sh',
119      'utils/build-ios-framework.sh',
120      'utils/build-mac-framework.sh',
121    ].map((file) =>
122      fs.copy(
123        path.join(REACT_NATIVE_SUBMODULE_DIR, 'sdks', 'hermes-engine', file),
124        path.join(hermesRoot, file)
125      )
126    )
127  );
128  // workaround for 0.69 as it doesn't support specifying custom JSI path
129  await spawnAsync('wget', [
130    '-O',
131    path.join(hermesRoot, 'utils/build-apple-framework.sh'),
132    'https://raw.githubusercontent.com/facebook/react-native/0.70-stable/sdks/hermes-engine/utils/build-apple-framework.sh',
133  ]);
134
135  // copy versioned jsi files from react-native
136  const versionedJsiDir = path.join(hermesRoot, VERSIONED_JSI_DIR);
137  await fs.copy(path.join(reactNativeRoot, 'ReactCommon', 'jsi'), versionedJsiDir);
138  await fs.rename(
139    path.join(versionedJsiDir, 'jsi'),
140    path.join(versionedJsiDir, `${versionName}jsi`)
141  );
142
143  // transform content
144  const currDir = process.cwd();
145  process.chdir(hermesRoot); // change cwd to hermesRoot for transformFilesAsync writing in relative path
146  const transformDirs = ['API', 'include', 'lib', 'public', 'tools', 'utils'].join(',');
147  const files = Array.from(
148    await searchFilesAsync(hermesRoot, [
149      `{${transformDirs}}/**/*.{h,cpp,mm}`,
150      '**/CMakeLists.txt',
151      '**/*.sh',
152    ])
153  );
154  await transformFilesAsync(files, createHermesTransforms(versionName, versionedJsiDir));
155  process.chdir(currDir);
156
157  // transform file names
158  await Promise.all(
159    TRANSFORM_HEADERS_API.map((file) => {
160      const dir = path.join(hermesRoot, 'API', 'hermes');
161      return fs.move(path.join(dir, `${file}.h`), path.join(dir, `${versionName}${file}.h`));
162    })
163  );
164  await Promise.all(
165    TRANSFORM_HEADERS_PUBLIC.map((file) => {
166      const dir = path.join(hermesRoot, 'public', 'hermes', 'Public');
167      return fs.move(path.join(dir, `${file}.h`), path.join(dir, `${versionName}${file}.h`));
168    })
169  );
170}
171
172function downloadHermesSourceAsync(downloadDir: string, ref: string) {
173  return GitDirectory.shallowCloneAsync(downloadDir, 'https://github.com/facebook/hermes.git', ref);
174}
175
176function buildHermesAsync(hermesRoot: string, options?: VersionHermesOptions) {
177  const versionedJsiDir = path.join(hermesRoot, VERSIONED_JSI_DIR);
178  return spawnAsync('./utils/build-ios-framework.sh', [], {
179    cwd: hermesRoot,
180    shell: true,
181    env: {
182      ...process.env,
183      JSI_PATH: versionedJsiDir,
184    },
185    stdio: options?.verbose ? 'inherit' : 'ignore',
186  });
187}
188
189async function removeUnusedHeaders(hermesRoot: string, versionName: string) {
190  const destRoot = path.join(hermesRoot, 'destroot');
191
192  // remove jsi headers
193  await fs.remove(path.join(destRoot, 'include', 'jsi'));
194
195  // remove unused and unversioned headers
196  const files = Array.from(
197    await searchFilesAsync(path.join(destRoot, 'include', 'hermes'), [`**/!(${versionName})*`], {
198      absolute: true,
199    })
200  );
201  await Promise.all(files.map((file) => fs.remove(file)));
202}
203
204export async function createVersionedHermesTarball(
205  versionedReactNativeRoot: string,
206  versionName: string,
207  options?: VersionHermesOptions
208): Promise<string> {
209  const hermesGitRef =
210    options?.hermesGitRef ??
211    (await fs.readFile(path.join(REACT_NATIVE_SUBMODULE_DIR, 'sdks', '.hermesversion'), 'utf8'));
212  if (!hermesGitRef) {
213    throw new Error('Cannot get bundled hermes version from react-native.');
214  }
215
216  const hermesRoot = options?.hermesDir ?? path.join(os.tmpdir(), 'hermes');
217  try {
218    await fs.remove(hermesRoot);
219    await fs.ensureDir(hermesRoot);
220
221    logger.log('Downloading hermes source code');
222    await downloadHermesSourceAsync(hermesRoot, hermesGitRef);
223
224    logger.log('Versioning hermes source code');
225    await transformHermesAsync(hermesRoot, versionedReactNativeRoot, versionName);
226
227    logger.log('Building hermes');
228    await buildHermesAsync(hermesRoot, options);
229
230    const tarball = path.join(EXPO_DIR, `${versionName}hermes.tar.gz`);
231    logger.log(`Archiving hermes tarball: ${tarball}`);
232    await removeUnusedHeaders(hermesRoot, versionName);
233    // NOTE(kudo): we should include the _LICENSE_ file in the tarball, otherwise CocoaPods will get empty result from tarball extraction.
234    await spawnAsync('tar', ['cvfz', tarball, 'destroot', 'LICENSE'], {
235      cwd: hermesRoot,
236      stdio: options?.verbose ? 'inherit' : 'ignore',
237    });
238    return tarball;
239  } finally {
240    await fs.remove(hermesRoot);
241  }
242}
243