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