|
| 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