xref: /expo/packages/@expo/cli/src/utils/npm.ts (revision af644ed4)
1import { getExpoHomeDirectory } from '@expo/config/build/getUserState';
2import { JSONValue } from '@expo/json-file';
3import spawnAsync from '@expo/spawn-async';
4import assert from 'assert';
5import fs from 'fs';
6import fetch from 'node-fetch';
7import path from 'path';
8import slugify from 'slugify';
9import { Stream } from 'stream';
10import tar from 'tar';
11import { promisify } from 'util';
12
13import { FileSystemCache } from '../api/rest/cache/FileSystemCache';
14import { wrapFetchWithCache } from '../api/rest/cache/wrapFetchWithCache';
15import * as Log from '../log';
16import { createEntryResolver, createFileTransform } from './createFileTransform';
17import { ensureDirectoryAsync } from './dir';
18import { CommandError } from './errors';
19
20const cachedFetch = wrapFetchWithCache(
21  fetch,
22  new FileSystemCache({
23    cacheDirectory: getCacheFilePath(),
24    // Time to live. How long (in ms) responses remain cached before being automatically ejected. If undefined, responses are never automatically ejected from the cache.
25    // ttl: 1000,
26  })
27);
28
29export function sanitizeNpmPackageName(name: string): string {
30  // https://github.com/npm/validate-npm-package-name/#naming-rules
31  return (
32    applyKnownNpmPackageNameRules(name) ||
33    applyKnownNpmPackageNameRules(slugify(name)) ||
34    // If nothing is left use 'app' like we do in Xcode projects.
35    'app'
36  );
37}
38
39function applyKnownNpmPackageNameRules(name: string): string | null {
40  // https://github.com/npm/validate-npm-package-name/#naming-rules
41
42  // package name cannot start with '.' or '_'.
43  while (/^(\.|_)/.test(name)) {
44    name = name.substring(1);
45  }
46
47  name = name.toLowerCase().replace(/[^a-zA-Z._\-/@]/g, '');
48
49  return (
50    name
51      // .replace(/![a-z0-9-._~]+/g, '')
52      // Remove special characters
53      .normalize('NFD')
54      .replace(/[\u0300-\u036f]/g, '') || null
55  );
56}
57
58export async function npmViewAsync(...props: string[]): Promise<JSONValue> {
59  const cmd = ['view', ...props, '--json'];
60  const results = (await spawnAsync('npm', cmd)).stdout?.trim();
61  const cmdString = `npm ${cmd.join(' ')}`;
62  Log.debug('Run:', cmdString);
63  if (!results) {
64    return null;
65  }
66  try {
67    return JSON.parse(results);
68  } catch (error: any) {
69    throw new Error(
70      `Could not parse JSON returned from "${cmdString}".\n\n${results}\n\nError: ${error.message}`
71    );
72  }
73}
74
75/** Given a package name like `expo` or `expo@beta`, return the registry URL if it exists. */
76export async function getNpmUrlAsync(packageName: string): Promise<string> {
77  const results = await npmViewAsync(packageName, 'dist.tarball');
78
79  assert(results, `Could not get npm url for package "${packageName}"`);
80
81  // Fully qualified url returns a string.
82  // Example:
83  // �� npm view expo-template-bare-minimum@sdk-33 dist.tarball --json
84  if (typeof results === 'string') {
85    return results;
86  }
87
88  // When the tag is arbitrary, the tarball url is an array, return the last value as it's the most recent.
89  // Example:
90  // �� npm view expo-template-bare-minimum@33 dist.tarball --json
91  if (Array.isArray(results)) {
92    return results[results.length - 1] as string;
93  }
94
95  throw new CommandError(
96    'Expected results of `npm view ...` to be an array or string. Instead found: ' + results
97  );
98}
99
100// @ts-ignore
101const pipeline = promisify(Stream.pipeline);
102
103export async function downloadAndExtractNpmModuleAsync(
104  npmName: string,
105  props: ExtractProps
106): Promise<void> {
107  const url = await getNpmUrlAsync(npmName);
108
109  Log.debug('Fetch from URL:', url);
110  await extractNpmTarballFromUrlAsync(url, props);
111}
112
113export async function extractLocalNpmTarballAsync(
114  tarFilePath: string,
115  props: ExtractProps
116): Promise<void> {
117  const readStream = fs.createReadStream(tarFilePath);
118  await extractNpmTarballAsync(readStream, props);
119}
120
121type ExtractProps = {
122  name: string;
123  cwd: string;
124  strip?: number;
125  fileList?: string[];
126};
127
128function getCacheFilePath() {
129  return path.join(getExpoHomeDirectory(), 'template-cache');
130}
131
132async function createUrlStreamAsync(url: string) {
133  const response = await cachedFetch(url);
134  if (!response.ok) {
135    throw new Error(`Unexpected response: ${response.statusText}. From url: ${url}`);
136  }
137
138  return response.body;
139}
140
141export async function extractNpmTarballFromUrlAsync(
142  url: string,
143  props: ExtractProps
144): Promise<void> {
145  await extractNpmTarballAsync(await createUrlStreamAsync(url), props);
146}
147
148export async function extractNpmTarballAsync(
149  stream: NodeJS.ReadableStream,
150  props: ExtractProps
151): Promise<void> {
152  const { cwd, strip, name, fileList = [] } = props;
153
154  await ensureDirectoryAsync(cwd);
155
156  await pipeline(
157    stream,
158    tar.extract(
159      {
160        cwd,
161        transform: createFileTransform(name),
162        onentry: createEntryResolver(name),
163        strip: strip ?? 1,
164      },
165      fileList
166    )
167  );
168}
169