Skip to content

Commit

Permalink
refactor(cna): make create-next-app even smaller and faster (#58030)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
SukkaW authored Jan 11, 2024
1 parent e6e6609 commit b8b1045
Show file tree
Hide file tree
Showing 11 changed files with 101 additions and 229 deletions.
3 changes: 1 addition & 2 deletions packages/create-next-app/create-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
}
Expand Down
97 changes: 50 additions & 47 deletions packages/create-next-app/helpers/examples.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -82,50 +85,50 @@ 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) {
if (name === '__internal-testing-retry') {
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)
}
2 changes: 1 addition & 1 deletion packages/create-next-app/helpers/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.')
Expand Down
11 changes: 6 additions & 5 deletions packages/create-next-app/helpers/is-folder-empty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
47 changes: 26 additions & 21 deletions packages/create-next-app/helpers/is-online.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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)
}

const proxy = getProxy()
if (!proxy) {
return resolve(false)
}
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 { hostname } = url.parse(proxy)
if (!hostname) {
return resolve(false)
}
const { hostname } = url.parse(proxy)
if (!hostname) {
// Invalid proxy URL
return 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
}
}
}
8 changes: 0 additions & 8 deletions packages/create-next-app/helpers/make-dir.ts

This file was deleted.

14 changes: 10 additions & 4 deletions packages/create-next-app/helpers/validate-pkg.ts
Original file line number Diff line number Diff line change
@@ -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 }
Expand Down
10 changes: 6 additions & 4 deletions packages/create-next-app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
},
})

Expand All @@ -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)
}

Expand Down
1 change: 0 additions & 1 deletion packages/create-next-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 1 addition & 2 deletions packages/create-next-app/templates/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit b8b1045

Please sign in to comment.