Skip to content

Commit 0caee15

Browse files
chrfalchclaude
andcommitted
feat(spm): fold Hermes public headers into ReactNativeHeaders
Libraries that use the Hermes C++ API (worklets' WorkletHermesRuntime → `#include <hermes/hermes.h>`) failed to build under SPM: the Hermes public headers were never vended. They ARE shipped — the hermes-ios Maven tarball carries them in `destroot/include/hermes` (46 headers incl. hermes.h) alongside the framework — but `extractXCFramework` keeps only the `.xcframework` and discards the rest. So the headers were downloaded and thrown away. Fix: stop discarding them, and fold the `hermes/` namespace into ReactNativeHeaders (the headers-only artifact already auto-served as a search path on every RN-linking target — same place jsi/folly/glog live). This makes `<hermes/...>` resolve everywhere with zero per-library wiring. Only `hermes/` is vended — `jsi/` is already provided, so it isn't re-copied (no double-vend). - download-spm-artifacts.js: on hermes extraction, stage `destroot/include/ hermes` → `<slot>/hermes-headers/hermes`. Self-heal for already-extracted slots (the fast path skips extraction): backfill from the cached tarball, downloading only as a last resort. validateArtifactsCache now requires the staged headers, so an older slot re-runs the (network-free) backfill. - zero-i-compose.js: buildReactNativeHeadersXcframework takes an optional hermesHeaders source and folds the `hermes/` namespace in (textual, like folly — no clang module); ensureZeroILayout locates the staged headers in the slot and includes their presence in the freshness marker so a slot that gains them recomposes. - xcframework.js: note where the prebuild publish path should pass the hermes include dir when that (gated) pipeline is productionized. Serves both forms through the single buildReactNativeHeadersXcframework path: the consumer self-compose (active today) and the eventually-published artifact. Verified end-to-end: `spm update` on a real app stages the headers from the cached tarball (no network) and recomposes ReactNativeHeaders with the `hermes` namespace (Headers/hermes/hermes.h present); the reanimated/worklets build now compiles past `<hermes/hermes.h>`. 332 spm tests still pass. (The build next hits a separate prefix-header gap — worklets ObjC files using NSThread/UIKit without importing Foundation — tracked separately.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bc6a1ae commit 0caee15

3 files changed

Lines changed: 165 additions & 3 deletions

File tree

packages/react-native/scripts/ios-prebuild/xcframework.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,12 @@ function buildXCFrameworks(
215215
);
216216
const plan = computeSpecPlan(rootFolder);
217217
emitReactFrameworkHeaders(outputPath, plan, rootFolder);
218+
// NOTE: the Hermes public headers (`<hermes/...>`) are folded into
219+
// ReactNativeHeaders on the consumer side by zero-i-compose's
220+
// ensureZeroILayout (it stages them from the hermes-ios tarball via
221+
// download-spm-artifacts). When this prebuild publish path is
222+
// productionized, pass the prebuild's hermes `destroot/include` dir as the
223+
// 6th arg so the PUBLISHED ReactNativeHeaders carries hermes too.
218224
zeroIHeadersXcfw = buildReactNativeHeadersXcframework(
219225
path.dirname(outputPath),
220226
plan,

packages/react-native/scripts/ios-prebuild/zero-i-compose.js

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,13 @@ function buildReactNativeHeadersXcframework(
141141
depsHeaders /*: string */,
142142
rnRoot /*: string */,
143143
includeCatalyst /*: boolean */ = false,
144+
// Optional dir containing a `hermes/` namespace (the Hermes public C++ API
145+
// headers, `destroot/include` from the hermes-ios tarball). Folded in as a
146+
// textual namespace — like folly/glog, no clang module — so `<hermes/...>`
147+
// resolves for any RN-linking target without per-library wiring. null when
148+
// the Hermes headers aren't staged (then `<hermes/...>` stays unavailable,
149+
// i.e. the pre-fold behavior).
150+
hermesHeaders /*: ?string */ = null,
144151
) /*: string */ {
145152
// ---- stage headers ----
146153
const stage = fs.mkdtempSync(path.join(outDir, '.rnh-stage-'));
@@ -157,6 +164,20 @@ function buildReactNativeHeadersXcframework(
157164
console.warn(`zero-i-compose: deps namespace missing: ${ns}`);
158165
}
159166
}
167+
// Hermes public headers (separate source from the deps namespaces — they
168+
// come from the hermes-ios tarball, not ReactNativeDependencies). Vend only
169+
// the `hermes/` namespace; `jsi/` is already provided elsewhere, so copying
170+
// it here would double-vend.
171+
let hermesFolded = false;
172+
if (hermesHeaders != null) {
173+
const src = path.join(hermesHeaders, 'hermes');
174+
if (fs.existsSync(src)) {
175+
execSync(`/bin/cp -Rc "${src}" "${path.join(stage, 'hermes')}"`);
176+
hermesFolded = true;
177+
} else {
178+
console.warn(`zero-i-compose: hermes headers missing at ${src}`);
179+
}
180+
}
160181
fs.writeFileSync(
161182
path.join(stage, 'module.modulemap'),
162183
renderNamespaceModuleMap(plan.namespaceModules),
@@ -208,7 +229,8 @@ function buildReactNativeHeadersXcframework(
208229
fs.rmSync(work, {recursive: true, force: true});
209230
console.log(
210231
`zero-i-compose: ReactNativeHeaders.xcframework (${slices.map(s => s.name).join(', ')}) -> ${outXcfw} ` +
211-
`(${plan.reactNativeHeaders.length} RN headers + deps ${plan.depsNamespaces.join(', ')}; ` +
232+
`(${plan.reactNativeHeaders.length} RN headers + deps ${plan.depsNamespaces.join(', ')}` +
233+
`${hermesFolded ? ', hermes' : ''}; ` +
212234
`${Object.keys(plan.namespaceModules).length} namespace modules)`,
213235
);
214236
return outXcfw;
@@ -240,12 +262,23 @@ function ensureZeroILayout(
240262
'ReactNativeDependencies.xcframework',
241263
'Headers',
242264
);
265+
// Hermes public headers staged into the slot by download-spm-artifacts
266+
// (the hermes-ios tarball ships them in destroot/include, which the
267+
// xcframework extraction otherwise discards). null when absent — then
268+
// ReactNativeHeaders composes without the hermes namespace.
269+
const hermesHeadersDir = path.join(artifactsDir, 'hermes-headers');
270+
const hermesHeaders = fs.existsSync(path.join(hermesHeadersDir, 'hermes'))
271+
? hermesHeadersDir
272+
: null;
243273
const reactXcfw = path.join(outDir, 'React.xcframework');
244274
const headersXcfw = path.join(outDir, 'ReactNativeHeaders.xcframework');
245275
const markerPath = path.join(outDir, '.composed-from');
246276

247277
const sourceStat = fs.statSync(path.join(sourceXcfw, 'Info.plist'));
248-
const marker = `${sourceXcfw}\n${sourceStat.mtimeMs}\n`;
278+
// Fold the hermes-headers presence into the marker so a slot that gains
279+
// staged hermes headers (e.g. after a tooling upgrade re-downloads them)
280+
// recomposes instead of reusing a hermes-less ReactNativeHeaders.
281+
const marker = `${sourceXcfw}\n${sourceStat.mtimeMs}\n${hermesHeaders ?? 'no-hermes'}\n`;
249282
if (
250283
!force &&
251284
fs.existsSync(reactXcfw) &&
@@ -270,7 +303,14 @@ function ensureZeroILayout(
270303

271304
const plan = computeSpecPlan(rnRoot);
272305
emitReactFrameworkHeaders(reactXcfw, plan, rnRoot);
273-
buildReactNativeHeadersXcframework(outDir, plan, depsHeaders, rnRoot);
306+
buildReactNativeHeadersXcframework(
307+
outDir,
308+
plan,
309+
depsHeaders,
310+
rnRoot,
311+
false,
312+
hermesHeaders,
313+
);
274314
fs.writeFileSync(markerPath, marker);
275315
return {reactXcfw, headersXcfw};
276316
}

packages/react-native/scripts/spm/download-spm-artifacts.js

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,86 @@ function findFirst(
621621
return null;
622622
}
623623

624+
/**
625+
* The hermes-ios tarball ships its public C++ API headers in
626+
* `destroot/include/hermes` alongside the framework — which extractXCFramework
627+
* discards (it keeps only the .xcframework). Stage the `hermes/` namespace into
628+
* `<outputDir>/hermes-headers/hermes` so zero-i-compose can fold it into
629+
* ReactNativeHeaders (making `<hermes/hermes.h>` resolve for any RN-linking
630+
* target). Only `hermes/` is staged — `jsi/` is already vended elsewhere.
631+
* Best-effort: a tarball without these headers just leaves hermes unavailable.
632+
*/
633+
function stageHermesHeaders(
634+
extractDir /*: string */,
635+
outputDir /*: string */,
636+
) /*: void */ {
637+
let includeDir = path.join(extractDir, 'destroot', 'include');
638+
if (!fs.existsSync(path.join(includeDir, 'hermes', 'hermes.h'))) {
639+
// Fall back to locating the include dir wherever it landed in the tarball.
640+
const hit = findFirst(extractDir, name => name === 'include', 8);
641+
if (hit != null) {
642+
includeDir = hit;
643+
}
644+
}
645+
const src = path.join(includeDir, 'hermes');
646+
if (!fs.existsSync(path.join(src, 'hermes.h'))) {
647+
log(' Hermes public headers not found in tarball — skipping header stage');
648+
return;
649+
}
650+
const destRoot = path.join(outputDir, 'hermes-headers');
651+
const dest = path.join(destRoot, 'hermes');
652+
fs.rmSync(dest, {recursive: true, force: true});
653+
fs.mkdirSync(destRoot, {recursive: true});
654+
execSync(`/bin/cp -R "${src}" "${dest}"`, {stdio: 'pipe'});
655+
log(' Staged Hermes public headers → hermes-headers/hermes');
656+
}
657+
658+
/**
659+
* Self-heal: stage Hermes headers into an already-extracted slot (the fast
660+
* path skips extraction, so the headers were never staged). Prefers a CACHED
661+
* hermes tarball (no network); downloads only as a last resort so the slot
662+
* can't get stuck "incomplete" forever. No-op only when the headers can't be
663+
* obtained at all (e.g. a missing local-tarball override).
664+
*/
665+
async function ensureHermesHeadersStaged(
666+
url /*: string */,
667+
downloadDir /*: string */,
668+
sharedTarballName /*: ?string */,
669+
outputDir /*: string */,
670+
) /*: Promise<void> */ {
671+
const candidates = [
672+
!/^https?:\/\//.test(url) ? url : null, // local-tarball override
673+
path.join(downloadDir, url.split('/').pop() ?? ''),
674+
sharedTarballName != null
675+
? path.join(sharedCacheDir(), sharedTarballName)
676+
: null,
677+
].filter(Boolean);
678+
let tarPath /*: ?string */ = candidates.find(
679+
p => p != null && fs.existsSync(p),
680+
);
681+
if (tarPath == null) {
682+
if (!/^https?:\/\//.test(url)) {
683+
return; // local override missing — nothing to recover from
684+
}
685+
const localPath = path.join(
686+
downloadDir,
687+
url.split('/').pop() ?? 'hermes.tar.gz',
688+
);
689+
fs.mkdirSync(downloadDir, {recursive: true});
690+
await download(url, localPath);
691+
tarPath = localPath;
692+
}
693+
const tmp = path.join(outputDir, '.hermes-hdr-tmp');
694+
fs.rmSync(tmp, {recursive: true, force: true});
695+
fs.mkdirSync(tmp, {recursive: true});
696+
try {
697+
execSync(`tar -xzf "${tarPath}" -C "${tmp}"`, {stdio: 'pipe'});
698+
stageHermesHeaders(tmp, outputDir);
699+
} finally {
700+
fs.rmSync(tmp, {recursive: true, force: true});
701+
}
702+
}
703+
624704
/**
625705
* Downloads a tarball, extracts the xcframework, and places it directly in
626706
* the output directory as <xcframeworkName>.xcframework/.
@@ -661,6 +741,23 @@ async function processArtifact(
661741
} else {
662742
log(` Already extracted: ${xcframeworkName}.xcframework`);
663743
}
744+
// The xcframework is cached, but a slot from older tooling won't have the
745+
// Hermes headers staged. Backfill them from a cached tarball (no network).
746+
if (
747+
label === 'hermes' &&
748+
!fs.existsSync(path.join(outputDir, 'hermes-headers', 'hermes'))
749+
) {
750+
try {
751+
await ensureHermesHeadersStaged(
752+
url,
753+
downloadDir,
754+
sharedTarballName,
755+
outputDir,
756+
);
757+
} catch (e) {
758+
log(` Hermes header backfill failed (${e.message}) — continuing`);
759+
}
760+
}
664761
return {label, version, xcframeworkPath: destXcfwPath, url};
665762
}
666763

@@ -750,6 +847,17 @@ async function processArtifact(
750847
fs.renameSync(xcfwPath, destXcfwPath);
751848
}
752849

850+
// Hermes ships its public headers in the same tarball; stage them next to
851+
// the xcframeworks so zero-i-compose can fold `hermes/` into
852+
// ReactNativeHeaders. (Other artifacts have no such headers — no-op.)
853+
if (label === 'hermes') {
854+
try {
855+
stageHermesHeaders(tmpExtractDir, outputDir);
856+
} catch (e) {
857+
log(` Hermes header staging failed (${e.message}) — continuing`);
858+
}
859+
}
860+
753861
fs.rmSync(tmpExtractDir, {recursive: true, force: true});
754862

755863
return {label, version, xcframeworkPath: destXcfwPath, url};
@@ -1023,6 +1131,14 @@ function validateArtifactsCache(
10231131
return `xcframework for "${name}" not found at ${entry.xcframeworkPath}`;
10241132
}
10251133
}
1134+
// The Hermes public headers must be staged for zero-i-compose to fold
1135+
// `<hermes/...>` into ReactNativeHeaders. A slot from older tooling won't
1136+
// have them — report incomplete so ensureArtifacts re-runs the download
1137+
// (which, with the xcframeworks already present, only backfills the headers
1138+
// from the cached tarball — no network re-download).
1139+
if (!fs.existsSync(path.join(artifactsDir, 'hermes-headers', 'hermes'))) {
1140+
return 'Hermes public headers not staged (hermes-headers/hermes)';
1141+
}
10261142
return null;
10271143
}
10281144

0 commit comments

Comments
 (0)