Skip to content

Commit 27549b8

Browse files
chrfalchclaude
andcommitted
feat(ios): headers-spec layout + ReactNativeHeaders compose
The minimal machinery to build the packaged header structures: - headers-spec.js: the executable layout contract (rules R1-R8) — which namespaces are hoisted into the React framework, which carry module maps, and how collisions are rejected. - headers-inventory.js: scans the source tree and classifies every shipped header (language surface + modularizability bucket) — the input to the spec. computeInventory() feeds the build in-memory; the CLI writes a JSON manifest. - headers-compose.js: emits the layout — writes the <React/...> headers + umbrella + module map into each React.framework slice (detected by the framework's presence), and assembles the headers-only ReactNativeHeaders.xcframework (every other namespace + deps + Hermes). Called by xcframework.js during compose. This is the alternative header source that lets consumers resolve React Native headers without a clang VFS overlay. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 57ce6bc commit 27549b8

3 files changed

Lines changed: 1129 additions & 0 deletions

File tree

Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow
8+
* @format
9+
*/
10+
11+
/**
12+
* Headers compose — emits the headers-spec layout (rules R1–R8 in
13+
* headers-spec.js) into a React.xcframework and builds the headers-only
14+
* ReactNativeHeaders.xcframework beside it.
15+
*
16+
* The prebuild compose step (xcframework.js) emits the layout into the freshly
17+
* composed React.xcframework's slices and builds ReactNativeHeaders.xcframework
18+
* beside it — BEFORE signing (R7). `ensureHeadersLayout()` applies the same
19+
* emission to an already-cached artifact (binaries are header-independent) for
20+
* tooling that composes on demand.
21+
*
22+
* One projector, spec-driven, byte-identical output either way.
23+
*/
24+
25+
const {computeInventory} = require('./headers-inventory');
26+
const {
27+
DEPS_NAMESPACES,
28+
planFromInventory,
29+
renderNamespaceModuleMap,
30+
renderReactModuleMap,
31+
renderUmbrellaHeader,
32+
} = require('./headers-spec');
33+
const {execSync} = require('child_process');
34+
const fs = require('fs');
35+
const path = require('path');
36+
37+
/*:: import type {HeadersSpecPlan, SpecEntry} from './headers-spec'; */
38+
39+
/**
40+
* Computes the spec plan from the live source tree. Throws on collisions
41+
* (R8) — a collision means the spec and the source tree disagree and the
42+
* artifact must not be produced.
43+
*/
44+
function computeSpecPlan(rnRoot /*: string */) /*: HeadersSpecPlan */ {
45+
const plan = planFromInventory(computeInventory(rnRoot));
46+
if (plan.collisions.length > 0) {
47+
throw new Error(
48+
`headers-spec collisions (R8):\n ${plan.collisions.join('\n ')}`,
49+
);
50+
}
51+
return plan;
52+
}
53+
54+
/**
55+
* Copies spec entries (each `{relPath, source}`) into a staging dir, creating
56+
* parent dirs. Shared by the React.framework and ReactNativeHeaders emission.
57+
*/
58+
function stageEntries(
59+
stage /*: string */,
60+
entries /*: Array<SpecEntry> */,
61+
rnRoot /*: string */,
62+
) /*: void */ {
63+
for (const e of entries) {
64+
const dest = path.join(stage, e.relPath);
65+
fs.mkdirSync(path.dirname(dest), {recursive: true});
66+
fs.copyFileSync(path.join(rnRoot, e.source), dest);
67+
}
68+
}
69+
70+
/**
71+
* Emits the React.framework side of the spec (R1, R4, R6) into every slice
72+
* of an xcframework: Headers root = React/ ∪ react/ hoisted + bare aliases,
73+
* generated umbrella + framework module map. Replaces each slice's Headers
74+
* and Modules. The xcframework's ROOT Headers/ (the CocoaPods header surface)
75+
* is left untouched.
76+
*/
77+
function emitReactFrameworkHeaders(
78+
xcfwPath /*: string */,
79+
plan /*: HeadersSpecPlan */,
80+
rnRoot /*: string */,
81+
) /*: void */ {
82+
const stage = fs.mkdtempSync(
83+
path.join(path.dirname(xcfwPath), '.react-stage-'),
84+
);
85+
stageEntries(stage, plan.react, rnRoot);
86+
fs.writeFileSync(
87+
path.join(stage, 'React-umbrella.h'),
88+
renderUmbrellaHeader(plan.umbrella),
89+
);
90+
91+
// A slice is any entry carrying a React.framework. The framework as built by
92+
// xcodebuild -create-xcframework ships no Headers/ dir of its own — this
93+
// emission creates it (and replaces Modules), so detect by the framework, not
94+
// by a pre-existing Headers/.
95+
const slices = fs
96+
.readdirSync(xcfwPath)
97+
.filter(d =>
98+
fs.existsSync(path.join(xcfwPath, d, 'React.framework')),
99+
);
100+
for (const slice of slices) {
101+
const fwk = path.join(xcfwPath, slice, 'React.framework');
102+
fs.rmSync(path.join(fwk, 'Headers'), {recursive: true, force: true});
103+
execSync(`/bin/cp -Rc "${stage}" "${path.join(fwk, 'Headers')}"`);
104+
fs.rmSync(path.join(fwk, 'Modules'), {recursive: true, force: true});
105+
fs.mkdirSync(path.join(fwk, 'Modules'), {recursive: true});
106+
fs.writeFileSync(
107+
path.join(fwk, 'Modules', 'module.modulemap'),
108+
renderReactModuleMap(),
109+
);
110+
}
111+
fs.rmSync(stage, {recursive: true, force: true});
112+
console.log(
113+
`headers-compose: React.framework spec layout -> ${slices.join(', ')} ` +
114+
`(${plan.react.length} headers, umbrella ${plan.umbrella.length})`,
115+
);
116+
}
117+
118+
/*::
119+
type StubSlice = {
120+
name: string, // human label
121+
sdk: string, // xcrun --sdk name
122+
targets: Array<string>, // clang -target triples (lipo'd when > 1)
123+
};
124+
*/
125+
126+
const DEFAULT_STUB_SLICES /*: Array<StubSlice> */ = [
127+
{name: 'ios', sdk: 'iphoneos', targets: ['arm64-apple-ios15.0']},
128+
{
129+
name: 'ios-simulator',
130+
sdk: 'iphonesimulator',
131+
targets: [
132+
'arm64-apple-ios15.0-simulator',
133+
'x86_64-apple-ios15.0-simulator',
134+
],
135+
},
136+
];
137+
138+
// Mac Catalyst slice — used by the real compose (the cached-artifact
139+
// repackage path skips it to stay fast; React.xcframework carries it).
140+
const CATALYST_STUB_SLICE /*: StubSlice */ = {
141+
name: 'mac-catalyst',
142+
sdk: 'macosx',
143+
targets: ['arm64-apple-ios15.0-macabi', 'x86_64-apple-ios15.0-macabi'],
144+
};
145+
146+
/**
147+
* Builds ReactNativeHeaders.xcframework (R2, R5): a headers-only LIBRARY
148+
* xcframework (stub static archives — nothing embeds in apps) whose Headers
149+
* root carries every non-React namespace incl. the third-party deps
150+
* namespaces, plus module.modulemap with the plain per-namespace modules.
151+
* SPM serves its Headers automatically to dependents — no flags.
152+
*/
153+
function buildReactNativeHeadersXcframework(
154+
outDir /*: string */,
155+
plan /*: HeadersSpecPlan */,
156+
depsHeaders /*: string */,
157+
rnRoot /*: string */,
158+
includeCatalyst /*: boolean */ = false,
159+
// Optional dir containing a `hermes/` namespace (the Hermes public C++ API
160+
// headers, `destroot/include` from the hermes-ios tarball). Folded in as a
161+
// textual namespace — like folly/glog, no clang module — so `<hermes/...>`
162+
// resolves for any RN-linking target without per-library wiring. null when
163+
// the Hermes headers aren't staged (then `<hermes/...>` stays unavailable,
164+
// i.e. the pre-fold behavior).
165+
hermesHeaders /*: ?string */ = null,
166+
) /*: string */ {
167+
// ---- stage headers ----
168+
const stage = fs.mkdtempSync(path.join(outDir, '.rnh-stage-'));
169+
stageEntries(stage, plan.reactNativeHeaders, rnRoot);
170+
for (const ns of plan.depsNamespaces) {
171+
const src = path.join(depsHeaders, ns);
172+
if (fs.existsSync(src)) {
173+
execSync(`/bin/cp -Rc "${src}" "${path.join(stage, ns)}"`);
174+
} else {
175+
console.warn(`headers-compose: deps namespace missing: ${ns}`);
176+
}
177+
}
178+
// Hermes public headers (separate source from the deps namespaces — they
179+
// come from the hermes-ios tarball, not ReactNativeDependencies). Vend only
180+
// the `hermes/` namespace; `jsi/` is already provided elsewhere, so copying
181+
// it here would double-vend.
182+
let hermesFolded = false;
183+
if (hermesHeaders != null) {
184+
const src = path.join(hermesHeaders, 'hermes');
185+
if (fs.existsSync(src)) {
186+
execSync(`/bin/cp -Rc "${src}" "${path.join(stage, 'hermes')}"`);
187+
hermesFolded = true;
188+
} else {
189+
console.warn(`headers-compose: hermes headers missing at ${src}`);
190+
}
191+
}
192+
fs.writeFileSync(
193+
path.join(stage, 'module.modulemap'),
194+
renderNamespaceModuleMap(plan.namespaceModules),
195+
);
196+
197+
// ---- stub static archives per slice ----
198+
const work = fs.mkdtempSync(path.join(outDir, '.stub-work-'));
199+
fs.writeFileSync(
200+
path.join(work, 'stub.c'),
201+
'// ReactNativeHeaders is headers-only; this stub satisfies xcframework tooling.\nstatic int RNHeadersStub __attribute__((unused)) = 0;\n',
202+
);
203+
const slices = includeCatalyst
204+
? [...DEFAULT_STUB_SLICES, CATALYST_STUB_SLICE]
205+
: DEFAULT_STUB_SLICES;
206+
const libs = slices.map(slice => {
207+
const sdkPath = execSync(`xcrun --sdk ${slice.sdk} --show-sdk-path`)
208+
.toString()
209+
.trim();
210+
const thins = slice.targets.map((t, i) => {
211+
const obj = path.join(work, `stub-${slice.name}-${i}.o`);
212+
execSync(
213+
`xcrun clang -c -target ${t} -isysroot "${sdkPath}" "${path.join(work, 'stub.c')}" -o "${obj}"`,
214+
);
215+
const lib = path.join(work, `stub-${slice.name}-${i}.a`);
216+
execSync(`xcrun libtool -static -o "${lib}" "${obj}" 2>/dev/null`);
217+
return lib;
218+
});
219+
const outLib = path.join(work, `libReactNativeHeaders-${slice.name}.a`);
220+
if (thins.length === 1) {
221+
fs.copyFileSync(thins[0], outLib);
222+
} else {
223+
execSync(
224+
`xcrun lipo -create ${thins.map(l => `"${l}"`).join(' ')} -output "${outLib}"`,
225+
);
226+
}
227+
return outLib;
228+
});
229+
230+
// ---- compose ----
231+
const outXcfw = path.join(outDir, 'ReactNativeHeaders.xcframework');
232+
fs.rmSync(outXcfw, {recursive: true, force: true});
233+
execSync(
234+
`xcodebuild -create-xcframework ` +
235+
libs.map(l => `-library "${l}" -headers "${stage}"`).join(' ') +
236+
` -output "${outXcfw}"`,
237+
{stdio: 'pipe'},
238+
);
239+
fs.rmSync(stage, {recursive: true, force: true});
240+
fs.rmSync(work, {recursive: true, force: true});
241+
console.log(
242+
`headers-compose: ReactNativeHeaders.xcframework (${slices.map(s => s.name).join(', ')}) -> ${outXcfw} ` +
243+
`(${plan.reactNativeHeaders.length} RN headers + deps ${plan.depsNamespaces.join(', ')}` +
244+
`${hermesFolded ? ', hermes' : ''}; ` +
245+
`${Object.keys(plan.namespaceModules).length} namespace modules)`,
246+
);
247+
return outXcfw;
248+
}
249+
250+
/**
251+
* Ensures the headers-spec layout exists at `outDir`, composed from the cache
252+
* slot's artifacts: clones React.xcframework (APFS clonefile), strips the
253+
* stale signature (R7 — production signs after compose), emits the spec
254+
* layout into every slice, and builds ReactNativeHeaders.xcframework from
255+
* the plan + the slot's deps headers.
256+
*
257+
* Skips when the freshness marker matches the source artifact (same
258+
* realpath + Info.plist mtime) unless `force`. Any consumer with a cache slot
259+
* gets composed artifacts automatically — no published ReactNativeHeaders
260+
* required.
261+
*/
262+
function ensureHeadersLayout(
263+
artifactsDir /*: string */,
264+
rnRoot /*: string */,
265+
outDir /*: string */,
266+
force /*: boolean */ = false,
267+
) /*: {reactXcfw: string, headersXcfw: string} */ {
268+
const sourceXcfw = fs.realpathSync(
269+
path.join(artifactsDir, 'React.xcframework'),
270+
);
271+
const depsHeaders = path.join(
272+
artifactsDir,
273+
'ReactNativeDependencies.xcframework',
274+
'Headers',
275+
);
276+
// Hermes public headers staged into the slot by download-spm-artifacts
277+
// (the hermes-ios tarball ships them in destroot/include, which the
278+
// xcframework extraction otherwise discards). null when absent — then
279+
// ReactNativeHeaders composes without the hermes namespace.
280+
const hermesHeadersDir = path.join(artifactsDir, 'hermes-headers');
281+
const hermesHeaders = fs.existsSync(path.join(hermesHeadersDir, 'hermes'))
282+
? hermesHeadersDir
283+
: null;
284+
const reactXcfw = path.join(outDir, 'React.xcframework');
285+
const headersXcfw = path.join(outDir, 'ReactNativeHeaders.xcframework');
286+
const markerPath = path.join(outDir, '.composed-from');
287+
288+
const sourceStat = fs.statSync(path.join(sourceXcfw, 'Info.plist'));
289+
// Fold the hermes-headers presence into the marker so a slot that gains
290+
// staged hermes headers (e.g. after a tooling upgrade re-downloads them)
291+
// recomposes instead of reusing a hermes-less ReactNativeHeaders.
292+
const marker = `${sourceXcfw}\n${sourceStat.mtimeMs}\n${hermesHeaders ?? 'no-hermes'}\n`;
293+
if (
294+
!force &&
295+
fs.existsSync(reactXcfw) &&
296+
fs.existsSync(headersXcfw) &&
297+
fs.existsSync(markerPath) &&
298+
fs.readFileSync(markerPath, 'utf8') === marker
299+
) {
300+
return {reactXcfw, headersXcfw};
301+
}
302+
303+
console.log(
304+
`headers-compose: composing layout from ${path.basename(artifactsDir)} slot...`,
305+
);
306+
fs.rmSync(reactXcfw, {recursive: true, force: true});
307+
fs.rmSync(markerPath, {force: true});
308+
fs.mkdirSync(outDir, {recursive: true});
309+
execSync(`/bin/cp -Rc "${sourceXcfw}" "${reactXcfw}"`);
310+
fs.rmSync(path.join(reactXcfw, '_CodeSignature'), {
311+
recursive: true,
312+
force: true,
313+
});
314+
315+
const plan = computeSpecPlan(rnRoot);
316+
emitReactFrameworkHeaders(reactXcfw, plan, rnRoot);
317+
buildReactNativeHeadersXcframework(
318+
outDir,
319+
plan,
320+
depsHeaders,
321+
rnRoot,
322+
false,
323+
hermesHeaders,
324+
);
325+
fs.writeFileSync(markerPath, marker);
326+
return {reactXcfw, headersXcfw};
327+
}
328+
329+
module.exports = {
330+
computeSpecPlan,
331+
emitReactFrameworkHeaders,
332+
buildReactNativeHeadersXcframework,
333+
ensureHeadersLayout,
334+
DEPS_NAMESPACES,
335+
};

0 commit comments

Comments
 (0)