1import { PackageJSONConfig } from '@expo/config';
2import npmPackageArg from 'npm-package-arg';
3
4import { getVersionedNativeModulesAsync } from './bundledNativeModules';
5import { getVersionsAsync, SDKVersion } from '../../../api/getVersions';
6import { Log } from '../../../log';
7import { env } from '../../../utils/env';
8import { CommandError } from '../../../utils/errors';
9
10const debug = require('debug')(
11  'expo:doctor:dependencies:getVersionedPackages'
12) as typeof console.log;
13
14export type DependencyList = Record<string, string>;
15
16/** Adds `react-dom`, `react`, and `react-native` to the list of known package versions (`relatedPackages`) */
17function normalizeSdkVersionObject(version?: SDKVersion): Record<string, string> {
18  if (!version) {
19    return {};
20  }
21  const { relatedPackages, facebookReactVersion, facebookReactNativeVersion } = version;
22
23  const reactVersion = facebookReactVersion
24    ? {
25        react: facebookReactVersion,
26        'react-dom': facebookReactVersion,
27      }
28    : undefined;
29
30  return {
31    ...relatedPackages,
32    ...reactVersion,
33    'react-native': facebookReactNativeVersion,
34  };
35}
36
37/** Get the known versions for a given SDK, combines all sources. */
38export async function getCombinedKnownVersionsAsync({
39  projectRoot,
40  sdkVersion,
41  skipCache,
42}: {
43  projectRoot: string;
44  sdkVersion?: string;
45  skipCache?: boolean;
46}) {
47  const bundledNativeModules = sdkVersion
48    ? await getVersionedNativeModulesAsync(projectRoot, sdkVersion)
49    : {};
50  const versionsForSdk = await getRemoteVersionsForSdkAsync({ sdkVersion, skipCache });
51  return {
52    ...bundledNativeModules,
53    // Prefer the remote versions over the bundled versions, this enables us to push
54    // emergency fixes that users can access without having to update the `expo` package.
55    ...versionsForSdk,
56  };
57}
58
59/** @returns a key/value list of known dependencies and their version (including range). */
60export async function getRemoteVersionsForSdkAsync({
61  sdkVersion,
62  skipCache,
63}: { sdkVersion?: string; skipCache?: boolean } = {}): Promise<DependencyList> {
64  if (env.EXPO_OFFLINE) {
65    Log.warn('Dependency validation is unreliable in offline-mode');
66    return {};
67  }
68
69  try {
70    const { sdkVersions } = await getVersionsAsync({ skipCache });
71
72    // We only want versioned dependencies so skip if they cannot be found.
73    if (!sdkVersion || !(sdkVersion in sdkVersions)) {
74      debug(
75        `Skipping versioned dependencies because the SDK version is not found. (sdkVersion: ${sdkVersion}, available: ${Object.keys(
76          sdkVersions
77        ).join(', ')})`
78      );
79      return {};
80    }
81
82    const version = sdkVersions[sdkVersion as keyof typeof sdkVersions] as unknown as SDKVersion;
83
84    return normalizeSdkVersionObject(version);
85  } catch (error: any) {
86    if (error instanceof CommandError && error.code === 'OFFLINE') {
87      return getRemoteVersionsForSdkAsync({ sdkVersion, skipCache });
88    }
89    throw error;
90  }
91}
92
93/**
94 * Versions a list of `packages` against a given `sdkVersion` based on local and remote versioning resources.
95 *
96 * @param projectRoot
97 * @param param1
98 * @returns
99 */
100export async function getVersionedPackagesAsync(
101  projectRoot: string,
102  {
103    packages,
104    sdkVersion,
105    pkg,
106  }: {
107    /** List of npm packages to process. */
108    packages: string[];
109    /** Target SDK Version number to version the `packages` for. */
110    sdkVersion: string;
111    pkg: PackageJSONConfig;
112  }
113): Promise<{
114  packages: string[];
115  messages: string[];
116  excludedNativeModules: { name: string; bundledNativeVersion: string }[];
117}> {
118  const versionsForSdk = await getCombinedKnownVersionsAsync({
119    projectRoot,
120    sdkVersion,
121    skipCache: true,
122  });
123
124  let nativeModulesCount = 0;
125  let othersCount = 0;
126  const excludedNativeModules: { name: string; bundledNativeVersion: string }[] = [];
127
128  const versionedPackages = packages.map((arg) => {
129    const { name, type, raw } = npmPackageArg(arg);
130
131    if (['tag', 'version', 'range'].includes(type) && name && versionsForSdk[name]) {
132      // Unimodule packages from npm registry are modified to use the bundled version.
133      // Some packages have the recommended version listed in https://exp.host/--/api/v2/versions.
134      if (pkg?.expo?.install?.exclude?.includes(name)) {
135        othersCount++;
136        excludedNativeModules.push({ name, bundledNativeVersion: versionsForSdk[name] });
137        return raw;
138      }
139      nativeModulesCount++;
140      return `${name}@${versionsForSdk[name]}`;
141    } else {
142      // Other packages are passed through unmodified.
143      othersCount++;
144      return raw;
145    }
146  });
147
148  const messages = getOperationLog({
149    othersCount,
150    nativeModulesCount,
151    sdkVersion,
152  });
153
154  return {
155    packages: versionedPackages,
156    messages,
157    excludedNativeModules,
158  };
159}
160
161/** Craft a set of messages regarding the install operations. */
162export function getOperationLog({
163  nativeModulesCount,
164  sdkVersion,
165  othersCount,
166}: {
167  nativeModulesCount: number;
168  othersCount: number;
169  sdkVersion: string;
170}): string[] {
171  return [
172    nativeModulesCount > 0 &&
173      `${nativeModulesCount} SDK ${sdkVersion} compatible native ${
174        nativeModulesCount === 1 ? 'module' : 'modules'
175      }`,
176    othersCount > 0 && `${othersCount} other ${othersCount === 1 ? 'package' : 'packages'}`,
177  ].filter(Boolean) as string[];
178}
179