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