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