UNPKG

react-native

Version:

A framework for building native apps using React

415 lines (404 loc) • 16.4 kB
/** * 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 * @format */ /** * SwiftPM autolinking plugins — the extension seam for frameworks (Expo first) * that need to contribute their own SwiftPM package refs / product deps / * generated sources into the autolinked package graph. * * PREVIEW / UNSTABLE CONTRACT. The discovery mechanism and the plugin * function's context/return shape may change while the first real consumers * (Expo) validate it. * * Why a plugin and not a post-process: the Xcode auto-sync build phase * re-runs autolinking on every dependency change, so a one-shot rewrite of the * generated Package.swift is clobbered on the next sync. Plugins are invoked * from generate-spm-autolinking.js:main() — the single function both `add`/ * `update` and the build-time `sync` call — so a contribution runs on EVERY * regeneration. * * Discovery (transitive, mirrors CocoaPods' `use_expo_modules!`): any * autolinked dependency can register a plugin from its own * react-native.config.js — * * // node_modules/expo/react-native.config.js * module.exports = { spm: { autolinkingPlugin: './spm/plugin.js' } }; * * Installing the framework is enough; no app-level config is required. An app * MAY still exclude a plugin via `spm.denyPlugins` (npm-name list) in its own * react-native.config.js — an escape hatch, not a required allowlist. * * Contract: * * module.exports = function plugin(context) { * // context: {appRoot, projectRoot, reactNativeRoot, autolinking, * // outputDir, react} * // context.react (?ReactDescriptor): how to depend on React — * // {packageRef, products}. packageRef is local ({name, path (absolute), * // relPath}) or remote ({name, url, version}); products is the set RN * // gives its own autolinked targets (incl. ReactAppHeaders from the * // separate React-GeneratedCode package), filtered to what resolves * // this run. Use it instead of re-deriving RN's path/identity/products. * return { * packageDependencies: [{name, path}] | [{name, url, version}], * productDependencies: [{name, package}], * // Sources compiled INTO THE APP TARGET (not the static Autolinked * // aggregate) — the only place an @objc class reaches the ObjC * // classlist, so emit files here when a class must be reachable by * // name (e.g. via NSClassFromString), as Expo's ExpoModulesProvider * // needs. Recorded to `.spm-plugin-generated-sources.json`; the * // `spm add`/`update` xcodeproj injector wires each into the app * // target (PBXFileReference + Sources-phase entry), marker-tracked * // so deinit reverts and update reconciles. * generatedSources: [{path}], * // Precompiled dynamic frameworks selected and embedded by RN outside * // the SwiftPM graph. Both flavors are mandatory. * flavoredFrameworks: [{id, frameworkName, linkage: 'dynamic', * flavors: {debug, release}}], * // Absolute paths (dirs OR files) the Xcode auto-sync build phase * // should watch for staleness — e.g. the plugin dep's own * // `Package.swift`, `expo-module.config.json`, and per-module * // manifests. Folded into `.spm-sync-watch-paths`; a file edit or a * // dir add/remove there re-triggers the sync. Relative/empty/non-string * // entries are dropped with a warning; a non-array is ignored. * watchPaths: ['/abs/path/Package.swift', '/abs/dir'], * // Build-time shell phases on the app target — SwiftPM has no * // `script_phase`. Only id/name/script are required; `id` is the * // ledger key and UUID seed, `position` defaults to 'end'. Recorded * // to `.spm-plugin-script-phases.json`, from which the `spm add`/ * // `update` injector emits a PBXShellScriptBuildPhase per entry. * // Malformed entries and duplicate ids are fatal. * scriptPhases: [{id, name, script, position, inputPaths, outputPaths, * alwaysOutOfDate}], * }; * }; * * The plugin returns DATA; it never writes into RN's generated tree. RN owns * the merge, so regeneration stays deterministic and idempotent. */ const {isValidScriptPhaseId, isValidScriptPhaseName} = require('./spm-utils'); const {readSwiftpmConfig} = require('./swiftpm-config'); const path = require('node:path'); /*:: import type { AutolinkedDep, PluginContext, PluginResult, PluginScriptPhase, DiscoveredPlugin, } from './spm-types'; */ /** * Discover plugins declared by autolinked deps. `readConfig(root)` returns the * dep's parsed react-native.config.js (or null), which is one of the two homes * the plugin may be declared in. `denyList` is the app's `denyPlugins`. Fail-closed: a declared-but-missing * or unloadable plugin throws, naming the dep — a framework silently dropping * its modules is worse than a loud stop. */ function discoverPlugins( deps /*: ReadonlyArray<AutolinkedDep> */, readConfig /*: (root: string) => ?{readonly [string]: unknown} */, denyList /*: ReadonlyArray<string> */ = [], ) /*: Array<DiscoveredPlugin> */ { const denied = new Set(denyList); const found /*: Array<DiscoveredPlugin> */ = []; for (const dep of deps) { if (denied.has(dep.name)) { continue; } // $FlowFixMe[incompatible-use] config has a dynamic shape const rel = readSwiftpmConfig( dep.root, readConfig(dep.root), )?.autolinkingPlugin; if (rel == null) { continue; } if (typeof rel !== 'string' || rel.length === 0) { throw new Error( `react-native spm: '${dep.name}' declares an invalid 'swiftpmConfig.autolinkingPlugin' ` + `(expected a module path string).`, ); } const pluginPath = path.resolve(dep.root, rel); let fn /*: unknown */ = null; try { // $FlowFixMe[unsupported-syntax] dynamic require by computed path fn = require(pluginPath); } catch (e) { throw new Error( `react-native spm: failed to load the autolinking plugin for '${dep.name}' ` + `at ${pluginPath}: ${e.message}`, ); } // Support `module.exports = fn` and `{default: fn}` / `{plugin: fn}`. const resolved = typeof fn === 'function' ? fn : // $FlowFixMe[incompatible-use] interop shapes typeof fn?.default === 'function' ? fn.default : // $FlowFixMe[incompatible-use] typeof fn?.plugin === 'function' ? fn.plugin : null; if (resolved == null) { throw new Error( `react-native spm: the autolinking plugin for '${dep.name}' at ${pluginPath} ` + `does not export a function.`, ); } found.push({depName: dep.name, pluginPath, plugin: resolved}); } return found; } const isNonEmptyString = (value /*: unknown */) /*: boolean */ => typeof value === 'string' && value.length > 0; const isOptionalStringList = (value /*: unknown */) /*: boolean */ => value == null || (Array.isArray(value) && value.every(isNonEmptyString)); /** * Invoke discovered plugins and merge their results. Each plugin gets the same * context. Fail-closed: a throwing plugin aborts (named), and a malformed * return is rejected. Dedupe package + product deps by name so a plugin and an * npm dep that both reference the same package don't double-declare. */ function invokePlugins( plugins /*: ReadonlyArray<DiscoveredPlugin> */, context /*: PluginContext */, logger /*: {warn: (msg: string) => void} */ = { warn: msg => console.warn(msg), }, ) /*: PluginResult */ { const packageDependencies /*: Array<{name: string, path?: string, url?: string, version?: string}> */ = []; const productDependencies /*: Array<{name: string, package: string}> */ = []; const generatedSources /*: Array<{path: string}> */ = []; const flavoredFrameworks /*: Array<{id: string, frameworkName: string, linkage: 'dynamic', flavors: {debug: string, release: string}}> */ = []; const watchPaths /*: Array<string> */ = []; const scriptPhases /*: Array<PluginScriptPhase> */ = []; const seenPackages /*: Set<string> */ = new Set(); const seenProducts /*: Set<string> */ = new Set(); const seenFrameworkIds /*: Set<string> */ = new Set(); const seenFrameworkNames /*: Set<string> */ = new Set(); const seenScriptPhaseIds /*: Set<string> */ = new Set(); for (const {depName, pluginPath, plugin} of plugins) { let result /*: unknown */ = null; try { result = plugin(context); } catch (e) { throw new Error( `react-native spm: the autolinking plugin for '${depName}' (${pluginPath}) ` + `threw: ${e.message}`, ); } if (result == null) { continue; } if (typeof result !== 'object') { throw new Error( `react-native spm: the autolinking plugin for '${depName}' returned a ` + `${typeof result}; expected an object or undefined.`, ); } // $FlowFixMe[incompatible-use] validated field-by-field below const pkgs = result.packageDependencies ?? []; // $FlowFixMe[incompatible-use] const prods = result.productDependencies ?? []; // $FlowFixMe[incompatible-use] const srcs = result.generatedSources ?? []; // $FlowFixMe[incompatible-use] const rawFrameworks = result.flavoredFrameworks ?? []; // $FlowFixMe[incompatible-use] const rawWatch = result.watchPaths ?? []; // $FlowFixMe[incompatible-use] const rawScriptPhases = result.scriptPhases ?? []; if (!Array.isArray(rawFrameworks)) { throw new Error( `react-native spm: '${depName}' returned a non-array flavoredFrameworks.`, ); } if (!Array.isArray(rawScriptPhases)) { throw new Error( `react-native spm: '${depName}' returned a non-array scriptPhases.`, ); } // Watch paths are best-effort staleness hints, so // a non-array is ignored (warn, never fatal). if (!Array.isArray(rawWatch)) { logger.warn( `react-native spm: '${depName}' returned a non-array watchPaths ` + `— ignoring it.`, ); } const watch = Array.isArray(rawWatch) ? rawWatch : []; for (const p of pkgs) { if (p == null || typeof p.name !== 'string') { throw new Error( `react-native spm: '${depName}' returned a packageDependency without a name.`, ); } if (p.path == null && (p.url == null || p.version == null)) { throw new Error( `react-native spm: '${depName}' packageDependency '${p.name}' needs either ` + `a path or a url+version.`, ); } if (!seenPackages.has(p.name)) { seenPackages.add(p.name); packageDependencies.push(p); } } for (const p of prods) { if ( p == null || typeof p.name !== 'string' || typeof p.package !== 'string' ) { throw new Error( `react-native spm: '${depName}' returned a productDependency needing name + package.`, ); } const key = `${p.package}/${p.name}`; if (!seenProducts.has(key)) { seenProducts.add(key); productDependencies.push(p); } } for (const s of srcs) { if (s == null || typeof s.path !== 'string') { throw new Error( `react-native spm: '${depName}' returned a generatedSource without a path.`, ); } generatedSources.push(s); } for (const framework of rawFrameworks) { if ( framework == null || typeof framework.id !== 'string' || !/^[A-Za-z0-9_.-]+$/.test(framework.id) || typeof framework.frameworkName !== 'string' || framework.frameworkName.length === 0 || framework.linkage !== 'dynamic' || framework.flavors == null || typeof framework.flavors.debug !== 'string' || typeof framework.flavors.release !== 'string' || !path.isAbsolute(framework.flavors.debug) || !path.isAbsolute(framework.flavors.release) ) { throw new Error( `react-native spm: '${depName}' returned an invalid flavoredFramework ` + '(need {id, frameworkName, linkage: "dynamic", flavors: ' + '{debug, release}} with absolute flavor paths).', ); } if (seenFrameworkIds.has(framework.id)) { throw new Error( `react-native spm: duplicate flavored framework id '${framework.id}'.`, ); } if (seenFrameworkNames.has(framework.frameworkName)) { throw new Error( `react-native spm: multiple plugins embed '${framework.frameworkName}.framework'.`, ); } seenFrameworkIds.add(framework.id); seenFrameworkNames.add(framework.frameworkName); flavoredFrameworks.push({ id: framework.id, frameworkName: framework.frameworkName, linkage: 'dynamic', flavors: { debug: framework.flavors.debug, release: framework.flavors.release, }, }); } // watchPaths are dropped (with a warning), not fatal. A non-string, empty, // or relative entry only // means one staleness input is missed, not a broken build. Absolute-only so // the generated phase (which has no cwd context) can test them directly. for (const w of watch) { if (typeof w !== 'string' || w.length === 0 || !path.isAbsolute(w)) { logger.warn( `react-native spm: '${depName}' returned an invalid watchPath ` + `(need a non-empty absolute path string) — dropping it.`, ); continue; } watchPaths.push(w); } // Fatal like flavoredFrameworks, not dropped like watchPaths: a missing // phase produces a GREEN build whose generated content was never written. for (const phase of rawScriptPhases) { if ( phase == null || !isValidScriptPhaseId(phase.id) || !isNonEmptyString(phase.script) || (phase.position != null && phase.position !== 'beforeCompile' && phase.position !== 'end') || !isOptionalStringList(phase.inputPaths) || !isOptionalStringList(phase.outputPaths) || (phase.alwaysOutOfDate != null && typeof phase.alwaysOutOfDate !== 'boolean') ) { const named = typeof phase?.id === 'string' ? ` '${phase.id}'` : ''; throw new Error( `react-native spm: '${depName}' returned an invalid scriptPhase${named} ` + '(need {id (matching /^[@A-Za-z0-9_./-]+$/, and not __proto__, ' + 'constructor or prototype), name, script} plus optional ' + '{position: "beforeCompile" | "end", inputPaths, outputPaths, ' + 'alwaysOutOfDate}).', ); } if (!isValidScriptPhaseName(phase.name)) { throw new Error( `react-native spm: '${depName}' returned an invalid scriptPhase name for ` + `'${phase.id}' (a name must be a non-empty single-line string — it is ` + "the phase's display name in Xcode).", ); } if (seenScriptPhaseIds.has(phase.id)) { throw new Error( `react-native spm: duplicate script phase id '${phase.id}'.`, ); } seenScriptPhaseIds.add(phase.id); const normalized /*: PluginScriptPhase */ = { id: phase.id, name: phase.name, script: phase.script, position: phase.position ?? 'end', }; if (phase.inputPaths != null) { normalized.inputPaths = [...phase.inputPaths]; } if (phase.outputPaths != null) { normalized.outputPaths = [...phase.outputPaths]; } if (phase.alwaysOutOfDate != null) { normalized.alwaysOutOfDate = phase.alwaysOutOfDate; } scriptPhases.push(normalized); } } return { packageDependencies, productDependencies, generatedSources, flavoredFrameworks, watchPaths, scriptPhases, }; } module.exports = { discoverPlugins, invokePlugins, };