expo-modules-autolinking 55.0.18 → 55.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,21 @@
10
10
 
11
11
  ### 💡 Others
12
12
 
13
+ ## 55.0.20 — 2026-05-05
14
+
15
+ _This version does not introduce any user-facing changes._
16
+
17
+ ## 55.0.19 — 2026-04-28
18
+
19
+ ### 🐛 Bug fixes
20
+
21
+ - [iOS] Disable precompiled modules with a clear warning when `EXPO_USE_PRECOMPILED_MODULES=1` is set without `RCT_USE_PREBUILT_RNCORE=1` ([#45381](https://github.com/expo/expo/pull/45381) by [@chrfalch](https://github.com/chrfalch))
22
+ - [iOS] Fall back to source build when a remote precompiled XCFramework download returns 404. ([#45240](https://github.com/expo/expo/pull/45240) by [@chrfalch](https://github.com/chrfalch))
23
+ - [iOS] Resolve npm-bundled precompiled XCFrameworks for nested Expo packages like `expo-modules-core` during `pod install`. ([#45166](https://github.com/expo/expo/pull/45166) by [@chrfalch](https://github.com/chrfalch))
24
+ - [iOS] Build precompiled Expo modules from source when a required upstream Expo dependency is unavailable as prebuilt. ([#45160](https://github.com/expo/expo/pull/45160) by [@chrfalch](https://github.com/chrfalch))
25
+ - [iOS] Add support for optionally downloading external precompiled XCFramework tarballs from during `pod install`. ([#45067](https://github.com/expo/expo/pull/45067) by [@chrfalch](https://github.com/chrfalch))
26
+ - [iOS] Resolve 3rd-party prebuilt xcframework packages via `@react-native-community/cli` autolinking output instead of guessing `node_modules/<pkg>`, fixing pnpm non-hoisted layouts, transitive native deps, yarn resolutions/PnP, and aliased specifiers ([#45004](https://github.com/expo/expo/pull/45004) by [@chrfalch](https://github.com/chrfalch))
27
+
13
28
  ## 55.0.18 — 2026-04-21
14
29
 
15
30
  ### 🐛 Bug fixes
@@ -36,6 +36,14 @@ Configs live in `packages/expo-modules-autolinking/external-configs/ios/<package
36
36
  - **Config location**: `packages/expo-modules-autolinking/external-configs/ios/<package-name>/spm.config.json`
37
37
  - **Source location**: `node_modules/<package-name>/` (resolved at build time)
38
38
  - **Output location**: `packages/precompile/.build/<package-name>/output/<flavor>/xcframeworks/`
39
+ - **Remote fallback**: external package prebuilds can download missing tarballs from a remote host when `EXPO_PRECOMPILED_MODULES_BASE_URL` is set.
40
+
41
+ ### Remote Artifact Downloads
42
+
43
+ Remote downloads are opt-in via `EXPO_PRECOMPILED_MODULES_BASE_URL`.
44
+
45
+ - Supports `http://` and `https://` URLs.
46
+ - Final download path: `<baseUrl>/<npm-package>/output/<packageVersion>/<reactNativeVersion>/<hermesVersion>/<flavor>/xcframeworks/<Product>.tar.gz` when versions are known, otherwise `<baseUrl>/<npm-package>/output/<flavor>/xcframeworks/<Product>.tar.gz`
39
47
 
40
48
  ### The SPMPackageSource Interface
41
49
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-modules-autolinking",
3
- "version": "55.0.18",
3
+ "version": "55.0.20",
4
4
  "description": "Scripts that autolink Expo modules.",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -43,5 +43,5 @@
43
43
  "chalk": "^4.1.0",
44
44
  "commander": "^7.2.0"
45
45
  },
46
- "gitHead": "e37e614d97c3ca53f16b91609a787675d044c284"
46
+ "gitHead": "17007db30c487d21b82548365b84180a73af25ff"
47
47
  }
@@ -28,6 +28,8 @@
28
28
 
29
29
  require 'fileutils'
30
30
  require 'json'
31
+ require 'net/http'
32
+ require 'set'
31
33
  require 'uri'
32
34
 
33
35
  module Expo
@@ -41,6 +43,9 @@ module Expo
41
43
  # Environment variable for custom precompiled modules base path
42
44
  MODULES_PATH_ENV_VAR = 'EXPO_PRECOMPILED_MODULES_PATH'.freeze
43
45
 
46
+ # Environment variable for a shared remote base URL used by external prebuilt packages.
47
+ EXTERNAL_MODULES_BASE_URL_ENV_VAR = 'EXPO_PRECOMPILED_MODULES_BASE_URL'.freeze
48
+
44
49
  # Subdirectory within each pod dir for tarballs and build state
45
50
  ARTIFACTS_DIR_NAME = 'artifacts'.freeze
46
51
 
@@ -56,6 +61,10 @@ module Expo
56
61
  # Regex to strip `framework module React { ... }` from modulemaps
57
62
  FRAMEWORK_MODULE_REACT_REGEX = /framework module React \{.*?\n\}\s*/m
58
63
 
64
+ # ExpoModulesJSI is always provided as an xcframework by its own podspec/npm package,
65
+ # so it is not resolved through the Expo precompiled tarball pipeline.
66
+ CUSTOM_XCFRAMEWORK_DEPENDENCIES = %w[ExpoModulesJSI].freeze
67
+
59
68
  # Module-level caches (initialized lazily)
60
69
  @pod_lookup_map = nil
61
70
  @repo_root = nil
@@ -65,6 +74,8 @@ module Expo
65
74
  @hermes_version = nil
66
75
  @claimed_vendored_frameworks = nil # Set<String> — xcframework names already claimed by a prebuilt pod
67
76
  @framework_owner_map = nil # Hash: framework_name -> owning_pod_name
77
+ @failed_remote_downloads = Set.new
78
+ @warned_no_prebuilt_react = false
68
79
 
69
80
  class << self
70
81
  # Returns the build flavor (debug/release) for precompiled modules.
@@ -81,9 +92,25 @@ module Expo
81
92
  ENV[MODULES_PATH_ENV_VAR]
82
93
  end
83
94
 
84
- # Returns true if precompiled modules are enabled via environment variable
95
+ # Returns the shared base URL for remote external prebuilt artifacts, if set.
96
+ def external_modules_base_url
97
+ ENV[EXTERNAL_MODULES_BASE_URL_ENV_VAR]
98
+ end
99
+
100
+ # Returns true if precompiled modules are enabled via environment variable.
101
+ # Precompiled module xcframeworks are linked against the prebuilt
102
+ # React.xcframework, so they also require RCT_USE_PREBUILT_RNCORE=1. If
103
+ # the user opted in to precompiled modules but React Native is still set
104
+ # to build from source, fall back to source-built modules and warn once.
85
105
  def enabled?
86
- ENV[ENV_VAR] == '1'
106
+ return false unless ENV[ENV_VAR] == '1'
107
+ return true if prebuilt_react_active?
108
+
109
+ unless @warned_no_prebuilt_react
110
+ @warned_no_prebuilt_react = true
111
+ Pod::UI.warn "[Expo] EXPO_USE_PRECOMPILED_MODULES=1 was set, but React Native is configured to build from source (RCT_USE_PREBUILT_RNCORE is not 1). Precompiled Expo modules require the prebuilt React.xcframework, so every Expo module will be built from source for this install. To use precompiled modules, ensure `ios.buildReactNativeFromSource` is not `true` in the `expo-build-properties` plugin (the default uses the prebuilt framework), or export RCT_USE_PREBUILT_RNCORE=1 before running `pod install`."
112
+ end
113
+ false
87
114
  end
88
115
 
89
116
  # Sets the list of package name patterns that should be built from source
@@ -104,6 +131,19 @@ module Expo
104
131
  }
105
132
  end
106
133
 
134
+ # @react-native-community/cli autolinking output (same shape as RN CLI's `config`).
135
+ # Used to locate 3rd-party packages via real node resolution so non-flat node_modules
136
+ # layouts (pnpm non-hoisted, yarn PnP, aliased specifiers) resolve correctly.
137
+ def react_native_config
138
+ @react_native_config ||= invoke_autolinking('react-native-config', platform: 'ios')
139
+ end
140
+
141
+ # The resolved Expo modules list. Used by scan_node_modules_configs to locate
142
+ # each internal package's spm.config.json via its resolved podspec dir.
143
+ def resolved_modules
144
+ @resolved_modules ||= invoke_autolinking('resolve', platform: 'apple').fetch('modules', [])
145
+ end
146
+
107
147
  # ──────────────────────────────────────────────────────────────────────
108
148
  # Facade methods — called from installer.rb / autolinking_manager.rb
109
149
  # ──────────────────────────────────────────────────────────────────────
@@ -213,7 +253,7 @@ module Expo
213
253
  def has_prebuilt_xcframework?(pod_name)
214
254
  return false unless enabled?
215
255
 
216
- !resolve_prebuilt_info(pod_name).nil?
256
+ resolve_prebuilt_status(pod_name)[:available]
217
257
  end
218
258
 
219
259
  # Returns whether test specs should be included for a pod.
@@ -456,13 +496,13 @@ module Expo
456
496
  def try_link_with_prebuilt_xcframework(spec)
457
497
  return false unless enabled?
458
498
 
459
- resolved = resolve_prebuilt_info(spec.name)
460
- unless resolved
461
- log_linking_status(spec.name, false, "no prebuilt xcframework available")
499
+ resolution = resolve_prebuilt_status(spec.name)
500
+ unless resolution[:available]
501
+ log_linking_status(spec.name, false, resolution)
462
502
  return false
463
503
  end
464
504
 
465
- pod_info, product_name, default_tarball = resolved
505
+ pod_info, product_name, default_tarball = resolution[:resolved]
466
506
 
467
507
  log_linking_status(spec.name, true, default_tarball)
468
508
 
@@ -491,10 +531,13 @@ module Expo
491
531
  # @param spec [Pod::Specification] The podspec to patch
492
532
  # @return [Pod::Specification] A new patched specification (or original on failure)
493
533
  def patch_spec_for_prebuilt(spec)
494
- resolved = resolve_prebuilt_info(spec.name)
495
- return spec unless resolved
534
+ resolution = resolve_prebuilt_status(spec.name)
535
+ unless resolution[:available]
536
+ log_linking_status(spec.name, false, resolution) if resolution[:reason] == :dependency_unavailable
537
+ return spec
538
+ end
496
539
 
497
- pod_info, product_name, default_tarball = resolved
540
+ pod_info, product_name, default_tarball = resolution[:resolved]
498
541
 
499
542
  log_linking_status(spec.name, true, default_tarball)
500
543
 
@@ -563,13 +606,19 @@ module Expo
563
606
  prefix = "[Expo-precompiled] ".blue
564
607
  Pod::UI.info "#{prefix}Precompiled modules:"
565
608
  @linked_pods.sort_by { |name, _| name.downcase }.each do |pod_name, info|
609
+ version = installed_version_for(pod_name)
610
+ version_suffix = version ? " #{"(#{version})".dark}" : ""
566
611
  if info[:found]
567
- Pod::UI.info "#{prefix} 📦 #{pod_name.green}"
612
+ Pod::UI.info "#{prefix} 📦 #{pod_name.green}#{version_suffix}"
568
613
  else
569
- Pod::UI.info "#{prefix} ⚠️ #{pod_name} #{"(Build from source: framework not found #{info[:path]})".yellow}"
614
+ reason = format_prebuilt_unavailable_reason(info)
615
+ Pod::UI.info "#{prefix} ⚠️ #{pod_name}#{version_suffix} #{"(#{reason})".dark}"
570
616
  end
617
+ spm_versions = pod_lookup_map.dig(pod_name, :spm_dependency_versions) || {}
571
618
  info[:spm_deps].each do |dep_name|
572
- Pod::UI.info "#{prefix} ∟ #{dep_name}.xcframework".green
619
+ dep_version = spm_versions[dep_name]
620
+ dep_suffix = dep_version ? " #{"(#{dep_version})".dark}" : ""
621
+ Pod::UI.info "#{prefix} ∟ #{"#{dep_name}.xcframework".green}#{dep_suffix}"
573
622
  end
574
623
  end
575
624
 
@@ -578,6 +627,19 @@ module Expo
578
627
  end
579
628
  end
580
629
 
630
+ # Returns the installed version for a pod by reading its package.json, or nil.
631
+ def installed_version_for(pod_name)
632
+ package_root = pod_lookup_map.dig(pod_name, :package_root)
633
+ return nil unless package_root
634
+
635
+ pkg_json = File.join(package_root, 'package.json')
636
+ return nil unless File.exist?(pkg_json)
637
+
638
+ JSON.parse(File.read(pkg_json))['version']
639
+ rescue JSON::ParserError, Errno::ENOENT
640
+ nil
641
+ end
642
+
581
643
  # ──────────────────────────────────────────────────────────────────────
582
644
  # Post-install configuration steps
583
645
  # ──────────────────────────────────────────────────────────────────────
@@ -606,7 +668,7 @@ module Expo
606
668
 
607
669
  # Copy both flavor tarballs
608
670
  ['debug', 'release'].each do |flavor|
609
- src = File.join(build_output_dir, flavor, 'xcframeworks', "#{product_name}.tar.gz")
671
+ src = resolve_prebuilt_tarball(info, product_name, flavor, pod_name)
610
672
  dst = File.join(artifacts_dir, "#{product_name}-#{flavor}.tar.gz")
611
673
  FileUtils.cp(src, dst) if File.exist?(src) && !File.exist?(dst)
612
674
  end
@@ -618,7 +680,7 @@ module Expo
618
680
  # Self-healing: extract xcframework if missing (CocoaPods cache issue)
619
681
  xcframework_dir = File.join(pod_dir, "#{product_name}.xcframework")
620
682
  unless File.directory?(xcframework_dir)
621
- tarball = File.join(build_output_dir, build_flavor, 'xcframeworks', "#{product_name}.tar.gz")
683
+ tarball = resolve_prebuilt_tarball(info, product_name, build_flavor, pod_name)
622
684
  if File.exist?(tarball)
623
685
  Pod::UI.info "#{'[Expo-precompiled] '.blue}Extracting #{product_name}.xcframework (cache miss)"
624
686
  system("tar", "xzf", tarball, "-C", pod_dir)
@@ -1224,68 +1286,76 @@ module Expo
1224
1286
  scan_external_configs(repo_root)
1225
1287
  end
1226
1288
 
1227
- # Scans node_modules for spm.config.json files in standalone projects.
1228
- # Used when EXPO_PRECOMPILED_MODULES_PATH is set but no monorepo root is found.
1289
+ # Locates spm.config.json for internal Expo modules in standalone projects
1290
+ # (no monorepo root). Uses resolved podspec dirs so non-flat layouts work.
1229
1291
  def scan_node_modules_configs(project_root)
1230
- node_modules = File.join(project_root, 'node_modules')
1231
- return unless File.directory?(node_modules)
1292
+ resolved_modules.each do |mod|
1293
+ podspec_dir = (mod['pods'] || []).map { |pod| pod['podspecDir'] }.compact.first
1294
+ next unless podspec_dir
1232
1295
 
1233
- # Internal Expo packages: node_modules/*/spm.config.json
1234
- Dir.glob(File.join(node_modules, '*', 'spm.config.json')).each do |config_path|
1235
- process_spm_config(config_path, :internal, project_root)
1236
- end
1296
+ config_path = spm_config_path_for_podspec_dir(podspec_dir)
1297
+ next unless config_path
1237
1298
 
1238
- # Internal Expo scoped packages: node_modules/@scope/*/spm.config.json
1239
- Dir.glob(File.join(node_modules, '@*', '*', 'spm.config.json')).each do |config_path|
1240
1299
  process_spm_config(config_path, :internal, project_root)
1241
1300
  end
1242
1301
 
1243
- # External 3rd-party packages: bundled in external-configs/ios/
1244
1302
  scan_external_configs(project_root)
1245
1303
  end
1246
1304
 
1305
+ # Resolves spm.config.json from an Expo autolinking podspecDir. Most modules
1306
+ # report `<package>/ios`, while packages with root podspecs report `<package>`.
1307
+ def spm_config_path_for_podspec_dir(podspec_dir)
1308
+ [podspec_dir, File.dirname(podspec_dir)].uniq.each do |package_dir|
1309
+ config_path = File.join(package_dir, 'spm.config.json')
1310
+ return config_path if File.exist?(config_path)
1311
+ end
1312
+
1313
+ nil
1314
+ end
1315
+
1247
1316
  # Scans spm.config.json files from external-configs/ios/ for 3rd-party packages
1248
1317
  # (e.g. react-native-screens, react-native-svg) that don't ship their own spm.config.json.
1249
1318
  # Shared by both monorepo and standalone project paths.
1250
1319
  #
1251
- # @param effective_root [String] The project or repo root used to locate node_modules
1320
+ # @param effective_root [String] The project or repo root (used to compute the default build output dir)
1252
1321
  def scan_external_configs(effective_root)
1253
1322
  external_configs_dir = File.join(__dir__, '..', '..', 'external-configs', 'ios')
1254
1323
  return unless File.directory?(external_configs_dir)
1255
1324
 
1256
- node_modules = File.join(effective_root, 'node_modules')
1257
-
1258
1325
  # Non-scoped: external-configs/ios/*/spm.config.json
1259
1326
  Dir.glob(File.join(external_configs_dir, '*', 'spm.config.json')).each do |config_path|
1260
1327
  npm_package = File.basename(File.dirname(config_path))
1261
- process_external_config(config_path, npm_package, node_modules, effective_root)
1328
+ process_external_config(config_path, npm_package, effective_root)
1262
1329
  end
1263
1330
 
1264
1331
  # Scoped: external-configs/ios/@scope/*/spm.config.json
1265
1332
  Dir.glob(File.join(external_configs_dir, '@*', '*', 'spm.config.json')).each do |config_path|
1266
1333
  rel = config_path.sub("#{external_configs_dir}/", '')
1267
1334
  npm_package = File.dirname(rel) # e.g. "@shopify/react-native-skia"
1268
- process_external_config(config_path, npm_package, node_modules, effective_root)
1335
+ process_external_config(config_path, npm_package, effective_root)
1269
1336
  end
1270
1337
  end
1271
1338
 
1272
1339
  # Processes a single external spm.config.json for a 3rd-party package.
1273
- def process_external_config(config_path, npm_package, node_modules, effective_root)
1340
+ def process_external_config(config_path, npm_package, effective_root)
1274
1341
  config = JSON.parse(File.read(config_path))
1275
1342
  products = config['products'] || []
1276
- package_root = File.join(node_modules, npm_package)
1277
1343
 
1278
- # Only process if the package is actually installed
1279
- return unless File.directory?(package_root)
1344
+ # Resolve via rncli autolinking so we use real node resolution (handles pnpm,
1345
+ # yarn resolutions/PnP, aliased specifiers). Skip if the package isn't installed.
1346
+ dep = react_native_config.dig('dependencies', npm_package)
1347
+ return unless dep
1348
+
1349
+ package_root = dep['root']
1350
+ pkg_version = dep.dig('platforms', 'ios', 'version')
1280
1351
 
1281
- # Prefer codegenConfig.name from the installed package.json
1352
+ # codegenConfig.name isn't surfaced by rncli; read it from package.json.
1282
1353
  installed_codegen_name = nil
1283
- pkg_version = nil
1284
1354
  pkg_json_path = File.join(package_root, 'package.json')
1285
1355
  if File.exist?(pkg_json_path)
1286
1356
  pkg_json = JSON.parse(File.read(pkg_json_path))
1287
1357
  installed_codegen_name = pkg_json.dig('codegenConfig', 'name')
1288
- pkg_version = pkg_json['version']
1358
+ pkg_version ||= pkg_json['version']
1289
1359
  end
1290
1360
 
1291
1361
  base_dir = custom_modules_path || File.join(effective_root, 'packages', 'precompile', PRECOMPILE_BUILD_DIR)
@@ -1326,6 +1396,7 @@ module Expo
1326
1396
  .map { |t| { name: t['name'], path: t['path'] } }
1327
1397
 
1328
1398
  spm_dependency_frameworks = (product['spmPackages'] || []).map { |pkg| pkg['productName'] }.compact
1399
+ spm_dependency_versions = spm_package_versions(product['spmPackages'])
1329
1400
 
1330
1401
  @pod_lookup_map[pod_name] = {
1331
1402
  type: :external,
@@ -1337,6 +1408,8 @@ module Expo
1337
1408
  product_name: product_name,
1338
1409
  targets: targets,
1339
1410
  spm_dependency_frameworks: spm_dependency_frameworks,
1411
+ spm_dependency_versions: spm_dependency_versions,
1412
+ prebuilt_dependency_pods: prebuilt_dependency_pods(product['externalDependencies']),
1340
1413
  autolink_when: product['autolinkWhen']
1341
1414
  }
1342
1415
  end
@@ -1344,6 +1417,14 @@ module Expo
1344
1417
  Pod::UI.warn "[Expo-precompiled] Failed to read external config at #{config_path}: #{e.message}"
1345
1418
  end
1346
1419
 
1420
+ # Returns { productName => versionString } from a spm.config.json spmPackages array.
1421
+ def spm_package_versions(spm_packages)
1422
+ (spm_packages || []).each_with_object({}) do |pkg, h|
1423
+ version = pkg['version'].is_a?(Hash) ? pkg['version']['exact'] : pkg['version']
1424
+ h[pkg['productName']] = version if pkg['productName'] && version
1425
+ end
1426
+ end
1427
+
1347
1428
  # Processes a single spm.config.json file and adds entries to @pod_lookup_map.
1348
1429
  def process_spm_config(config_path, type, repo_root)
1349
1430
  config = JSON.parse(File.read(config_path))
@@ -1397,6 +1478,7 @@ module Expo
1397
1478
  .map { |t| { name: t['name'], path: t['path'] } }
1398
1479
 
1399
1480
  spm_dependency_frameworks = (product['spmPackages'] || []).map { |pkg| pkg['productName'] }.compact
1481
+ spm_dependency_versions = spm_package_versions(product['spmPackages'])
1400
1482
 
1401
1483
  {
1402
1484
  type: type,
@@ -1408,10 +1490,31 @@ module Expo
1408
1490
  product_name: product_name,
1409
1491
  targets: targets,
1410
1492
  spm_dependency_frameworks: spm_dependency_frameworks,
1493
+ spm_dependency_versions: spm_dependency_versions,
1494
+ prebuilt_dependency_pods: prebuilt_dependency_pods(product['externalDependencies']),
1411
1495
  autolink_when: product['autolinkWhen']
1412
1496
  }
1413
1497
  end
1414
1498
 
1499
+ # Returns local Expo product dependencies whose prebuilt availability must
1500
+ # match this product's availability. Runtime deps like React/Hermes are not
1501
+ # encoded as package/product strings and are ignored here.
1502
+ def prebuilt_dependency_pods(external_dependencies)
1503
+ (external_dependencies || []).filter_map do |dep|
1504
+ next unless dep.is_a?(String) && dep.include?('/')
1505
+
1506
+ parts = dep.split('/')
1507
+ is_scoped = parts[0].start_with?('@')
1508
+ package_name = is_scoped ? "#{parts[0]}/#{parts[1]}" : parts[0]
1509
+ product_name = is_scoped ? parts[2] : parts[1]
1510
+
1511
+ next unless package_name&.start_with?('expo-', '@expo/')
1512
+ next if CUSTOM_XCFRAMEWORK_DEPENDENCIES.include?(product_name)
1513
+
1514
+ product_name
1515
+ end.uniq
1516
+ end
1517
+
1415
1518
  # Resolves the codegen module name. For external packages, prefers codegenConfig.name
1416
1519
  # from the installed package.json over spm.config.json's codegenName.
1417
1520
  def resolve_codegen_name(product, pod_name, npm_package, type, repo_root)
@@ -1480,7 +1583,7 @@ module Expo
1480
1583
  current_dir = start_dir || Dir.pwd
1481
1584
 
1482
1585
  loop do
1483
- return current_dir if File.directory?(File.join(current_dir, 'packages'))
1586
+ return current_dir if File.exist?(File.join(current_dir, 'packages', 'expo-modules-core', 'spm.config.json'))
1484
1587
 
1485
1588
  parent = File.dirname(current_dir)
1486
1589
  break if parent == current_dir
@@ -1490,6 +1593,16 @@ module Expo
1490
1593
  nil
1491
1594
  end
1492
1595
 
1596
+ # Invokes `expo-modules-autolinking <subcommand> --json` and parses the output.
1597
+ # Used by `react_native_config` and `resolved_modules` above.
1598
+ def invoke_autolinking(subcommand, platform:)
1599
+ args = ['node', '--no-warnings', '--eval', "require('expo/bin/autolinking')",
1600
+ 'expo-modules-autolinking', subcommand, '--platform', platform, '--json']
1601
+ JSON.parse(IO.popen(args, &:read))
1602
+ rescue => error
1603
+ raise "Failed to invoke `expo-modules-autolinking #{subcommand}`: #{error}"
1604
+ end
1605
+
1493
1606
  # ──────────────────────────────────────────────────────────────────────
1494
1607
  # Helpers: bundled frameworks
1495
1608
  # ──────────────────────────────────────────────────────────────────────
@@ -1525,7 +1638,13 @@ module Expo
1525
1638
  results = []
1526
1639
  pod_lookup_map.each do |pod_name, info|
1527
1640
  next unless info[:type] == :external
1528
- next unless has_prebuilt_xcframework?(pod_name)
1641
+
1642
+ unless has_prebuilt_xcframework?(pod_name)
1643
+ product_name = info[:product_name] || pod_name
1644
+ expected = File.join(info[:build_output_dir], build_flavor, 'xcframeworks', "#{product_name}.tar.gz")
1645
+ Pod::UI.warn "[Expo-precompiled] #{pod_name}: prebuilt xcframework not found. Expected tarball at #{expected}"
1646
+ next
1647
+ end
1529
1648
 
1530
1649
  podspec_file = File.join(info[:podspec_dir], "#{pod_name}.podspec")
1531
1650
  next unless File.exist?(podspec_file)
@@ -1565,11 +1684,15 @@ module Expo
1565
1684
  # Helpers: version resolution for 3rd-party prebuild versioning
1566
1685
  # ──────────────────────────────────────────────────────────────────────
1567
1686
 
1568
- # Returns the installed React Native version from node_modules.
1687
+ # RN package path, as resolved by rncli (handles pnpm workspaces correctly).
1688
+ def react_native_path
1689
+ react_native_config['reactNativePath']
1690
+ end
1691
+
1569
1692
  def react_native_version
1570
1693
  @react_native_version ||= begin
1571
- rn_pkg = File.join(find_node_modules_dir, 'react-native', 'package.json')
1572
- File.exist?(rn_pkg) ? JSON.parse(File.read(rn_pkg))['version'] : nil
1694
+ rn_pkg = react_native_path && File.join(react_native_path, 'package.json')
1695
+ rn_pkg && File.exist?(rn_pkg) ? JSON.parse(File.read(rn_pkg))['version'] : nil
1573
1696
  end
1574
1697
  end
1575
1698
 
@@ -1577,37 +1700,21 @@ module Expo
1577
1700
  # Mirrors the TypeScript resolution logic in tools/src/prebuilds/Utils.ts.
1578
1701
  def hermes_version
1579
1702
  @hermes_version ||= begin
1580
- rn_path = File.join(find_node_modules_dir, 'react-native')
1581
- is_v1 = ENV['RCT_HERMES_V1_ENABLED'] == '1'
1582
- version = nil
1583
-
1584
- # Read from version.properties (primary source)
1585
- props_path = File.join(rn_path, 'sdks', 'hermes-engine', 'version.properties')
1586
- if File.exist?(props_path)
1587
- props = parse_version_properties(props_path)
1588
- version = is_v1 ? props['HERMES_V1_VERSION_NAME'] : props['HERMES_VERSION_NAME']
1589
- end
1590
-
1591
- # Fallback to tag files
1592
- unless version
1593
- tag_file = is_v1 ? '.hermesv1version' : '.hermesversion'
1594
- tag_path = File.join(rn_path, 'sdks', tag_file)
1595
- version = File.read(tag_path).strip if File.exist?(tag_path)
1596
- end
1597
-
1598
- # Normalize: strip "hermes-" prefix and "v" prefix
1599
- version&.gsub(/^hermes-?/i, '')&.gsub(/^v/i, '')&.strip
1600
- end
1601
- end
1703
+ rn_path = react_native_path
1704
+ if rn_path
1705
+ is_v1 = ENV['RCT_HERMES_V1_ENABLED'] == '1'
1706
+ props_path = File.join(rn_path, 'sdks', 'hermes-engine', 'version.properties')
1707
+ version = File.exist?(props_path) ?
1708
+ parse_version_properties(props_path)[is_v1 ? 'HERMES_V1_VERSION_NAME' : 'HERMES_VERSION_NAME'] : nil
1709
+
1710
+ # Fallback to tag files
1711
+ unless version
1712
+ tag_path = File.join(rn_path, 'sdks', is_v1 ? '.hermesv1version' : '.hermesversion')
1713
+ version = File.read(tag_path).strip if File.exist?(tag_path)
1714
+ end
1602
1715
 
1603
- # Returns the node_modules directory for the project.
1604
- def find_node_modules_dir
1605
- @node_modules_dir ||= begin
1606
- repo_root = find_repo_root
1607
- if repo_root
1608
- File.join(repo_root, 'node_modules')
1609
- else
1610
- File.join(File.dirname(Dir.pwd), 'node_modules')
1716
+ # Normalize: strip "hermes-" prefix and "v" prefix
1717
+ version&.gsub(/^hermes-?/i, '')&.gsub(/^v/i, '')&.strip
1611
1718
  end
1612
1719
  end
1613
1720
  end
@@ -1641,16 +1748,127 @@ module Expo
1641
1748
  # Resolves prebuilt xcframework info for a pod.
1642
1749
  # @return [Array, nil] [pod_info, product_name, tarball_path] or nil
1643
1750
  def resolve_prebuilt_info(pod_name)
1644
- return nil if build_from_source?(pod_name)
1751
+ resolution = resolve_prebuilt_status(pod_name)
1752
+ resolution[:available] ? resolution[:resolved] : nil
1753
+ end
1645
1754
 
1755
+ # Resolves only this pod's own prebuilt artifact without checking parent dependencies.
1756
+ # @return [Hash] Availability information with :available, :resolved, :reason, and :path.
1757
+ def resolve_own_prebuilt_info(pod_name)
1646
1758
  pod_info = pod_lookup_map[pod_name]
1647
- return nil unless pod_info
1759
+ return { available: false, reason: :missing_config } unless pod_info
1648
1760
 
1649
1761
  product_name = pod_info[:product_name] || pod_name
1650
- tarball = File.join(pod_info[:build_output_dir], build_flavor, 'xcframeworks', "#{product_name}.tar.gz")
1651
- return nil unless File.exist?(tarball)
1762
+ tarball = resolve_prebuilt_tarball(pod_info, product_name, build_flavor, pod_name)
1763
+ return { available: false, reason: :missing_tarball, path: tarball } unless File.exist?(tarball)
1652
1764
 
1653
- [pod_info, product_name, tarball]
1765
+ { available: true, resolved: [pod_info, product_name, tarball] }
1766
+ end
1767
+
1768
+ # A pod may use a prebuilt xcframework only when its own prebuilt artifact
1769
+ # exists and every local Expo dependency also uses prebuilt.
1770
+ def resolve_prebuilt_status(pod_name, visiting = Set.new)
1771
+ return { available: false, reason: :build_from_source } if build_from_source?(pod_name)
1772
+ return { available: true } if visiting.include?(pod_name)
1773
+
1774
+ own_resolution = resolve_own_prebuilt_info(pod_name)
1775
+ return own_resolution unless own_resolution[:available]
1776
+
1777
+ pod_info = own_resolution[:resolved][0]
1778
+ next_visiting = visiting.dup.add(pod_name)
1779
+
1780
+ (pod_info[:prebuilt_dependency_pods] || []).each do |dep_name|
1781
+ dep_resolution = resolve_prebuilt_status(dep_name, next_visiting)
1782
+ next if dep_resolution[:available]
1783
+
1784
+ return {
1785
+ available: false,
1786
+ reason: :dependency_unavailable,
1787
+ dependency: dep_name,
1788
+ dependency_reason: dep_resolution[:reason],
1789
+ dependency_path: dep_resolution[:path]
1790
+ }
1791
+ end
1792
+
1793
+ own_resolution
1794
+ end
1795
+
1796
+ def resolve_prebuilt_tarball(pod_info, product_name, flavor, pod_name = nil)
1797
+ tarball = File.join(pod_info[:build_output_dir], flavor, 'xcframeworks', "#{product_name}.tar.gz")
1798
+ return tarball if File.exist?(tarball)
1799
+
1800
+ base_url = external_modules_base_url
1801
+ return tarball unless pod_info[:type] == :external && base_url
1802
+
1803
+ output_prefix = File.join(pod_info[:npm_package], 'output')
1804
+ idx = pod_info[:build_output_dir].rindex(output_prefix)
1805
+ relative_path = [
1806
+ (idx ? pod_info[:build_output_dir][idx..] : output_prefix),
1807
+ flavor,
1808
+ 'xcframeworks',
1809
+ "#{product_name}.tar.gz"
1810
+ ].join('/')
1811
+ remote_url = "#{base_url.chomp('/')}/#{relative_path}"
1812
+ remote_tarball = File.join(remote_precompiled_artifacts_dir, relative_path)
1813
+
1814
+ return remote_tarball if File.exist?(remote_tarball)
1815
+ return remote_tarball if failed_remote_downloads.include?(remote_url)
1816
+
1817
+ download_remote_tarball(remote_url, remote_tarball, pod_name || product_name, flavor)
1818
+ remote_tarball
1819
+ end
1820
+
1821
+ def failed_remote_downloads
1822
+ @failed_remote_downloads ||= Set.new
1823
+ end
1824
+
1825
+ def remote_precompiled_artifacts_dir
1826
+ pods_root = Pod::Config.instance.sandbox_root rescue File.join(Dir.pwd, 'Pods')
1827
+ File.join(pods_root.to_s, 'ExpoPrecompiledArtifacts')
1828
+ end
1829
+
1830
+ def gray(text)
1831
+ text.respond_to?(:gray) ? text.gray : text
1832
+ end
1833
+
1834
+ def download_remote_tarball(remote_url, destination_path, pod_name, flavor)
1835
+ FileUtils.mkdir_p(File.dirname(destination_path))
1836
+ tmp_path = "#{destination_path}.download-#{Process.pid}"
1837
+
1838
+ download_to_file(remote_url, tmp_path)
1839
+ FileUtils.mv(tmp_path, destination_path)
1840
+ Pod::UI.info "#{'[Expo-precompiled] '.blue}#{pod_name}: downloaded remote #{flavor} artifact"
1841
+ destination_path
1842
+ rescue => e
1843
+ FileUtils.rm_f(tmp_path) if tmp_path && File.exist?(tmp_path)
1844
+ failed_remote_downloads.add(remote_url)
1845
+ Pod::UI.puts "#{'[Expo-precompiled] '.blue}#{"#{pod_name}: remote #{flavor} artifact unavailable (#{e.message}); building from source".yellow}"
1846
+ Pod::UI.puts "#{'[Expo-precompiled] '.blue}#{gray(" URL: #{remote_url}")}"
1847
+ nil
1848
+ end
1849
+
1850
+ def download_to_file(url, destination_path, limit = 5)
1851
+ raise 'Too many HTTP redirects' if limit <= 0
1852
+
1853
+ uri = URI.parse(url)
1854
+
1855
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.is_a?(URI::HTTPS), open_timeout: 10, read_timeout: 120) do |http|
1856
+ request = Net::HTTP::Get.new(uri.request_uri)
1857
+
1858
+ http.request(request) do |response|
1859
+ case response
1860
+ when Net::HTTPSuccess
1861
+ File.open(destination_path, 'wb') do |file|
1862
+ response.read_body { |chunk| file.write(chunk) }
1863
+ end
1864
+ when Net::HTTPRedirection
1865
+ redirect_url = URI.join(uri.to_s, response['location']).to_s
1866
+ return download_to_file(redirect_url, destination_path, limit - 1)
1867
+ else
1868
+ raise "HTTP #{response.code}"
1869
+ end
1870
+ end
1871
+ end
1654
1872
  end
1655
1873
 
1656
1874
  # ──────────────────────────────────────────────────────────────────────
@@ -1863,10 +2081,27 @@ module Expo
1863
2081
  # ──────────────────────────────────────────────────────────────────────
1864
2082
 
1865
2083
  # Records the linking status for a pod (only once per pod to avoid duplicates).
1866
- def log_linking_status(pod_name, found, path)
2084
+ def log_linking_status(pod_name, found, info = nil)
1867
2085
  @linked_pods ||= {}
1868
2086
  return if @linked_pods[pod_name]
1869
- @linked_pods[pod_name] = { found: found, path: path, spm_deps: [] }
2087
+ status = info.is_a?(Hash) ? info.dup : { path: info }
2088
+ @linked_pods[pod_name] = status.merge(found: found, spm_deps: [])
2089
+ end
2090
+
2091
+ def format_prebuilt_unavailable_reason(info)
2092
+ case info[:reason]
2093
+ when :build_from_source
2094
+ 'configured by buildFromSource'
2095
+ when :missing_config
2096
+ 'prebuilt config not found'
2097
+ when :missing_tarball
2098
+ 'prebuilt tarball not found'
2099
+ when :dependency_unavailable
2100
+ reason = format_prebuilt_unavailable_reason(reason: info[:dependency_reason], path: info[:dependency_path])
2101
+ "dependency #{info[:dependency]} is not using prebuilt: #{reason}"
2102
+ else
2103
+ info[:path] || 'prebuilt unavailable'
2104
+ end
1870
2105
  end
1871
2106
 
1872
2107
  # Records an SPM dependency xcframework bundled inside a precompiled pod.