react-native
Version:
A framework for building native apps using React
497 lines (466 loc) • 18.1 kB
JavaScript
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
*/
;
const {
findPodspecs,
readPodspecCached,
readPodspecNames,
} = require('./read-podspec');
const {
RESERVED_SWIFT_NAMES,
isValidSwiftName,
makeLogger,
swiftNameKey,
toC99Name,
toSwiftName,
} = require('./spm-utils');
const {readSwiftpmConfig, stringList} = require('./swiftpm-config');
const fs = require('node:fs');
const path = require('node:path');
const {warn} = makeLogger('expand-spm-dependencies');
/**
* expand-spm-dependencies.js — Resolves transitive native deps a library
* declares in its `swiftpmConfig`.
*
* SPM has no equivalent of CocoaPods' podspec `s.dependency`, so library
* authors declare the same relationships explicitly:
*
* // react-native-reanimated/package.json
* { "swiftpmConfig": { "dependencies": ["react-native-worklets"] } }
*
* This module reads the directly-autolinked deps (from autolinking.json),
* follows each one's declared dependencies recursively, and returns the deduped
* list with autolinking-shaped entries so the downstream pipeline can convert
* each to an SPM target without further branching.
*
* It also resolves each dep's Swift target name — see resolveSwiftName.
*
* I/O is injected (readConfig, resolveDep, readPodspec) so the logic stays pure
* and testable.
*/
/*::
import type {AutolinkedDep} from './spm-types';
import type {SwiftpmConfig} from './swiftpm-config';
// react-native.config.js entries have a user-defined shape, so we use an
// inexact object type and access properties dynamically.
type RnConfig = {...};
type ReadConfig = (root: string) => ?RnConfig;
type ResolveDep = (name: string, fromRoot: string) => ?string;
// Where a resolved name came from: what the library declared, what its podspec
// says, or the npm-name guess. `podspecKey` narrows a podspec-sourced name to
// the field it was read from — `header_dir` and `module_name` can name the same
// library differently, and only one of them wins.
type PodspecNameKey = 'header_dir' | 'module_name' | 'name';
type ResolvedSwiftName = {
name: string,
source: 'config' | 'podspec' | 'npm',
podspecKey?: PodspecNameKey,
};
// The podspec fields that name a library. A PodspecModel satisfies it.
type PodspecFacts = {
readonly name?: ?string,
readonly moduleName?: ?string,
readonly headerDir?: ?string,
...
};
type ReadPodspec = (root: string, podspecPath: ?string) => ?PodspecFacts;
type ReadSwiftpmConfig = (root: string, rnConfig: ?RnConfig) => ?SwiftpmConfig;
// Keyed by swiftNameKey, valued with the canonical spelling: a name that only
// differs from a reserved one in case or punctuation is not distinct enough for
// the build to keep the two apart.
type ReservedNames = ReadonlyMap<string, string>;
type Options = {
readConfig: ReadConfig,
resolveDep: ResolveDep,
readPodspec?: ?ReadPodspec,
readSwiftpmConfig?: ?ReadSwiftpmConfig,
// Names to reserve alongside RESERVED_SWIFT_NAMES, supplied by the caller
// (remote mode relabels the RN package) since this module reads no config.
extraReservedNames?: ?ReadonlyArray<string>,
};
*/
/**
* A misconfiguration rather than a resolution failure: scaffoldAll degrades past
* a transitive dep it cannot find, but must still surface this.
*/
class SpmNameCollisionError extends Error {
constructor(message /*: string */) {
super(message);
this.name = 'SpmNameCollisionError';
}
}
function reservedSwiftNames(
extraReservedNames /*: ?ReadonlyArray<string> */,
) /*: ReservedNames */ {
return new Map(
[...RESERVED_SWIFT_NAMES, ...(extraReservedNames ?? [])].map(name => [
swiftNameKey(name),
name,
]),
);
}
// The prefix a podspec publishes its headers under, and the field it came from
// — or null when the podspec declares none. A prefix Swift cannot spell is
// normalized rather than abandoned: reverting to the npm name would silently
// produce a prefix nothing imports.
function podspecSwiftName(
npmName /*: string */,
podspec /*: ?PodspecFacts */,
) /*: ?{name: string, key: PodspecNameKey} */ {
// CocoaPods' own order is `module_name` first (specification.rb), but our
// single name is the ObjC include prefix as well as the Swift module: a
// declared `header_dir` IS the prefix a library's consumers write, so it keeps
// winning. `module_name` comes next, being what Swift and `@import` consumers
// spell, and the pod name — often dashed — is the last resort.
const candidates /*: ReadonlyArray<[PodspecNameKey, ?string]> */ = [
['header_dir', podspec?.headerDir],
['module_name', podspec?.moduleName],
['name', podspec?.name],
];
for (const [key, candidate] of candidates) {
if (typeof candidate !== 'string' || candidate.length === 0) {
continue;
}
// Ruby the evaluator never ran: what the prefix would be is unknowable, so
// the npm name is the honest answer — and, not being derived, it is not
// recorded in the library's package.json.
if (candidate.includes('#{')) {
return null;
}
if (isValidSwiftName(candidate)) {
return {name: candidate, key};
}
const normalized = toC99Name(candidate);
warn(
`'${npmName}' declares the podspec prefix '${candidate}', which is not a valid Swift target name; using '${normalized}'. ` +
`Set 'swiftpmConfig.name' in ${npmName}'s package.json to choose the prefix yourself.`,
);
return {name: normalized, key};
}
return null;
}
/**
* The Swift target name for one dep, which is also the prefix its headers are
* imported under (`#import <Name/Header.h>`), and where it came from. The name
* a library declares wins; the podspec is the transitional source of truth
* behind it — `react-native-svg` publishes `RNSVG`, not `ReactNativeSvg` — in
* the order `header_dir` → `module_name` → pod name. The npm name is the last
* resort, for a library that ships a `Package.swift` and no podspec.
*
* The source is what tells the scaffolder which names are safe to write into a
* library's package.json: a derived one, never a guessed one.
*/
function resolveSwiftName(
npmName /*: string */,
spmConfig /*: ?SwiftpmConfig */,
podspec /*: ?PodspecFacts */,
) /*: ResolvedSwiftName */ {
const declared = spmConfig?.name;
if (declared != null) {
if (typeof declared !== 'string' || declared.length === 0) {
throw new Error(
`react-native autolinking: '${npmName}' declares an invalid SwiftPM name: expected a non-empty string, got ${String(declared)}. Set 'swiftpmConfig.name' in its package.json.`,
);
}
if (!isValidSwiftName(declared)) {
throw new Error(
`react-native autolinking: '${npmName}' declares an invalid SwiftPM name '${declared}': must start with a letter or underscore and contain only letters, digits, underscores, or hyphens. Set 'swiftpmConfig.name' in its package.json.`,
);
}
return {name: declared, source: 'config'};
}
const fromPodspec = podspecSwiftName(npmName, podspec);
if (fromPodspec != null) {
return {
name: fromPodspec.name,
source: 'podspec',
podspecKey: fromPodspec.key,
};
}
const fromNpm = toSwiftName(npmName);
if (podspec != null) {
warn(
`'${npmName}' ships a podspec React Native could not read a name from (CocoaPods may not be installed), so it is named '${fromNpm}' after its npm package instead. ` +
`A machine that can read the podspec may resolve a different name — set 'swiftpmConfig.name' in ${npmName}'s package.json to settle it everywhere.`,
);
}
return {name: fromNpm, source: 'npm'};
}
function collisionDiagnosis(
existing /*: string */,
swiftName /*: string */,
) /*: string */ {
if (existing === swiftName) {
return `both resolve to '${swiftName}'.`;
}
if (existing.toLowerCase() === swiftName.toLowerCase()) {
return `differ only in case, which collides on case-insensitive filesystems.`;
}
return `both compile as the module '${toC99Name(swiftName)}' — SwiftPM replaces every character C99 rejects.`;
}
// Vaguer about the clash than the dep-vs-dep message on purpose: this set spans
// package identities and product names, which collide differently.
function reservedDiagnosis(
swiftName /*: string */,
reservedName /*: string */,
) /*: string */ {
if (reservedName === swiftName) {
return `which React Native reserves for its own SPM package and products.`;
}
if (reservedName.toLowerCase() === swiftName.toLowerCase()) {
return `which differs from React Native's reserved '${reservedName}' only in case — not distinct enough for the build to keep the two apart.`;
}
return `which compiles as the same module as React Native's reserved '${reservedName}'.`;
}
function assertNameNotReserved(
swiftName /*: string */,
reserved /*: ReservedNames */,
labels /*: {label: string, remedy: string} */,
) /*: void */ {
const reservedName = reserved.get(swiftNameKey(swiftName));
if (reservedName == null) {
return;
}
throw new SpmNameCollisionError(
`react-native autolinking: SPM Swift name collision: ${labels.label} resolves to '${swiftName}', ` +
reservedDiagnosis(swiftName, reservedName) +
` ${labels.remedy}`,
);
}
/**
* Throws when `swiftName` is one React Native's own manifests use. `remedy` is
* the fix: a library sets `swiftpmConfig.name`, an app renames its
* `swiftpmConfig.modules` entry.
*/
function assertSwiftNameNotReserved(
swiftName /*: string */,
options /*: {
label: string,
remedy: string,
extraReservedNames?: ?ReadonlyArray<string>,
} */,
) /*: void */ {
const {label, remedy, extraReservedNames} = options;
assertNameNotReserved(swiftName, reservedSwiftNames(extraReservedNames), {
label,
remedy,
});
}
// Reserved-name backstop over the resolved set. Unconditional: a plugin-shipping
// library is checked like any other, so `spm scaffold` — which knows nothing
// about plugins — cannot disagree with the autolinker about the same dep.
function assertNoReservedSwiftNames(
deps /*: ReadonlyArray<AutolinkedDep> */,
reserved /*: ReservedNames */,
) /*: void */ {
for (const dep of deps) {
const swiftName = dep.swiftName;
if (swiftName == null) {
continue;
}
assertNameNotReserved(swiftName, reserved, {
label: `'${dep.name}'`,
remedy: `Set a different 'swiftpmConfig.name' in ${dep.name}'s package.json.`,
});
}
}
function expandSpmDependencies(
directDeps /*: Array<AutolinkedDep> */,
options /*: Options */,
) /*: Array<AutolinkedDep> */ {
const {readConfig, resolveDep, readPodspec, extraReservedNames} = options;
const spmConfigOf = options.readSwiftpmConfig ?? readSwiftpmConfig;
const reserved = reservedSwiftNames(extraReservedNames);
const byName /*: Map<string, AutolinkedDep> */ = new Map();
for (const dep of directDeps) {
byName.set(dep.name, {...dep, spmDependencies: []});
}
// A podspec that cannot be read leaves the name to the next precedence step,
// rather than failing a build over a file only CocoaPods needs.
const podspecFor = (
root /*: string */,
podspecPath /*: ?string */,
) /*: ?PodspecFacts */ => {
if (readPodspec == null) {
return null;
}
try {
return readPodspec(root, podspecPath);
} catch {
return null;
}
};
const queue /*: Array<string> */ = directDeps.map(d => d.name);
while (queue.length > 0) {
const currentName = queue.shift();
if (typeof currentName !== 'string') {
continue;
}
const current = byName.get(currentName);
if (current == null) {
continue;
}
const spmConfig = spmConfigOf(current.root, readConfig(current.root));
if (current.swiftName == null) {
const resolved = resolveSwiftName(
currentName,
spmConfig,
podspecFor(current.root, current.platforms.ios.podspecPath),
);
current.swiftName = resolved.name;
current.swiftNameSource = resolved.source;
current.swiftNamePodspecKey = resolved.podspecKey;
}
const transitives = stringList(spmConfig?.dependencies);
const currentSpmDeps /*: Array<string> */ = [];
for (const transitiveName of transitives) {
if (!byName.has(transitiveName)) {
const transitiveRoot = resolveDep(transitiveName, current.root);
if (transitiveRoot == null) {
throw new Error(
`react-native autolinking: '${currentName}' declares an unresolvable SwiftPM dependency '${transitiveName}'. Ensure '${transitiveName}' is installed and visible via Node module resolution from ${current.root}.`,
);
}
const transitiveConfig = readConfig(transitiveRoot);
// $FlowFixMe[prop-missing] config has dynamic shape
const iosPlatform = transitiveConfig?.dependency?.platforms?.ios;
if (iosPlatform == null) {
// No iOS native code — nothing to autolink and nothing to declare
// as an SPM target dep; mirrors the silent skip in
// autolinkingDepToSpmTarget for android-only deps.
continue;
}
const resolved = resolveSwiftName(
transitiveName,
spmConfigOf(transitiveRoot, transitiveConfig),
podspecFor(transitiveRoot, iosPlatform.podspecPath),
);
byName.set(transitiveName, {
name: transitiveName,
root: transitiveRoot,
platforms: {ios: iosPlatform},
swiftName: resolved.name,
swiftNameSource: resolved.source,
swiftNamePodspecKey: resolved.podspecKey,
spmDependencies: [],
});
queue.push(transitiveName);
}
currentSpmDeps.push(transitiveName);
}
current.spmDependencies = currentSpmDeps;
}
const allDeps /*: Array<AutolinkedDep> */ = Array.from(byName.values());
assertNoReservedSwiftNames(allDeps, reserved);
// Collision check: two deps mapping to the same Swift name would clobber each
// other in the synth package layout and the centralized headers tree. Surface
// it now with a clear message instead of letting SPM emit a confusing
// duplicate-target error later. swiftNameKey is what makes two names two
// targets — punctuation collapses into one module, case into one directory.
const seen /*: Map<string, {name: string, swiftName: string}> */ = new Map();
for (const dep of allDeps) {
const swiftName = dep.swiftName;
if (swiftName == null) {
continue;
}
const key = swiftNameKey(swiftName);
const existing = seen.get(key);
if (existing != null) {
throw new SpmNameCollisionError(
`react-native autolinking: SPM Swift name collision: '${existing.name}' ('${existing.swiftName}') and '${dep.name}' ('${swiftName}') ` +
collisionDiagnosis(existing.swiftName, swiftName) +
` Set a distinct 'swiftpmConfig.name' in one of their package.json files.`,
);
}
seen.set(key, {name: dep.name, swiftName});
}
return allDeps;
}
// ---------------------------------------------------------------------------
// Default I/O implementations
// ---------------------------------------------------------------------------
function defaultReadConfig(root /*: string */) /*: ?RnConfig */ {
const configPath = path.join(root, 'react-native.config.js');
if (!fs.existsSync(configPath)) {
return null;
}
try {
// $FlowFixMe[unsupported-syntax]
const mod = require(configPath);
// Read both export styles, because the community CLI's two loaders
// disagree with each other: its sync path (`loadConfig`) requires the
// module and sees named exports at top level, its async path
// (`loadConfigAsync`) takes the default export only. Merging covers both,
// with named exports winning — the shape the sync path already resolves.
// Every sibling key of the default export is preserved
// (`dependency.platforms.ios` is read from this result too).
// A function-style config (`module.exports = () => ({...})`) and other
// non-objects pass through untouched — there is no default export to
// unwrap, and nulling them would hide a config that used to be read.
if (mod == null || typeof mod !== 'object') {
return mod;
}
const dflt = mod.default;
if (dflt == null || typeof dflt !== 'object') {
return mod;
}
const {default: _unused, ...named} = mod;
return {...dflt, ...named};
} catch (e) {
// A config can fail to load for reasons unrelated to SPM (it may import a
// devDependency absent in a consumer install), so this stays a warning —
// but a silent null turns a dropped `spm` block into a link error much
// later.
warn(
`Failed to load ${configPath}: ${e.message}. Any 'spm' settings in it are ignored.`,
);
return null;
}
}
function defaultResolveDep(
name /*: string */,
fromRoot /*: string */,
) /*: ?string */ {
try {
const pkgJsonPath = require.resolve(`${name}/package.json`, {
paths: [fromRoot],
});
return path.dirname(pkgJsonPath);
} catch {
return null;
}
}
function defaultReadPodspec(
root /*: string */,
podspecPath /*: ?string */,
) /*: ?PodspecFacts */ {
// Deps synthesized from declared dependencies carry no podspecPath — only
// autolinking.json records one — so the dep root is searched as well.
const found = podspecPath ?? findPodspecs(root)[0];
if (found == null) {
return null;
}
try {
// Ruby-computed fields need the real evaluator; everything else is spared
// the `pod ipc spec` spawn.
return readPodspecNames(found) ?? readPodspecCached(found);
} catch {
return null;
}
}
module.exports = {
SpmNameCollisionError,
assertSwiftNameNotReserved,
expandSpmDependencies,
resolveSwiftName,
defaultReadConfig,
defaultReadPodspec,
defaultResolveDep,
};