refactor(cna): make create-next-app even smaller and faster (#58030)

The PR further reduces the `create-next-app` installation size by
another 80 KiB:

- Replace the callback version of Node.js built-in `dns` API usage with
`dns/promise` + async/await
- Replace `got` w/ `fetch` since Next.js and `create-next-app` now
target Node.js 18.17.0+
- Download and extract the tar.gz file in the memory (without creating
temporary files). This improves the performance.
- Some other minor refinements.

Following these changes, the size of `dist/index.js` is now 536 KiB.
This commit is contained in:
Sukka 2024-01-11 22:40:29 +08:00 committed by GitHub
parent e6e6609674
commit b8b104506d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 101 additions and 229 deletions

View file

@ -11,7 +11,6 @@ import {
existsInRepo,
hasRepo,
} from './helpers/examples'
import { makeDir } from './helpers/make-dir'
import { tryGitInit } from './helpers/git'
import { install } from './helpers/install'
import { isFolderEmpty } from './helpers/is-folder-empty'
@ -133,7 +132,7 @@ export async function createApp({
const appName = path.basename(root)
await makeDir(root)
fs.mkdirSync(root, { recursive: true })
if (!isFolderEmpty(root, appName)) {
process.exit(1)
}

View file

@ -1,13 +1,7 @@
/* eslint-disable import/no-extraneous-dependencies */
import got from 'got'
import tar from 'tar'
import { Stream } from 'stream'
import { promisify } from 'util'
import { join } from 'path'
import { tmpdir } from 'os'
import { createWriteStream, promises as fs } from 'fs'
const pipeline = promisify(Stream.pipeline)
import { Readable } from 'stream'
import { pipeline } from 'stream/promises'
export type RepoInfo = {
username: string
@ -17,8 +11,12 @@ export type RepoInfo = {
}
export async function isUrlOk(url: string): Promise<boolean> {
const res = await got.head(url).catch((e) => e)
return res.statusCode === 200
try {
const res = await fetch(url, { method: 'HEAD' })
return res.status === 200
} catch {
return false
}
}
export async function getRepoInfo(
@ -37,14 +35,19 @@ export async function getRepoInfo(
// In this case "t" will be an empty string while the next part "_branch" will be undefined
(t === '' && _branch === undefined)
) {
const infoResponse = await got(
`https://api.github.com/repos/${username}/${name}`
).catch((e) => e)
if (infoResponse.statusCode !== 200) {
try {
const infoResponse = await fetch(
`https://api.github.com/repos/${username}/${name}`
)
if (infoResponse.status !== 200) {
return
}
const info = await infoResponse.json()
return { username, name, branch: info['default_branch'], filePath }
} catch {
return
}
const info = JSON.parse(infoResponse.body)
return { username, name, branch: info['default_branch'], filePath }
}
// If examplePath is available, the branch name takes the entire path
@ -82,33 +85,35 @@ export function existsInRepo(nameOrUrl: string): Promise<boolean> {
}
}
async function downloadTar(url: string) {
const tempFile = join(tmpdir(), `next.js-cna-example.temp-${Date.now()}`)
await pipeline(got.stream(url), createWriteStream(tempFile))
return tempFile
async function downloadTarStream(url: string) {
const res = await fetch(url)
if (!res.body) {
throw new Error(`Failed to download: ${url}`)
}
return Readable.fromWeb(res.body as import('stream/web').ReadableStream)
}
export async function downloadAndExtractRepo(
root: string,
{ username, name, branch, filePath }: RepoInfo
) {
const tempFile = await downloadTar(
`https://codeload.github.com/${username}/${name}/tar.gz/${branch}`
await pipeline(
await downloadTarStream(
`https://codeload.github.com/${username}/${name}/tar.gz/${branch}`
),
tar.x({
cwd: root,
strip: filePath ? filePath.split('/').length + 1 : 1,
filter: (p) =>
p.startsWith(
`${name}-${branch.replace(/\//g, '-')}${
filePath ? `/${filePath}/` : '/'
}`
),
})
)
await tar.x({
file: tempFile,
cwd: root,
strip: filePath ? filePath.split('/').length + 1 : 1,
filter: (p) =>
p.startsWith(
`${name}-${branch.replace(/\//g, '-')}${
filePath ? `/${filePath}/` : '/'
}`
),
})
await fs.unlink(tempFile)
}
export async function downloadAndExtractExample(root: string, name: string) {
@ -116,16 +121,14 @@ export async function downloadAndExtractExample(root: string, name: string) {
throw new Error('This is an internal example for testing the CLI.')
}
const tempFile = await downloadTar(
'https://codeload.github.com/vercel/next.js/tar.gz/canary'
await pipeline(
await downloadTarStream(
'https://codeload.github.com/vercel/next.js/tar.gz/canary'
),
tar.x({
cwd: root,
strip: 2 + name.split('/').length,
filter: (p) => p.includes(`next.js-canary/examples/${name}/`),
})
)
await tar.x({
file: tempFile,
cwd: root,
strip: 2 + name.split('/').length,
filter: (p) => p.includes(`next.js-canary/examples/${name}/`),
})
await fs.unlink(tempFile)
}

View file

@ -14,7 +14,7 @@ export async function install(
/** Indicate whether there is an active Internet connection.*/
isOnline: boolean
): Promise<void> {
let args: string[] = ['install']
const args: string[] = ['install']
if (!isOnline) {
console.log(
yellow('You appear to be offline.\nFalling back to the local cache.')

View file

@ -27,11 +27,12 @@ export function isFolderEmpty(root: string, name: string): boolean {
'.yarn',
]
const conflicts = fs
.readdirSync(root)
.filter((file) => !validFiles.includes(file))
// Support IntelliJ IDEA-based editors
.filter((file) => !/\.iml$/.test(file))
const conflicts = fs.readdirSync(root).filter(
(file) =>
!validFiles.includes(file) &&
// Support IntelliJ IDEA-based editors
!/\.iml$/.test(file)
)
if (conflicts.length > 0) {
console.log(

View file

@ -1,5 +1,5 @@
import { execSync } from 'child_process'
import dns from 'dns'
import dns from 'dns/promises'
import url from 'url'
function getProxy(): string | undefined {
@ -15,26 +15,31 @@ function getProxy(): string | undefined {
}
}
export function getOnline(): Promise<boolean> {
return new Promise((resolve) => {
dns.lookup('registry.yarnpkg.com', (registryErr) => {
if (!registryErr) {
return resolve(true)
}
export async function getOnline(): Promise<boolean> {
try {
await dns.lookup('registry.yarnpkg.com')
// If DNS lookup succeeds, we are online
return true
} catch {
// The DNS lookup failed, but we are still fine as long as a proxy has been set
const proxy = getProxy()
if (!proxy) {
return false
}
const proxy = getProxy()
if (!proxy) {
return resolve(false)
}
const { hostname } = url.parse(proxy)
if (!hostname) {
// Invalid proxy URL
return false
}
const { hostname } = url.parse(proxy)
if (!hostname) {
return resolve(false)
}
dns.lookup(hostname, (proxyErr) => {
resolve(proxyErr == null)
})
})
})
try {
await dns.lookup(hostname)
// If DNS lookup succeeds for the proxy server, we are online
return true
} catch {
// The DNS lookup for the proxy server also failed, so we are offline
return false
}
}
}

View file

@ -1,8 +0,0 @@
import fs from 'fs'
export function makeDir(
root: string,
options = { recursive: true }
): Promise<string | undefined> {
return fs.promises.mkdir(root, options)
}

View file

@ -1,10 +1,16 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import validateProjectName from 'validate-npm-package-name'
export function validateNpmName(name: string): {
valid: boolean
problems?: string[]
} {
type ValidateNpmNameResult =
| {
valid: true
}
| {
valid: false
problems: string[]
}
export function validateNpmName(name: string): ValidateNpmNameResult {
const nameValidation = validateProjectName(name)
if (nameValidation.validForNewPackages) {
return { valid: true }

View file

@ -184,7 +184,7 @@ async function run(): Promise<void> {
if (validation.valid) {
return true
}
return 'Invalid project name: ' + validation.problems![0]
return 'Invalid project name: ' + validation.problems[0]
},
})
@ -207,15 +207,17 @@ async function run(): Promise<void> {
const resolvedProjectPath = path.resolve(projectPath)
const projectName = path.basename(resolvedProjectPath)
const { valid, problems } = validateNpmName(projectName)
if (!valid) {
const validation = validateNpmName(projectName)
if (!validation.valid) {
console.error(
`Could not create a project called ${red(
`"${projectName}"`
)} because of npm naming restrictions:`
)
problems!.forEach((p) => console.error(` ${red(bold('*'))} ${p}`))
validation.problems.forEach((p) =>
console.error(` ${red(bold('*'))} ${p}`)
)
process.exit(1)
}

View file

@ -43,7 +43,6 @@
"conf": "10.2.0",
"cross-spawn": "7.0.3",
"fast-glob": "3.3.1",
"got": "10.7.0",
"picocolors": "1.0.0",
"prettier-plugin-tailwindcss": "0.3.0",
"prompts": "2.4.2",

View file

@ -1,5 +1,4 @@
import { install } from '../helpers/install'
import { makeDir } from '../helpers/make-dir'
import { copy } from '../helpers/copy'
import { async as glob } from 'fast-glob'
@ -118,7 +117,7 @@ export const installTemplate = async ({
}
if (srcDir) {
await makeDir(path.join(root, 'src'))
await fs.mkdir(path.join(root, 'src'), { recursive: true })
await Promise.all(
SRC_DIR_NAMES.map(async (file) => {
await fs

View file

@ -725,9 +725,6 @@ importers:
fast-glob:
specifier: 3.3.1
version: 3.3.1
got:
specifier: 10.7.0
version: 10.7.0
picocolors:
specifier: 1.0.0
version: 1.0.0
@ -6261,11 +6258,6 @@ packages:
engines: {node: '>=6'}
dev: true
/@sindresorhus/is@2.1.0:
resolution: {integrity: sha512-lXKXfypKo644k4Da4yXkPCrwcvn6SlUW2X2zFbuflKHNjf0w9htru01bo26uMhleMXsDmnZ12eJLdrAZa9MANg==}
engines: {node: '>=10'}
dev: true
/@sinonjs/commons@1.8.6:
resolution: {integrity: sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==}
dependencies:
@ -6552,13 +6544,6 @@ packages:
defer-to-connect: 1.1.3
dev: true
/@szmarczak/http-timer@4.0.5:
resolution: {integrity: sha512-PyRA9sm1Yayuj5OIoJ1hGt2YISX45w9WcFbh6ddT0Z/0yaFxOtGLInr4jUfU1EAFVs0Yfyfev4RNwBlUaHdlDQ==}
engines: {node: '>=10'}
dependencies:
defer-to-connect: 2.0.0
dev: true
/@szmarczak/http-timer@5.0.1:
resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==}
engines: {node: '>=14.16'}
@ -6755,15 +6740,6 @@ packages:
resolution: {integrity: sha512-lOGyCnw+2JVPKU3wIV0srU0NyALwTBJlVSx5DfMQOFuuohA8y9S8orImpuIQikZ0uIQ8gehrRjxgQC1rLRi11w==}
dev: true
/@types/cacheable-request@6.0.1:
resolution: {integrity: sha512-ykFq2zmBGOCbpIXtoVbz4SKY5QriWPh3AjyU4G74RYbtt5yOc5OfaY75ftjg7mikMOla1CTGpX3lLbuJh8DTrQ==}
dependencies:
'@types/http-cache-semantics': 4.0.0
'@types/keyv': 3.1.1
'@types/node': 20.2.5
'@types/responselike': 1.0.0
dev: true
/@types/cheerio@0.22.16:
resolution: {integrity: sha512-bSbnU/D4yzFdzLpp3+rcDj0aQQMIRUBNJU7azPxdqMpnexjUSvGJyDuOBQBHeOZh1mMKgsJm6Dy+LLh80Ew4tQ==}
dependencies:
@ -6902,10 +6878,6 @@ packages:
'@types/node': 20.2.5
dev: true
/@types/http-cache-semantics@4.0.0:
resolution: {integrity: sha512-c3Xy026kOF7QOTn00hbIllV1dLR9hG9NkSrLQgCVs8NF6sBU+VGWjD3wLPhmh1TYAc7ugCFsvHYMN4VcBN1U1A==}
dev: true
/@types/http-proxy@1.17.3:
resolution: {integrity: sha512-wIPqXANye5BbORbuh74exbwNzj+UWCwWyeEFJzUQ7Fq3W2NSAy+7x7nX1fgbEypr2/TdKqpeuxLnXWgzN533/Q==}
dependencies:
@ -9059,14 +9031,6 @@ packages:
unset-value: 1.0.0
dev: false
/cacheable-lookup@2.0.1:
resolution: {integrity: sha512-EMMbsiOTcdngM/K6gV/OxF2x0t07+vMOWxZNSCRQMjO2MY2nhZQ6OYhOOpyQrbhqsgtvKGI7hcq6xjnA92USjg==}
engines: {node: '>=10'}
dependencies:
'@types/keyv': 3.1.1
keyv: 4.0.0
dev: true
/cacheable-request@6.1.0:
resolution: {integrity: sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==}
engines: {node: '>=8'}
@ -9080,19 +9044,6 @@ packages:
responselike: 1.0.2
dev: true
/cacheable-request@7.0.1:
resolution: {integrity: sha512-lt0mJ6YAnsrBErpTMWeu5kl/tg9xMAWjavYTN6VQXM1A/teBITuNcccXsCxF0tDQQJf9DfAaX5O4e0zp0KlfZw==}
engines: {node: '>=8'}
dependencies:
clone-response: 1.0.2
get-stream: 5.1.0
http-cache-semantics: 4.1.0
keyv: 4.0.0
lowercase-keys: 2.0.0
normalize-url: 4.5.0
responselike: 2.0.0
dev: true
/call-bind@1.0.2:
resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==}
dependencies:
@ -10782,13 +10733,6 @@ packages:
mimic-response: 1.0.1
dev: true
/decompress-response@5.0.0:
resolution: {integrity: sha512-TLZWWybuxWgoW7Lykv+gq9xvzOsUjQ9tF09Tj6NSTYGMTCHNXzrPnD6Hi+TgZq19PyTAGH4Ll/NIM/eTGglnMw==}
engines: {node: '>=10'}
dependencies:
mimic-response: 2.1.0
dev: true
/dedent@0.7.0:
resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==}
dev: true
@ -10843,11 +10787,6 @@ packages:
resolution: {integrity: sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==}
dev: true
/defer-to-connect@2.0.0:
resolution: {integrity: sha512-bYL2d05vOSf1JEZNx5vSAtPuBMkX8K9EUutg7zlKvTqKXHt7RhWJFbmd7qakVuf13i+IkGmp6FwSsONOf6VYIg==}
engines: {node: '>=10'}
dev: true
/defer-to-connect@2.0.1:
resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==}
engines: {node: '>=10'}
@ -13460,29 +13399,6 @@ packages:
dependencies:
get-intrinsic: 1.2.1
/got@10.7.0:
resolution: {integrity: sha512-aWTDeNw9g+XqEZNcTjMMZSy7B7yE9toWOFYip7ofFTLleJhvZwUxxTxkTpKvF+p1SAA4VHmuEy7PiHTHyq8tJg==}
engines: {node: '>=10'}
dependencies:
'@sindresorhus/is': 2.1.0
'@szmarczak/http-timer': 4.0.5
'@types/cacheable-request': 6.0.1
'@types/keyv': 3.1.1
'@types/responselike': 1.0.0
cacheable-lookup: 2.0.1
cacheable-request: 7.0.1
decompress-response: 5.0.0
duplexer3: 0.1.4
get-stream: 5.1.0
lowercase-keys: 2.0.0
mimic-response: 2.1.0
p-cancelable: 2.0.0
p-event: 4.1.0
responselike: 2.0.0
to-readable-stream: 2.1.0
type-fest: 0.10.0
dev: true
/got@7.1.0:
resolution: {integrity: sha512-Y5WMo7xKKq1muPsxD+KmrR8DH5auG7fBdDVueZwETwV6VytKyU9OX/ddpq2/1hp1vIPvVb4T81dKQz3BivkNLw==}
engines: {node: '>=4'}
@ -16118,10 +16034,6 @@ packages:
resolution: {integrity: sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=}
dev: true
/json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
dev: true
/json-merge-patch@1.0.2:
resolution: {integrity: sha512-M6Vp2GN9L7cfuMXiWOmHj9bEFbeC250iVtcKQbqVgEsDVYnIsrNsbU+h/Y/PkbBQCtEa4Bez+Ebv0zfbC8ObLg==}
dependencies:
@ -16287,12 +16199,6 @@ packages:
json-buffer: 3.0.0
dev: true
/keyv@4.0.0:
resolution: {integrity: sha512-U7ioE8AimvRVLfw4LffyOIRhL2xVgmE8T22L6i0BucSnBUyv4w+I7VN/zVZwRKHOI6ZRUcdMdWHQ8KSUvGpEog==}
dependencies:
json-buffer: 3.0.1
dev: true
/kind-of@3.2.2:
resolution: {integrity: sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==}
engines: {node: '>=0.10.0'}
@ -17792,11 +17698,6 @@ packages:
engines: {node: '>=4'}
dev: true
/mimic-response@2.1.0:
resolution: {integrity: sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==}
engines: {node: '>=8'}
dev: true
/min-indent@1.0.1:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
engines: {node: '>=4'}
@ -18859,18 +18760,6 @@ packages:
engines: {node: '>=6'}
dev: true
/p-cancelable@2.0.0:
resolution: {integrity: sha512-wvPXDmbMmu2ksjkB4Z3nZWTSkJEb9lqVdMaCKpZUGJG9TMiNp9XcbG3fn9fPKjem04fJMJnXoyFPk2FmgiaiNg==}
engines: {node: '>=8'}
dev: true
/p-event@4.1.0:
resolution: {integrity: sha512-4vAd06GCsgflX4wHN1JqrMzBh/8QZ4j+rzp0cd2scXRwuBEv+QR3wrVA5aLhWDLw4y2WgDKvzWF3CCLmVM1UgA==}
engines: {node: '>=8'}
dependencies:
p-timeout: 2.0.1
dev: true
/p-finally@1.0.0:
resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==}
engines: {node: '>=4'}
@ -18975,13 +18864,6 @@ packages:
p-finally: 1.0.0
dev: true
/p-timeout@2.0.1:
resolution: {integrity: sha512-88em58dDVB/KzPEx1X0N3LwFfYZPyDc4B6eF38M1rk9VTZMbxXXgjugz8mmwpS9Ox4BDZ+t6t3QP5+/gazweIA==}
engines: {node: '>=4'}
dependencies:
p-finally: 1.0.0
dev: true
/p-timeout@3.2.0:
resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==}
engines: {node: '>=8'}
@ -22026,12 +21908,6 @@ packages:
lowercase-keys: 1.0.1
dev: true
/responselike@2.0.0:
resolution: {integrity: sha512-xH48u3FTB9VsZw7R+vvgaKeLKzT6jOogbQhEe/jewwnZgzPcnyWui2Av6JpoYZF/91uueC+lqhWqeURw5/qhCw==}
dependencies:
lowercase-keys: 2.0.0
dev: true
/restore-cursor@2.0.0:
resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==}
engines: {node: '>=4'}
@ -23959,11 +23835,6 @@ packages:
engines: {node: '>=6'}
dev: true
/to-readable-stream@2.1.0:
resolution: {integrity: sha512-o3Qa6DGg1CEXshSdvWNX2sN4QHqg03SPq7U6jPXRahlQdl5dK8oXjkU/2/sGrnOZKeGV1zLSO8qPwyKklPPE7w==}
engines: {node: '>=8'}
dev: true
/to-regex-range@2.1.1:
resolution: {integrity: sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==}
engines: {node: '>=0.10.0'}
@ -24275,11 +24146,6 @@ packages:
engines: {node: '>=4'}
dev: true
/type-fest@0.10.0:
resolution: {integrity: sha512-EUV9jo4sffrwlg8s0zDhP0T2WD3pru5Xi0+HTE3zTUmBaZNhfkite9PdSJwdXLwPVW0jnAHT56pZHIOYckPEiw==}
engines: {node: '>=8'}
dev: true
/type-fest@0.13.1:
resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==}
engines: {node: '>=10'}