diff --git a/.gitignore b/.gitignore index 7011eb7af..e7d591c2c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,6 @@ playground-bun* playground-deno* playground-node* packages/nuxt-cli/test/fixtures/.tmp-* -packages/nuxi/src/utils/completions-data.ts packages/nuxt-cli/src/data/nitro-presets.ts packages/nuxt-cli/src/data/templates.ts bench/results diff --git a/bench/README.md b/bench/README.md index 2fb156968..32586a8ad 100644 --- a/bench/README.md +++ b/bench/README.md @@ -21,11 +21,12 @@ Useful flags: | Flag | Default | Meaning | | --- | --- | --- | | `--baseline` | `latest` | Any npm spec for `@nuxt/cli` to treat as the "before", or `ref:` to build the baseline from a commit of this repo (CI uses the PR's base sha) | -| `--suite` | all | Repeatable. One of `startup`, `modules`, `dev`, `restart`, `build`, `footprint` | +| `--suite` | all | Repeatable. One of `startup`, `modules`, `panel`, `dev`, `restart`, `build`, `footprint` | | `--fixture` | `playground`, `large` | Repeatable. Which fixture to run the project-level suites against | | `--workdir` | `~/.cache/nuxt-cli-bench` | Where isolated installs and fixtures live | | `--out` | `bench/results/report.md` | Markdown report path. A `.json` sibling is written alongside | | `--startup-reps` | `15` | Repetitions for the startup suite | +| `--panel-reps` | `5` | Repetitions for the panel suite | | `--dev-reps` | `5` | Repetitions for the dev-server suite | | `--restart-reps` | `5` | Repetitions for the restart suite | | `--build-reps` | `3` | Repetitions for the build suite | @@ -41,6 +42,7 @@ pnpm bench:cli --suite startup --startup-reps 31 - **`startup`** - cold `nuxi --version`, `--help`, and unknown-command time. Catches anything pulled onto the module graph of the fast paths. - **`modules`** - how many modules each target loads for those same commands, via `bench/lib/module-hook.mjs`. A count rather than a time, so it is stable across machines and is the better regression guard of the two. +- **`panel`** - time from spawn to the first painted `nuxt dev` frame, and until a keypress at that frame is answered. - **`dev`** - time from spawn to the dev server answering a request. - **`restart`** - time to serve again after a `nuxt.config.ts` edit, plus a no-op edit case to check that ignored changes stay ignored. - **`build`** - wall time for `nuxt build`. diff --git a/docs/1.overview.md b/docs/1.overview.md index be734a0f7..7d097d7c8 100644 --- a/docs/1.overview.md +++ b/docs/1.overview.md @@ -20,7 +20,7 @@ The binary is installed as `nuxt`, and also as `nuxi`, `nuxi-ng` and `nuxt-cli` ## Requirements -The CLI runs on Node v22.19+, v24.11+ or v26+, and imports TypeScript configuration natively on those versions. Nuxt 2 and `@nuxt/bridge` are not supported. +The CLI runs on Node v22.21+, v24.11+ or v26+, and imports TypeScript configuration natively on those versions. Nuxt 2 and `@nuxt/bridge` are not supported. ## Running commands diff --git a/knip.json b/knip.json index 85ff543d5..681f39c5e 100644 --- a/knip.json +++ b/knip.json @@ -13,9 +13,7 @@ ], "entry": [ "scripts/*", - "bench/run.ts", "bench/ab.ts", - "capture/record.ts", "capture/lib/fetch-stub.mjs" ], "ignore": [ diff --git a/packages/create-nuxt/package.json b/packages/create-nuxt/package.json index 640752331..89566781c 100644 --- a/packages/create-nuxt/package.json +++ b/packages/create-nuxt/package.json @@ -22,7 +22,7 @@ "dist" ], "engines": { - "node": "^22.19.0 || ^24.11.0 || >=26.0.0" + "node": "^22.21.0 || ^24.11.0 || >=26.0.0" }, "scripts": { "build": "tsdown", diff --git a/packages/create-nuxt/src/init.ts b/packages/create-nuxt/src/init.ts index b6aafd520..d1034892e 100644 --- a/packages/create-nuxt/src/init.ts +++ b/packages/create-nuxt/src/init.ts @@ -25,7 +25,7 @@ import { runCommandDef as runCommand } from '../../nuxt-cli/src/run-command' import { nuxtIcon } from '../../nuxt-cli/src/utils/ascii' import { fetchJson } from '../../nuxt-cli/src/utils/fetch' import { formatHeadlessCommand } from '../../nuxt-cli/src/utils/headless' -import { createInstallLog, runInstall, takeUnreportedIgnoredBuilds } from '../../nuxt-cli/src/utils/install' +import { createInstallLog, isVerboseInstall, runInstall, takeUnreportedIgnoredBuilds } from '../../nuxt-cli/src/utils/install' import { debug, logger } from '../../nuxt-cli/src/utils/logger' import { classifyNetworkError, describeNetworkError, logNetworkError, probeNetworkError } from '../../nuxt-cli/src/utils/network' import { detectPackageManager, isPackageManagerName, packageManagerNames } from '../../nuxt-cli/src/utils/package-managers' @@ -293,11 +293,6 @@ export default defineCommand({ templateName ||= DEFAULT_TEMPLATE_NAME - if (typeof templateName !== 'string') { - logger.error('Please specify a template!') - process.exit(1) - } - let dir = ctx.args.dir if (dir === '') { const defaultDir = availableTemplates[templateName]?.defaultDir || 'nuxt-app' @@ -434,12 +429,6 @@ export default defineCommand({ }) const nightlyChannelTag = ctx.args.nightly || 'latest' - if (!nightlyChannelTag) { - nightlySpinner.error('Failed to get nightly channel tag') - logger.error(`Error getting nightly channel tag.`) - process.exit(1) - } - const nightlyChannelVersion = response['dist-tags'][nightlyChannelTag] if (!nightlyChannelVersion) { @@ -548,14 +537,13 @@ export default defineCommand({ prompted = true } - if (!installRequested || skipInstallOnConflict) { - if (!skipInstallOnConflict) { - logger.info('Skipping install dependencies step.') - } + const shouldInstall = installRequested && !skipInstallOnConflict + if (!installRequested) { + logger.info('Skipping install dependencies step.') } - else { + else if (shouldInstall) { const installController = new AbortController() - const installLog = createInstallLog({ verbose: isVerbose(ctx.args.logLevel) }) + const installLog = createInstallLog({ verbose: isVerboseInstall(ctx.args.logLevel) }) const installSpinner = createSpinner({ indicator: 'timer', onCancel: () => installController.abort(), @@ -715,7 +703,7 @@ export default defineCommand({ const args: string[] = [ ...modulesToAdd, `--cwd=${template.dir}`, - installRequested && !skipInstallOnConflict ? '' : '--skipInstall', + shouldInstall ? '' : '--skipInstall', `--packageManager=${selectedPackageManager}`, ctx.args.logLevel ? `--logLevel=${ctx.args.logLevel}` : '', ].filter(Boolean) @@ -786,8 +774,7 @@ async function getModuleDependencies(moduleName: string) { const url = `https://registry.npmjs.org/${moduleName}/latest` try { const response = await fetchJson<{ dependencies?: Record }>(url) - const dependencies = response.dependencies || {} - return Object.keys(dependencies) + return Object.keys(response.dependencies || {}) } catch (err) { logNetworkError(err, { url, level: 'warn', prefix: `Could not get dependencies for ${styleText('cyan', moduleName)}.` }) @@ -796,28 +783,11 @@ async function getModuleDependencies(moduleName: string) { } function filterModules(modules: string[], allDependencies: Record) { - const result = { - toInstall: [] as string[], - skipped: [] as string[], + const isDependency = (module: string) => modules.some(other => other !== module && allDependencies[other]?.includes(module)) + return { + toInstall: modules.filter(module => !isDependency(module)), + skipped: modules.filter(isDependency), } - - for (const module of modules) { - const isDependency = modules.some((otherModule) => { - if (otherModule === module) - return false - const deps = allDependencies[otherModule] || [] - return deps.includes(module) - }) - - if (isDependency) { - result.skipped.push(module) - } - else { - result.toInstall.push(module) - } - } - - return result } async function getTemplateDependencies(templateDir: string) { @@ -827,22 +797,9 @@ async function getTemplateDependencies(templateDir: string) { return [] } const packageJson = await readPackageJSON(packageJsonPath) - const directDeps = { - ...packageJson.dependencies, - ...packageJson.devDependencies, - } - const directDepNames = Object.keys(directDeps) - const allDeps = new Set(directDepNames) - - const transitiveDepsResults = await Promise.all( - directDepNames.map(dep => getModuleDependencies(dep)), - ) - - transitiveDepsResults.forEach((deps) => { - deps.forEach(dep => allDeps.add(dep)) - }) - - return [...allDeps] + const directDepNames = Object.keys({ ...packageJson.dependencies, ...packageJson.devDependencies }) + const transitiveDeps = await Promise.all(directDepNames.map(getModuleDependencies)) + return [...new Set([...directDepNames, ...transitiveDeps.flat()])] } catch (err) { logger.warn(`Could not read template dependencies: ${err}`) @@ -850,10 +807,6 @@ async function getTemplateDependencies(templateDir: string) { } } -function isVerbose(logLevel?: string) { - return logLevel === 'verbose' || Boolean(process.env.DEBUG) -} - function detectCurrentPackageManager() { const userAgent = process.env.npm_config_user_agent if (!userAgent) { diff --git a/packages/nuxi/package.json b/packages/nuxi/package.json index 0941dcf47..c44ca3f11 100644 --- a/packages/nuxi/package.json +++ b/packages/nuxi/package.json @@ -25,7 +25,7 @@ "dist" ], "engines": { - "node": "^22.19.0 || ^24.11.0 || >=26.0.0" + "node": "^22.21.0 || ^24.11.0 || >=26.0.0" }, "scripts": { "build": "tsdown", diff --git a/packages/nuxi/src/main.ts b/packages/nuxi/src/main.ts index 8a9ceac93..64cbeb40b 100644 --- a/packages/nuxi/src/main.ts +++ b/packages/nuxi/src/main.ts @@ -85,7 +85,7 @@ const _main = defineCommand({ }) async function reportUnknownCommand(command: string): Promise { - const { suggestCommand } = await import('../../nuxt-cli/src/utils/suggest-command') + const { suggestCommand } = await import('../../nuxt-cli/src/utils/suggest') const suggestion = await suggestCommand(command, nuxiCommands.filter(name => !name.startsWith('_'))) if (!suggestion) { return diff --git a/packages/nuxt-cli/package.json b/packages/nuxt-cli/package.json index 226b72782..35159cb49 100644 --- a/packages/nuxt-cli/package.json +++ b/packages/nuxt-cli/package.json @@ -29,7 +29,7 @@ "runtime" ], "engines": { - "node": "^22.19.0 || ^24.11.0 || >=26.0.0" + "node": "^22.21.0 || ^24.11.0 || >=26.0.0" }, "scripts": { "build": "tsdown", diff --git a/packages/nuxt-cli/runtime/dev-request-context.mjs b/packages/nuxt-cli/runtime/dev-request-context.mjs index ba0a0a360..7b2782f1b 100644 --- a/packages/nuxt-cli/runtime/dev-request-context.mjs +++ b/packages/nuxt-cli/runtime/dev-request-context.mjs @@ -229,23 +229,25 @@ function parseRequest(id, label) { return { id, label: decoded } } +function withRequest(read, remove, serve) { + let request + try { + request = parseRequest(read(HEADER), read(LABEL_HEADER)) + if (request) { + remove(LABEL_HEADER) + } + } + catch {} + return request ? storage.run(request, serve) : serve() +} + function trackRequests(nitroApp) { const h3App = nitroApp?.h3App if (typeof h3App?.handler === 'function') { const handler = h3App.handler h3App.handler = Object.assign(function (event) { - let request - try { - const headers = event?.node?.req?.headers - request = parseRequest(headers?.[HEADER], headers?.[LABEL_HEADER]) - if (request) { - delete headers[LABEL_HEADER] - } - } - catch {} - return request - ? storage.run(request, () => handler.call(this, event)) - : handler.call(this, event) + const headers = event?.node?.req?.headers + return withRequest(name => headers?.[name], name => delete headers[name], () => handler.call(this, event)) }, handler) return } @@ -253,19 +255,7 @@ function trackRequests(nitroApp) { // Every nitro v3 entry, dev and deployed, serves through `nitroApp.fetch`. if (typeof nitroApp?.fetch === 'function') { const fetch = nitroApp.fetch.bind(nitroApp) - nitroApp.fetch = (req, ...args) => { - let request - try { - request = parseRequest(req?.headers?.get?.(HEADER), req?.headers?.get?.(LABEL_HEADER)) - if (request) { - req.headers.delete(LABEL_HEADER) - } - } - catch {} - return request - ? storage.run(request, () => fetch(req, ...args)) - : fetch(req, ...args) - } + nitroApp.fetch = (req, ...args) => withRequest(name => req?.headers?.get?.(name), name => req.headers.delete(name), () => fetch(req, ...args)) } } diff --git a/packages/nuxt-cli/src/commands/dev.ts b/packages/nuxt-cli/src/commands/dev.ts index ba266f74a..5652eadd4 100644 --- a/packages/nuxt-cli/src/commands/dev.ts +++ b/packages/nuxt-cli/src/commands/dev.ts @@ -19,12 +19,11 @@ import { ForkPool } from '../dev/pool' import { preflight } from '../dev/preflight' import { formatRestartReason } from '../dev/reason' import { devShortcutContext } from '../dev/shortcut-context' -import { adoptShutdown, SUPERVISOR_SHUTDOWN_TIMEOUT_MS } from '../dev/shutdown' +import { handleShutdownSignals } from '../dev/shutdown' import { formatTakeoverRefusal, takeOverDevServer } from '../dev/takeover' import { beginDevUI, setupDevUI, teardownDevUI } from '../dev/tui/controller' import { replaceCwdArg } from '../utils/args' import { resolveLockDir } from '../utils/dev-server' -import { summariseActiveResources } from '../utils/hang' import { debug, logger } from '../utils/logger' import { resolveRootDir } from '../utils/paths' import { startupElapsedMs } from '../utils/startup-clock' @@ -32,7 +31,6 @@ import { dotEnvArgs, envNameArgs, extendsArgs, logLevelArgs, profileArgs, rootDi const startTime: number | undefined = Date.now() -const SHUTDOWN_NOTICE_MS = 1500 const forkSupported = !isTest && (!isBun || isBunForkSupported()) const command = defineCommand({ @@ -235,8 +233,7 @@ const command = defineCommand({ const { context: shortcutContext, attach: attachServer, provide } = devShortcutContext() provide({ clearCaches }) const startingUI = ui ? await setupDevUI(shortcutContext, { ...uiOptions, enabled: true }) : undefined - setupSignalHandlers(() => shortcutContext.close()) - adoptShutdown() + handleShutdownSignals(() => shortcutContext.close()) // Evaluating the dev server's graph blocks the loop; let the panel answer // anything already typed first. @@ -479,64 +476,6 @@ async function beforeServing(work: () => Promise): Promise { } } -/** - * Shut the dev server down on `SIGINT`/`SIGTERM`. - * - * Registering any listener for these signals (the fork pool and the CPU - * profiler both do) suppresses Node's default exit behaviour, so Ctrl-C would - * otherwise leave the server, its forks and any tunnel running. - * - * Shutdown is given enough time for `close` hooks (nitro plugins closing database - * connections, and so on) to finish; a second Ctrl-C skips the wait. - */ -function setupSignalHandlers(close: () => Promise): void { - let closing = false - for (const signal of ['SIGINT', 'SIGTERM'] as const) { - process.on(signal, () => { - if (closing) { - process.exit(130) - } - closing = true - - // Ctrl-C should always give the terminal back, even if a watcher or an - // open connection stops the graceful shutdown from settling. - const deadline = setTimeout(() => { - const summary = summariseActiveResources() - logger.warn(`The dev server did not shut down within ${SUPERVISOR_SHUTDOWN_TIMEOUT_MS / 1000}s${summary ? `: ${summary}` : ''}. Exiting anyway.`) - process.exit() - }, SUPERVISOR_SHUTDOWN_TIMEOUT_MS) - - // Closing can take a while (nitro plugins draining connections, forks - // exiting), so it says so rather than appearing to hang. - void shutdownWithSpinner(async (indicator) => { - const notice = setTimeout(() => { - indicator.update('Cleaning up... press Ctrl-C again to exit immediately') - }, SHUTDOWN_NOTICE_MS) - notice.unref?.() - try { - await close() - } - catch (error) { - console.error(error) - process.exitCode = 1 - } - finally { - clearTimeout(notice) - clearTimeout(deadline) - } - }).finally(() => { - process.exit() - }) - }) - } -} - -/** `withSpinner`, loaded on the way out rather than on every `nuxt dev`. */ -async function shutdownWithSpinner(work: (indicator: { update: (message: string) => void }) => Promise): Promise { - const { withSpinner } = await import('../utils/spinner') - return withSpinner('Cleaning up', work, { done: 'Stopped the dev server' }) -} - function resolveForkPoolSize(): number | undefined { const raw = process.env.NUXT_DEV_FORK_POOL_SIZE if (!raw) { diff --git a/packages/nuxt-cli/src/commands/info.ts b/packages/nuxt-cli/src/commands/info.ts index 35e741af5..2831860b7 100644 --- a/packages/nuxt-cli/src/commands/info.ts +++ b/packages/nuxt-cli/src/commands/info.ts @@ -9,14 +9,13 @@ import { box } from '@clack/prompts' import { defineCommand } from 'citty' import { readPackageJSON } from 'pkg-types' -import { camelCase } from 'scule' import { isBun, isDeno, isMinimal } from 'std-env' import { writeText } from 'tinyclip' import { version as nuxiVersion } from '../../package.json' import { getBuilder } from '../utils/banner' import { resolveCatalogEntry } from '../utils/catalog' -import { withDirectStdout } from '../utils/console' +import { printJson } from '../utils/console' import { formatInfoBox } from '../utils/formatting' import { logger } from '../utils/logger' import { resolveNitroVersion } from '../utils/nitro' @@ -59,10 +58,11 @@ export default defineCommand({ }, async run(ctx) { const cwd = resolveRootDir(ctx.args) - const [nuxtConfig, projectPkg, detectedPackageManager] = await Promise.all([ + const packageManagerPromise = detectPackageManager(cwd).then(async detected => + detected ? `${detected.name}@${await getPackageManagerVersion(detected.name)}` : 'unknown') + const [nuxtConfig, projectPkg] = await Promise.all([ getNuxtConfig(cwd), readPackageJSON(cwd).catch(() => ({} as PackageJson)), - detectPackageManager(cwd), ]) const { dependencies = {}, devDependencies = {} } = projectPkg const nuxtPath = tryResolveNuxt(cwd) @@ -86,17 +86,15 @@ export default defineCommand({ const version = packageName && await getDepVersion(packageName) return version ? `${name}@${version}` : name })) - const [modules, nuxtVersion = '-', nitroVersion] = await Promise.all([ + const [modules, nuxtVersion = '-', nitroVersion, packageManager] = await Promise.all([ modulesPromise, getDepVersion('nuxt').then(version => version || getDepVersion('nuxt-nightly')), resolveNitroVersion(cwd, getDepVersion), + packageManagerPromise, ]) const configKeys = Object.keys(nuxtConfig).sort() const moduleNames = modules.filter(module => module !== null) const builder = nuxtConfig.builder || 'vite' - const packageManager = detectedPackageManager - ? `${detectedPackageManager.name}@${getPackageManagerVersion(detectedPackageManager.name)}` - : 'unknown' const osType = os.type() const cpus = os.cpus() const builderInfo = typeof builder === 'string' && ['vite', '@nuxt/vite-builder', 'webpack', '@nuxt/webpack-builder', 'rspack', '@nuxt/rspack-builder'].includes(builder) @@ -126,14 +124,13 @@ export default defineCommand({ // Arrays come from the source values rather than the rendered string, so a // key or module path containing `, ` stays a single entry. const lists: Record = { config: configKeys, modules: moduleNames } - const payload = JSON.stringify({ + await printJson({ rootDir: nuxtConfig.rootDir || cwd, ...Object.fromEntries(Object.entries(infoObj).map(([label, value]) => { - const key = JSON_KEYS[label] ?? camelCase(label) + const key = JSON_KEYS[label]! return [key, lists[key] ?? (value?.replaceAll('`', '') || null)] })), - }, null, 2) - await withDirectStdout(() => process.stdout.write(`${payload}\n`)) + }) return } diff --git a/packages/nuxt-cli/src/commands/module/add.ts b/packages/nuxt-cli/src/commands/module/add.ts index 4db939e90..ef1d36f10 100644 --- a/packages/nuxt-cli/src/commands/module/add.ts +++ b/packages/nuxt-cli/src/commands/module/add.ts @@ -2,7 +2,6 @@ import type { DetectResult } from 'package-manager-detector' import type { PackageJson } from 'pkg-types' import type { ConfigEntries } from '../../utils/config' -import type { RegistryMeta } from '../../utils/registry' import type { NuxtModule } from './_utils' import process from 'node:process' @@ -16,7 +15,7 @@ import { findMaxSatisfying, satisfies } from 'verkit' import { runCommandDef as runCommand } from '../../run-command' import { addNuxtConfigEntries, createNuxtConfig, readNuxtConfig } from '../../utils/config' import { fetchJson } from '../../utils/fetch' -import { createInstallLog, runInstall, takeUnreportedIgnoredBuilds } from '../../utils/install' +import { createInstallLog, isVerboseInstall, runInstall, takeUnreportedIgnoredBuilds } from '../../utils/install' import { logger } from '../../utils/logger' import { logNetworkError } from '../../utils/network' import { defaultPackageManager, detectPackageManager, isPackageManagerName, packageManagerNames } from '../../utils/package-managers' @@ -31,10 +30,8 @@ import { basePackageName, checkNuxtCompatibility, ensureNuxtDependency, fetchMod const WHITESPACE_RE = /\s/ interface ResolvedModule { - nuxtModule?: NuxtModule pkg: string pkgName: string - pkgVersion: string /** Specifier to write to `nuxt.config`, which may include a subpath. */ specifier: string /** Whether the package is a Nuxt layer, and so belongs in `extends`. */ @@ -42,8 +39,6 @@ interface ResolvedModule { peerDependencies?: Record optionalPeerDependencies?: string[] } -type UnresolvedModule = false -type ModuleResolution = ResolvedModule | UnresolvedModule /** * `layers` only affects help text: `nuxt add` is documented as accepting layers as @@ -161,22 +156,11 @@ export function defineAddCommand({ layers = false }: { layers?: boolean } = {}) export default defineAddCommand() -// -- Internal Utils -- async function addModules(modules: ResolvedModule[], { skipInstall = false, skipConfig = false, cwd, dev = false, packageManager: packageManagerName, logLevel }: { skipInstall?: boolean, skipConfig?: boolean, cwd: string, dev?: boolean, packageManager?: string, logLevel?: string }, projectPkg: PackageJson): Promise { if (!skipInstall) { - const installedModules: ResolvedModule[] = [] - const notInstalledModules: ResolvedModule[] = [] - const dependencies = getProjectDependencies(projectPkg) - - for (const module of modules) { - if (dependencies.has(module.pkgName)) { - installedModules.push(module) - } - else { - notInstalledModules.push(module) - } - } + const installedModules = modules.filter(module => dependencies.has(module.pkgName)) + const notInstalledModules = modules.filter(module => !dependencies.has(module.pkgName)) if (installedModules.length > 0) { const installedModulesList = installedModules.map(module => styleText('cyan', module.pkgName)).join(', ') @@ -199,9 +183,8 @@ async function addModules(modules: ResolvedModule[], { skipInstall = false, skip logger.info(`Also installing required peer ${peers.length > 1 ? 'dependencies' : 'dependency'} ${peers.map(peer => styleText('cyan', peer)).join(', ')}`) } - const verbose = logLevel === 'verbose' || Boolean(process.env.DEBUG) const installController = new AbortController() - const installLog = createInstallLog({ verbose }) + const installLog = createInstallLog({ verbose: isVerboseInstall(logLevel) }) const installSpinner = createSpinner({ indicator: 'timer', onCancel: () => installController.abort(), @@ -344,7 +327,7 @@ export default defineNuxtConfig({ })` } -async function resolveModule(moduleName: string, cwd: string, modulesDB: NuxtModule[], getProjectNuxtVersion: () => Promise): Promise { +async function resolveModule(moduleName: string, cwd: string, modulesDB: NuxtModule[], getProjectNuxtVersion: () => Promise): Promise { const spec = parseModuleSpec(moduleName) if (!spec) { @@ -420,14 +403,9 @@ async function resolveModule(moduleName: string, cwd: string, modulesDB: NuxtMod let version = pkgVersion || 'latest' const pkgScope = pkgName.startsWith('@') ? pkgName.split('/')[0]! : null - const meta: RegistryMeta = await detectNpmRegistry(pkgScope, cwd) - const headers: HeadersInit = {} - - if (meta.authorization) { - headers.Authorization = meta.authorization - } + const meta = await detectNpmRegistry(pkgScope, cwd) + const headers: HeadersInit = meta.authorization ? { Authorization: meta.authorization } : {} - // TODO: spinner const pkgUrl = `${meta.registry}/${pkgName}` const pkgDetails = await fetchJson(pkgUrl, { headers }).catch((err: unknown) => { logNetworkError(err, { url: pkgUrl, prefix: `Failed to fetch package details for ${styleText('cyan', pkgName)}.` }) @@ -455,11 +433,7 @@ async function resolveModule(moduleName: string, cwd: string, modulesDB: NuxtMod logger.info(`${styleText('cyan', pkgName)} is a Nuxt layer, and will be added to ${styleText('cyan', 'extends')}.`) } - const pkgDependencies = Object.assign( - pkg.dependencies || {}, - pkg.devDependencies || {}, - pkg.peerDependencies || {}, - ) + const pkgDependencies = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies } // A package exposing its module behind a `nuxt`/`module` subpath is a Nuxt // integration regardless of how it declares its dependency on Nuxt. if ( @@ -479,10 +453,8 @@ async function resolveModule(moduleName: string, cwd: string, modulesDB: NuxtMod } return { - nuxtModule: matchedModule, pkg: `${pkgName}@${version}`, pkgName, - pkgVersion: version, specifier: subpath ? `${pkgName}/${subpath}` : pkgName, isLayer: entry.isLayer, peerDependencies: pkg.peerDependencies, diff --git a/packages/nuxt-cli/src/commands/module/remove.ts b/packages/nuxt-cli/src/commands/module/remove.ts index a632661cc..9b53c51c8 100644 --- a/packages/nuxt-cli/src/commands/module/remove.ts +++ b/packages/nuxt-cli/src/commands/module/remove.ts @@ -94,7 +94,6 @@ export default defineCommand({ }, }) -// -- Internal Utils -- async function removeModules(modules: string[], { skipInstall = false, skipConfig = false, cwd }: { skipInstall?: boolean, skipConfig?: boolean, cwd: string }, projectPkg: PackageJson): Promise { const removedFromConfig: string[] = [] const dependencies = getProjectDependencies(projectPkg) @@ -158,9 +157,6 @@ async function removeModules(modules: string[], { skipInstall = false, skipConfi } if (!skipInstall) { - const installedModules: string[] = [] - const notInstalledModules: string[] = [] - // Entries removed from the config are only uninstalled when they name an // installed package, so a local layer (`./layers/admin`) is left alone. const targets = Array.from(new Set([ @@ -168,14 +164,8 @@ async function removeModules(modules: string[], { skipInstall = false, skipConfi ...removedFromConfig.map(basePackageName).filter(name => dependencies.has(name)), ])) - for (const module of targets) { - if (dependencies.has(module)) { - installedModules.push(module) - } - else { - notInstalledModules.push(module) - } - } + const installedModules = targets.filter(module => dependencies.has(module)) + const notInstalledModules = targets.filter(module => !dependencies.has(module)) if (notInstalledModules.length > 0) { const notInstalledList = notInstalledModules.map(m => styleText('cyan', m)).join(', ') @@ -297,11 +287,7 @@ async function findOrphanedPeers(removing: string[], projectPkg: PackageJson, cw } } - const orphans: OrphanedPeer[] = [] - for (const [peer, source] of candidates) { - if (!stillNeeded.has(peer)) { - orphans.push({ peer, source }) - } - } - return orphans + return [...candidates] + .filter(([peer]) => !stillNeeded.has(peer)) + .map(([peer, source]) => ({ peer, source })) } diff --git a/packages/nuxt-cli/src/commands/module/search.ts b/packages/nuxt-cli/src/commands/module/search.ts index 74b683590..7f32ef749 100644 --- a/packages/nuxt-cli/src/commands/module/search.ts +++ b/packages/nuxt-cli/src/commands/module/search.ts @@ -6,7 +6,7 @@ import { defineCommand } from 'citty' import fuzzysort from 'fuzzysort' import { kebabCase, upperFirst } from 'scule' -import { withDirectStdout } from '../../utils/console' +import { printJson } from '../../utils/console' import { formatInfoBox } from '../../utils/formatting' import { logger } from '../../utils/logger' import { logNetworkError } from '../../utils/network' @@ -95,7 +95,7 @@ async function findModuleByKeywords(query: string, nuxtVersion: string, json?: b }).map(({ obj: { item } }) => item) if (json) { - const payload = JSON.stringify({ + await printJson({ query, nuxtVersion, modules: matches.map(item => ({ @@ -109,8 +109,7 @@ async function findModuleByKeywords(query: string, nuxtVersion: string, json?: b monthlyDownloads: item.stats.downloads, install: `npx nuxt add ${item.name}`, })), - }, null, 2) - await withDirectStdout(() => process.stdout.write(`${payload}\n`)) + }) return } diff --git a/packages/nuxt-cli/src/commands/task/list.ts b/packages/nuxt-cli/src/commands/task/list.ts index 1536548f5..44be97653 100644 --- a/packages/nuxt-cli/src/commands/task/list.ts +++ b/packages/nuxt-cli/src/commands/task/list.ts @@ -5,7 +5,7 @@ import { styleText } from 'node:util' import { defineCommand } from 'citty' -import { withDirectStdout } from '../../utils/console' +import { printJson } from '../../utils/console' import { logger } from '../../utils/logger' import { resolveRootDir } from '../../utils/paths' import { jsonArgs, rootDirArgs } from '../_shared' @@ -38,11 +38,10 @@ export default defineCommand({ const names = Object.keys(tasks).sort() if (ctx.args.json) { - const payload = JSON.stringify({ + await printJson({ tasks: names.map(name => ({ name, description: tasks[name]?.description || null })), scheduledTasks: (scheduledTasks || []).map(({ cron, tasks }) => ({ cron, tasks })), - }, null, 2) - await withDirectStdout(() => process.stdout.write(`${payload}\n`)) + }) return } diff --git a/packages/nuxt-cli/src/commands/typecheck.ts b/packages/nuxt-cli/src/commands/typecheck.ts index 358b732e2..535ed2906 100644 --- a/packages/nuxt-cli/src/commands/typecheck.ts +++ b/packages/nuxt-cli/src/commands/typecheck.ts @@ -14,6 +14,7 @@ import { hasTTY } from 'std-env' import { x } from 'tinyexec' import { resolveDotenvFileNames } from '../utils/args' +import { formatDuration } from '../utils/formatting' import { loadKit } from '../utils/kit' import { logger } from '../utils/logger' import { resolveRootDir } from '../utils/paths' @@ -164,7 +165,7 @@ export default defineCommand({ const result = await x(typechecker.bin, TYPE_CHECKERS[typechecker.checker].args(useProjectReferences), { nodeOptions: { stdio: 'inherit', cwd }, }) - const duration = `${Date.now() - start}ms` + const duration = formatDuration(Date.now() - start) if (result.exitCode === 0) { if (hasTTY) { diff --git a/packages/nuxt-cli/src/commands/upgrade.ts b/packages/nuxt-cli/src/commands/upgrade.ts index fa18f51f2..b64af14ed 100644 --- a/packages/nuxt-cli/src/commands/upgrade.ts +++ b/packages/nuxt-cli/src/commands/upgrade.ts @@ -183,12 +183,15 @@ export default defineCommand({ } const { name: packageManagerName } = packageManager const packageManagerVersion = getPackageManagerVersion(packageManagerName) - logger.step(`Package manager: ${styleText('cyan', packageManagerName)} ${packageManagerVersion}`) + const currentVersionPromise = getNuxtVersion(cwd) + currentVersionPromise.catch(() => {}) + const pkgPromise = readPackageJSON(cwd).catch(() => null) - const currentVersion = (await getNuxtVersion(cwd)) || '[unknown]' + logger.step(`Package manager: ${styleText('cyan', packageManagerName)} ${await packageManagerVersion}`) + const currentVersion = (await currentVersionPromise) || '[unknown]' logger.step(`Current Nuxt version: ${styleText('cyan', currentVersion)}`) - const pkg = await readPackageJSON(cwd).catch(() => null) + const pkg = await pkgPromise const nuxtDependencyType = pkg ? checkNuxtDependencyType(pkg) : 'dependencies' const corePackages = ['@nuxt/kit', '@nuxt/schema', '@nuxt/vite-builder', '@nuxt/webpack-builder', '@nuxt/rspack-builder'] diff --git a/packages/nuxt-cli/src/dev/config-watcher.ts b/packages/nuxt-cli/src/dev/config-watcher.ts new file mode 100644 index 000000000..daf792ff6 --- /dev/null +++ b/packages/nuxt-cli/src/dev/config-watcher.ts @@ -0,0 +1,216 @@ +import type { Stats } from 'node:fs' + +import { Buffer } from 'node:buffer' +import { hash } from 'node:crypto' +import { closeSync, existsSync, openSync, readdirSync, readSync, statSync, watch } from 'node:fs' + +import { join, resolve } from 'pathe' + +const RESTART_RE = /^(?:nuxt\.config\.[a-z0-9]+|\.nuxtignore|\.nuxtrc|\.config\/nuxt(?:\.config)?\.[a-z0-9]+)$/ + +/** Files above this size are tracked by mtime alone. */ +const MAX_HASHED_FILE_SIZE = 256 * 1024 + +interface TrackedFile { + mtimeMs: number + /** Absent for directories and for files too large to hash. */ + contentHash?: string +} + +function hashFileContents(path: string, size: number): string | undefined { + if (size > MAX_HASHED_FILE_SIZE) { + return undefined + } + let fd: number | undefined + try { + fd = openSync(path, 'r') + // The stat size may be stale; reading one extra byte detects a file that outgrew the limit. + const buffer = Buffer.allocUnsafe(MAX_HASHED_FILE_SIZE + 1) + let read = 0 + while (read < buffer.length) { + const bytes = readSync(fd, buffer, read, buffer.length - read, read) + if (bytes === 0) { + break + } + read += bytes + } + if (read > MAX_HASHED_FILE_SIZE) { + return undefined + } + return hash('sha1', buffer.subarray(0, read), 'hex') + } + catch { + return undefined + } + finally { + if (fd !== undefined) { + try { + closeSync(fd) + } + catch {} + } + } +} + +function trackFile(path: string, stats: Stats): TrackedFile { + if (stats.isDirectory()) { + return { mtimeMs: stats.mtimeMs } + } + return { mtimeMs: stats.mtimeMs, contentHash: hashFileContents(path, stats.size) } +} + +export class FileChangeTracker { + private entries = new Map() + + /** Whether `filePath` changed: by content for regular files, by mtime otherwise. */ + shouldEmitChange(filePath: string): boolean { + const resolved = resolve(filePath) + try { + const stats = statSync(resolved) + const previous = this.entries.get(resolved) + const current = trackFile(resolved, stats) + + this.entries.set(resolved, current) + + if (previous === undefined) { + return true + } + if (previous.contentHash !== undefined && current.contentHash !== undefined) { + return previous.contentHash !== current.contentHash + } + return previous.mtimeMs !== current.mtimeMs + } + catch { + this.entries.delete(resolved) + return true + } + } + + prime(filePath: string, recursive: boolean = false): void { + const resolved = resolve(filePath) + const stat = statSync(resolved) + this.entries.set(resolved, trackFile(resolved, stat)) + if (stat.isDirectory()) { + const entries = readdirSync(resolved) + for (const entry of entries) { + const fullPath = resolve(resolved, entry) + try { + const stats = statSync(fullPath) + this.entries.set(fullPath, trackFile(fullPath, stats)) + if (recursive && stats.isDirectory()) { + this.prime(fullPath, recursive) + } + } + catch {} + } + } + } +} + +/** Local layer directories to watch, excluding the root and layers in `node_modules` or outside it. */ +export function getLocalLayerDirs(layers: ReadonlyArray<{ cwd?: string, config?: { rootDir?: string } | null }>, cwd: string): string[] { + const root = resolve(cwd) + const dirs = new Set() + for (const layer of layers) { + const dir = layer.cwd || layer.config?.rootDir + const resolved = dir && resolve(dir) + if (resolved && resolved !== root && resolved.startsWith(`${root}/`) && !resolved.includes('/node_modules/')) { + dirs.add(resolved) + } + } + return [...dirs] +} + +export function createConfigWatcher(cwd: string, dotenvFileName: string | string[] = '.env', onRestart: (file: string) => void, onReload: (file: string) => void, layerDirs: string[] = []) { + const dotenvFileNames = new Set(Array.isArray(dotenvFileName) ? dotenvFileName : [dotenvFileName]) + + // Only the root restarts on dotenv changes. + const closers = [ + watchConfigDir(cwd, onReload, (file, path) => dotenvFileNames.has(file) && onRestart(path)), + ...layerDirs.map(dir => watchConfigDir(dir, onReload)), + ] + + return () => { + for (const close of closers) { + close() + } + } +} + +/** Collapse the events of a single save into one call per file, so a half-written file is never evaluated. */ +export function perFile(handler: (file: string) => void, delay = 30): { listener: (event: unknown, file: string | null) => void, cancel: () => void } { + const timers = new Map() + return { + listener: (_event, file) => { + if (!file) { + return + } + clearTimeout(timers.get(file)) + const timer = setTimeout(() => { + timers.delete(file) + handler(file) + }, delay) + timer.unref?.() + timers.set(file, timer) + }, + cancel: () => { + for (const timer of timers.values()) { + clearTimeout(timer) + } + timers.clear() + }, + } +} + +function watchConfigDir(dir: string, onReload: (path: string) => void, onFile?: (file: string, path: string) => void) { + const fileWatcher = new FileChangeTracker() + fileWatcher.prime(dir) + const watcher = watch(dir) + let configDirWatcher = existsSync(join(dir, '.config')) ? createConfigDirWatcher(dir, onReload) : undefined + + const { listener, cancel } = perFile((file) => { + if (!fileWatcher.shouldEmitChange(resolve(dir, file))) { + return + } + + onFile?.(file, resolve(dir, file)) + + if (RESTART_RE.test(file)) { + onReload(resolve(dir, file)) + } + + if (file === '.config') { + configDirWatcher ||= createConfigDirWatcher(dir, onReload) + } + }) + watcher.on('change', listener) + + return () => { + cancel() + watcher.close() + configDirWatcher?.() + } +} + +function createConfigDirWatcher(cwd: string, onReload: (path: string) => void) { + const configDir = join(cwd, '.config') + const fileWatcher = new FileChangeTracker() + + fileWatcher.prime(configDir) + const configDirWatcher = watch(configDir) + const { listener, cancel } = perFile((file) => { + if (!fileWatcher.shouldEmitChange(resolve(configDir, file))) { + return + } + + if (RESTART_RE.test(file)) { + onReload(resolve(configDir, file)) + } + }) + configDirWatcher.on('change', listener) + + return () => { + cancel() + configDirWatcher.close() + } +} diff --git a/packages/nuxt-cli/src/dev/error-channel.ts b/packages/nuxt-cli/src/dev/error-channel.ts index b9ccce9a3..f953bb817 100644 --- a/packages/nuxt-cli/src/dev/error-channel.ts +++ b/packages/nuxt-cli/src/dev/error-channel.ts @@ -11,6 +11,7 @@ import { BroadcastChannel } from 'node:worker_threads' import { isAbsolute, join, relative } from 'pathe' import { debug } from '../utils/logger' +import { isLoopbackAddress } from './host-check' import { DEV_INTERNAL_PREFIX } from './progress' /** Base path of the live error channel, before `nuxt.config` is known. */ @@ -109,19 +110,14 @@ export function isErrorChannelRequest(path: string, base: string): boolean { return path === base || path.startsWith(`${base}/`) } -export interface HandleErrorChannelOptions { - /** - * Whether the caller may see reports raised for other requests and use - * privileged actions. Untrusted callers are served the channel scoped to - * their own request. Default `true`. - */ - trusted?: boolean +/** Whether `req` came from this machine, by socket address since headers are forgeable. */ +export function isLocalPeer(req: IncomingMessage): boolean { + return isLoopbackAddress(req.socket?.remoteAddress) } -/** Answer a request under the mounted channel path. */ -export async function handleErrorChannelRequest(req: IncomingMessage, res: ServerResponse, options: ErrorChannelOptions = {}, caller: HandleErrorChannelOptions = {}): Promise { +export async function handleErrorChannelRequest(req: IncomingMessage, res: ServerResponse, options: ErrorChannelOptions = {}): Promise { const instance = await useErrorChannel(options) - if (await instance.handler(req, res, caller)) { + if (await instance.handler(req, res, { trusted: isLocalPeer(req) })) { return } res.statusCode = 404 @@ -169,12 +165,6 @@ export async function renderErrorPage(report: ErrorReport, options: { cwd?: stri }) } -/** Render `report` for the terminal, with its own marker and colours. */ -async function renderReportAnsi(report: ErrorReport, cwd?: string): Promise { - const { renderAnsi } = await import('my-bad') - return renderAnsi(report, { cwd }) -} - /** The CLI's own phase sequence, as the channel knows it apart from the app's. */ const CLI_PROGRESS_SOURCE = 'cli' @@ -295,6 +285,7 @@ export interface ReportContext { /** Everything the supervisor needs to present `report`, rendered for a terminal. */ export async function summariseReport(report: ErrorReport, context: ReportContext = {}, cwd: string = process.cwd()): Promise { + const { renderAnsi } = await import('my-bad') // A request that hit a compile error is described by the compile error. const named = findCompileReport(report) ?? report const frames = named.frames.some(frame => frame.file) ? named.frames : report.frames @@ -308,7 +299,7 @@ export async function summariseReport(report: ErrorReport, context: ReportContex location: frame?.file && formatLocation(frame.file, frame.line, frame.column, cwd), requestId: context.requestId, request: context.request, - ansi: await renderReportAnsi(report, cwd), + ansi: renderAnsi(report, { cwd }), } } diff --git a/packages/nuxt-cli/src/dev/index.ts b/packages/nuxt-cli/src/dev/index.ts index 872d15db4..15474c38e 100644 --- a/packages/nuxt-cli/src/dev/index.ts +++ b/packages/nuxt-cli/src/dev/index.ts @@ -24,7 +24,7 @@ import { blankLineBefore, writeDirect } from '../utils/stdout' import { startCpuProfile, stopCpuProfile } from '../utils/profile.ts' import { openInspector } from './inspect' import { closeErrorChannel, formatReportForTerminal } from './error-channel' -import { currentRequest, isServingRequest } from './serving-state' +import { currentRequest } from './serving-state' import { createPhaseReporter } from '../utils/phase-reporter' import { NuxtDevServer } from './utils' @@ -202,15 +202,16 @@ if (ipc.enabled && process.env.__NUXT_DEV_PIPED_TTY__) { consola.wrapAll() consola.addReporter({ log(logObj) { + const request = currentRequest() ipc.send({ type: 'nuxt:internal:dev:log', level: logObj.level, logType: logObj.type, tag: logObj.tag || undefined, message: formatWithOptions({ colors: false }, ...logObj.args), - origin: isServingRequest() ? 'runtime' : 'build', - request: currentRequest()?.label, - requestId: currentRequest()?.id, + origin: request ? 'runtime' : 'build', + request: request?.label, + requestId: request?.id, raw: true, }) }, @@ -504,7 +505,7 @@ export async function initialize(devContext: NuxtDevContext, ctx: InitializeOpti closePromise ??= (async () => { closeLogChannel?.() closeSpanChannel?.() - devServer.closeWatchers() + devServer.shutdown() try { await Promise.all([ devServer.listener.close(), @@ -513,7 +514,6 @@ export async function initialize(devContext: NuxtDevContext, ctx: InitializeOpti } finally { devServer.progress.close() - devServer.closeErrorBridge() await closeErrorChannel() devServer.releaseLock() } diff --git a/packages/nuxt-cli/src/dev/listen.ts b/packages/nuxt-cli/src/dev/listen.ts index 8f09cdd6b..9947dc520 100644 --- a/packages/nuxt-cli/src/dev/listen.ts +++ b/packages/nuxt-cli/src/dev/listen.ts @@ -547,19 +547,28 @@ export async function printQRCode(url: string, { showURL = false }: { showURL?: console.log(`\n${centerBlock(renderUnicodeCompact(url))}${caption}\n`) } -export async function copyURL(url: string): Promise { +/** Write `text` to the system clipboard, resolving whether it succeeded. */ +export async function writeClipboard(text: string): Promise { if (!hasDisplayServer()) { - logger.warn('No clipboard is available in this environment.') - return + return false } try { const { writeText } = await import('tinyclip') - await writeText(url) - logger.info('URL copied to clipboard.') + await writeText(text) + return true } catch (error) { - debug('Failed to copy URL to clipboard:', error) - logger.warn('Could not copy the URL to the clipboard.') + debug('Failed to write to the clipboard:', error) + return false + } +} + +export async function copyURL(url: string): Promise { + if (await writeClipboard(url)) { + logger.info('URL copied to clipboard.') + } + else { + logger.warn('No clipboard is available in this environment.') } } diff --git a/packages/nuxt-cli/src/dev/pool.ts b/packages/nuxt-cli/src/dev/pool.ts index 45304b56c..de1df23a8 100644 --- a/packages/nuxt-cli/src/dev/pool.ts +++ b/packages/nuxt-cli/src/dev/pool.ts @@ -14,27 +14,22 @@ interface ForkPoolOptions { poolSize?: number listenOverrides: DevListenOverrides inspect?: InspectOptions - /** - * Pipe fork stdio through this process instead of inheriting the terminal, - * so the interactive dev UI can keep its footer below all output. - */ + /** Pipe fork stdio through this process so the dev UI stays below all output. */ pipeOutput?: boolean } interface PooledFork { process: ChildProcess ready: Promise - state: 'warming' | 'ready' | 'active' | 'dead' + isReady: boolean + closing: boolean /** Whether this fork is the one serving the app, so its crash ends the session. */ serving: boolean } export interface ActiveFork { pid?: number - /** - * Resolves once the fork holds the listener, whether the app loaded or the - * error page is being served, and rejects if it dies before that. - */ + /** Resolves once the fork holds the listener (app or error page); rejects if it dies first. */ serving: Promise /** Promote the fork so that a later crash takes the dev session down. */ promote: () => void @@ -48,85 +43,68 @@ interface GetForkOptions { } export class ForkPool { - private pool: PooledFork[] = [] + private forks = new Set() + /** Forks that are starting or ready and not yet handed out. */ + private idle: PooledFork[] = [] private poolSize: number - private rawArgs: string[] - private listenOverrides: DevListenOverrides - private inspect?: InspectOptions - private pipeOutput: boolean private warming = false + private options: ForkPoolOptions constructor(options: ForkPoolOptions) { - this.rawArgs = options.rawArgs + this.options = options this.poolSize = options.poolSize ?? 1 - this.listenOverrides = options.listenOverrides - this.inspect = options.inspect - this.pipeOutput = options.pipeOutput ?? false - if (this.pipeOutput) { - // Piped forks read the terminal width from their environment snapshot, - // so resizes have to be forwarded for the fancy reporter's alignment. + if (options.pipeOutput) { + // Piped forks cannot see terminal resizes. process.stdout.on('resize', () => { - for (const fork of this.pool) { - if (fork.state !== 'dead' && fork.process.connected) { - // A fork can die between the check and the send, and this runs from - // a `resize` event where a throw would end the session. + for (const fork of this.forks) { + if (fork.process.connected) { + // The fork may have exited since the check. fork.process.send({ type: 'nuxt:internal:dev:resize', columns: process.stdout.columns || 80 } satisfies NuxtParentIPCMessage, () => {}) } } }) } - // last-resort for forks that outlive this process. nuxt closes forks gracefully - // on `SIGINT`/`SIGTERM`, so we skip them. - for (const signal of ['exit', 'SIGQUIT'] as const) { - process.once(signal, () => { - this.killAll(signal === 'exit' ? 0 : signal) - }) - } + // Last resort; signals close forks gracefully through the dev command. + process.once('exit', () => this.killAll('SIGTERM')) + process.once('SIGQUIT', () => this.killAll('SIGQUIT')) } startWarming(): void { - if (this.warming) { - return - } - this.warming = true - - for (let i = 0; i < this.poolSize; i++) { - this.warmFork() + if (!this.warming) { + this.warming = true + this.fill() } } async getFork(context: NuxtDevContext, options: GetForkOptions = {}): Promise { - // Once the app is served by a fork, file changes are no longer visible to - // this process, so a restart is the only signal left that more may follow. + // File changes are invisible here once a fork serves the app. this.warming = true - const pooledFork = this.pool.find(f => f.state === 'ready') - ?? this.pool.find(f => f.state === 'warming') - - if (!pooledFork) { - debug('No pre-warmed forks available, starting cold fork') - } - - const fork = pooledFork ?? this.createFork() - if (!pooledFork) { - this.pool.push(fork) - } + const fork = this.idle.find(f => f.isReady) ?? this.idle[0] ?? this.createFork() + this.idle = this.idle.filter(f => f !== fork) await fork.ready - fork.state = 'active' - const serving = this.trackServing(fork) - // Callers that never await `serving` (a caller that only wants the fork, or - // one that has already given up on it) must not turn its rejection into an - // unhandled rejection. + const serving = trackServing(fork.process) + // Not every caller awaits `serving`. serving.catch(() => {}) - if (options.onMessage) { - this.attachMessageHandler(fork.process, options.onMessage) + const onMessage = options.onMessage + if (onMessage) { + fork.process.on('message', (message: NuxtDevIPCMessage) => { + if (message.type !== 'nuxt:internal:dev:fork-ready') { + onMessage(message) + } + }) } - await this.sendContext(fork.process, context, options.listenOverrides) + fork.process.send({ + type: 'nuxt:internal:dev:context', + listenOverrides: { ...this.options.listenOverrides, ...options.listenOverrides }, + inspect: this.options.inspect, + context, + } satisfies NuxtParentIPCMessage) - this.warmFork() + this.fill() return { pid: fork.process.pid, @@ -138,74 +116,23 @@ export class ForkPool { } } - /** - * Resolves when the fork has bound its listener and is answering requests, so - * the caller can keep the outgoing server up until then. A load failure counts: - * the fork is serving an error page and owns the port either way. - */ - private trackServing(fork: PooledFork): Promise { - return new Promise((resolve, reject) => { - function settle(finish: () => void) { - fork.process.off('message', onMessage) - fork.process.off('close', onExit) - fork.process.off('error', onExit) - finish() - } - function onMessage(message: NuxtDevIPCMessage) { - if (message.type === 'nuxt:internal:dev:ready' || message.type === 'nuxt:internal:dev:loading:error') { - settle(resolve) - } - } - function onExit() { - settle(() => reject(new Error('Dev server fork exited before it was ready.'))) - } - fork.process.on('message', onMessage) - fork.process.once('close', onExit) - fork.process.once('error', onExit) - }) - } - - private attachMessageHandler(childProc: ChildProcess, onMessage: (message: NuxtDevIPCMessage) => void): void { - childProc.on('message', (message: NuxtDevIPCMessage) => { - // Don't forward fork-ready messages as those are internal - if (message.type !== 'nuxt:internal:dev:fork-ready') { - onMessage(message) - } - }) - } - - private warmFork(): void { - const idle = this.pool.filter(f => f.state === 'warming' || f.state === 'ready').length - if (idle >= this.poolSize) { - return + private fill(): void { + while (this.idle.length < this.poolSize) { + this.idle.push(this.createFork()) } - - const fork = this.createFork() - fork.ready.then(() => { - if (fork.state === 'warming') { - fork.state = 'ready' - } - }).catch(() => { - this.removeFork(fork) - }) - this.pool.push(fork) } - /** - * `ready` always settles, rejecting if the fork exits at any point, so every - * caller has to keep a rejection handler attached to it. - */ + /** `ready` rejects if the fork exits before it reports readiness. */ private createFork(): PooledFork { - const childProc = fork(globalThis.__nuxt_cli__.devEntry!, this.rawArgs, { - // The inspector is opened by the fork that actually serves the app (see - // `sendContext`), never via `execArgv`, so idle pooled forks don't race - // each other for the debug port. + const pipeOutput = this.options.pipeOutput + const childProc = fork(globalThis.__nuxt_cli__.devEntry!, this.options.rawArgs, { + // Only the serving fork opens the inspector, so idle forks do not contend for the port. execArgv: ['--enable-source-maps'], - stdio: this.pipeOutput ? ['ignore', 'pipe', 'pipe', 'ipc'] : undefined, + stdio: pipeOutput ? ['ignore', 'pipe', 'pipe', 'ipc'] : undefined, env: { ...process.env, __NUXT__FORK: 'true', - ...this.pipeOutput + ...pipeOutput ? { __NUXT_DEV_PIPED_TTY__: '1', __NUXT_DEV_COLUMNS__: String(process.stdout.columns || 80), @@ -215,154 +142,115 @@ export class ForkPool { }, }) - if (this.pipeOutput) { + if (pipeOutput) { childProc.stdout?.on('data', (chunk: Uint8Array) => writeDirectTo(process.stdout, chunk)) childProc.stderr?.on('data', (chunk: Uint8Array) => writeDirectTo(process.stderr, chunk)) } - let readyResolve: () => void - let readyReject: (err: Error) => void - const ready = new Promise((resolve, reject) => { - readyResolve = resolve - readyReject = reject - }) - const pooledFork: PooledFork = { process: childProc, - ready, - state: 'warming', + ready: new Promise((resolve, reject) => { + childProc.on('message', (message: NuxtDevIPCMessage) => { + if (message.type === 'nuxt:internal:dev:fork-ready') { + resolve() + } + }) + childProc.on('error', reject) + // A fork can exit without emitting `error`. + childProc.on('close', () => reject(new Error('Dev server fork exited before it finished starting.'))) + }), + isReady: false, + closing: false, serving: false, } + pooledFork.ready.then(() => { + pooledFork.isReady = true + }, () => {}) + this.forks.add(pooledFork) - childProc.on('message', (message: NuxtDevIPCMessage) => { - if (message.type === 'nuxt:internal:dev:fork-ready') { - readyResolve() - } - }) - - childProc.on('error', (err) => { - readyReject(err) - this.removeFork(pooledFork) - }) - + childProc.on('error', () => this.forget(pooledFork)) childProc.on('close', (errorCode) => { - // A fork can exit without ever emitting `error` (a throw while loading the - // entry, or a kill), which would leave `ready` pending forever. - readyReject(new Error('Dev server fork exited before it finished starting.')) if (pooledFork.serving && errorCode) { - // Ending the session on the crash of the process that holds the listener is - // silent otherwise, leaving no clue as to what stopped the dev server. logger.error(`The dev server process (PID ${childProc.pid}) exited with code ${errorCode}.`) process.exit(errorCode) } - this.removeFork(pooledFork) + this.forget(pooledFork) }) return pooledFork } - private async sendContext(childProc: ChildProcess, context: NuxtDevContext, listenOverrides?: Partial): Promise { - childProc.send({ - type: 'nuxt:internal:dev:context', - listenOverrides: { ...this.listenOverrides, ...listenOverrides }, - inspect: this.inspect, - context, - }) - } - - /** - * Ask a fork to shut down and wait for its `close` hooks to run, so nitro plugins - * and anything else the app opened get to tear down before the process goes away. - * A fork that takes too long is signalled instead. - */ + /** Ask a fork to run its `close` hooks and exit, signalling it if that fails or takes too long. */ private async closeFork(fork: PooledFork): Promise { - if (fork.state === 'dead' || fork.process.exitCode !== null || !fork.process.connected) { - return this.killFork(fork) - } - - fork.state = 'dead' + const alive = !fork.closing && fork.process.exitCode === null + fork.closing = true // A fork we are shutting down on purpose must not end the session. fork.serving = false - this.removeFork(fork) + this.forget(fork) + if (!alive) { + return + } const exited = waitForExit(fork.process) - fork.process.send({ type: 'nuxt:internal:dev:shutdown' } satisfies NuxtParentIPCMessage, (error) => { - if (error) { - fork.process.kill('SIGTERM') + if (fork.process.connected) { + fork.process.send({ type: 'nuxt:internal:dev:shutdown' } satisfies NuxtParentIPCMessage, (error) => { + if (error) { + fork.process.kill('SIGTERM') + } + }) + if (await settlesWithin(exited, DEV_SHUTDOWN_TIMEOUT_MS)) { + return } - }) - - if (await settlesWithin(exited, DEV_SHUTDOWN_TIMEOUT_MS)) { - return + debug(`Dev server fork ${fork.process.pid} did not shut down in time, terminating it`) } - debug(`Dev server fork ${fork.process.pid} did not shut down in time, terminating it`) fork.process.kill('SIGTERM') if (await settlesWithin(exited, FORCE_KILL_TIMEOUT_MS)) { return } - fork.process.kill('SIGKILL') await settlesWithin(exited, FORCE_KILL_TIMEOUT_MS) } - private killFork(fork: PooledFork, signal: NodeJS.Signals | number = 'SIGTERM'): Promise { - const wasAlive = fork.state !== 'dead' && !!fork.process && fork.process.exitCode === null - fork.state = 'dead' - // A fork we are shutting down on purpose must not end the session, however - // it exits on the way out. - fork.serving = false - if (fork.process) { - // signal 0 only probes for liveness, so map the `exit` case onto a real signal - fork.process.kill(signal === 0 ? 'SIGTERM' : signal) - } - this.removeFork(fork) - - if (!wasAlive) { - return Promise.resolve() - } - - // Resolve once the OS has reaped the process; the next fork may need to - // rebind ports (such as the inspector port) that it still holds. - return new Promise((resolve) => { - const timeout = setTimeout(resolve, 2000) - timeout.unref?.() - fork.process.once('exit', () => { - clearTimeout(timeout) - resolve() - }) - }) + private forget(fork: PooledFork): void { + this.forks.delete(fork) + this.idle = this.idle.filter(f => f !== fork) } - private removeFork(fork: PooledFork): void { - const index = this.pool.indexOf(fork) - if (index > -1) { - this.pool.splice(index, 1) + private killAll(signal: NodeJS.Signals): void { + for (const fork of this.forks) { + fork.serving = false + fork.process.kill(signal) } + this.forks.clear() + this.idle = [] } +} - private killAll(signal: NodeJS.Signals | number): void { - // `killFork` mutates the pool, so iterate over a snapshot - for (const fork of [...this.pool]) { - this.killFork(fork, signal) +/** Resolves when the fork is answering requests, including with an error page. */ +function trackServing(child: ChildProcess): Promise { + return new Promise((resolve, reject) => { + function settle(finish: () => void) { + child.off('message', onMessage) + child.off('close', onExit) + child.off('error', onExit) + finish() } - } - - getStats() { - return { - total: this.pool.length, - warming: this.pool.filter(f => f.state === 'warming').length, - ready: this.pool.filter(f => f.state === 'ready').length, - active: this.pool.filter(f => f.state === 'active').length, + function onMessage(message: NuxtDevIPCMessage) { + if (message.type === 'nuxt:internal:dev:ready' || message.type === 'nuxt:internal:dev:loading:error') { + settle(resolve) + } } - } + function onExit() { + settle(() => reject(new Error('Dev server fork exited before it was ready.'))) + } + child.on('message', onMessage) + child.once('close', onExit) + child.once('error', onExit) + }) } -/** - * Color settings for a fork whose stdio is piped back to this terminal. - * `isTTY` alone is not enough: `styleText` and most color libraries consult - * the color depth or `FORCE_COLOR`, which a pipe does not carry. - */ +/** Colour settings for a piped fork, which cannot detect colour support itself. */ function forcedColorEnv(): Record { if (process.env.NO_COLOR || process.env.FORCE_COLOR) { return {} diff --git a/packages/nuxt-cli/src/dev/preflight.ts b/packages/nuxt-cli/src/dev/preflight.ts index e52cba04e..81605b36b 100644 --- a/packages/nuxt-cli/src/dev/preflight.ts +++ b/packages/nuxt-cli/src/dev/preflight.ts @@ -10,14 +10,13 @@ import { restoreRawMode, withDirectStdout } from '../utils/console' import { ActionableError } from '../utils/errors' import { debug, logger } from '../utils/logger' import { CONFIG_EXTENSIONS } from '../utils/nuxt-config' +import { NUXT_PACKAGES } from '../utils/nuxt-packages' import { relativeTo } from '../utils/paths' import { tryResolveNuxt } from '../utils/resolve-nuxt' import { createSpinner } from '../utils/spinner' import { withUserAttention } from '../utils/startup-clock' import { isInteractive } from '../utils/stdout' -const NUXT_PACKAGES = ['nuxt', 'nuxt-nightly'] - /** * Extensions `c12` accepts for a config it parses rather than imports. Reporting * a project as missing is worse than looking in a few more places, so the whole diff --git a/packages/nuxt-cli/src/dev/progress.ts b/packages/nuxt-cli/src/dev/progress.ts index bb51bea02..b6318b46f 100644 --- a/packages/nuxt-cli/src/dev/progress.ts +++ b/packages/nuxt-cli/src/dev/progress.ts @@ -1,24 +1,19 @@ import type { IncomingMessage, ServerResponse } from 'node:http' -import type { PendingRender, PhaseTiming, ProgressSnapshot, ProgressStatus } from '../utils/progress-snapshot' +import type { PendingRender, Phase, ProgressSnapshot, ProgressStatus } from '../utils/progress-snapshot' -import { READY_MESSAGE } from '../utils/progress-snapshot' +import { PhaseTimeline, READY_MESSAGE } from '../utils/progress-snapshot' /** Path prefix reserved for the CLI's own dev-time endpoints. */ export const DEV_INTERNAL_PREFIX: string = '/__nuxt_dev__/' export const PROGRESS_PATH: string = `${DEV_INTERNAL_PREFIX}progress` const HEARTBEAT_INTERVAL = 15_000 -interface DevPhase { - id: string - message: string -} - /** * Startup phases, in the order they are reached. The index doubles as the * progress fraction shown to clients, so the list is deliberately coarse and * monotonic: a phase is never re-entered during a single load. */ -const DEV_PHASES: readonly DevPhase[] = [ +const DEV_PHASES: readonly Phase[] = [ { id: 'config', message: 'Loading Nuxt config' }, { id: 'modules', message: 'Setting up modules' }, { id: 'app', message: 'Preparing app' }, @@ -189,18 +184,13 @@ interface ActiveHook { * Tracks how far a `nuxt dev` load has got and fans that out to the terminal * reporter and to any loading pages connected over SSE. */ -export class DevProgress { +export class DevProgress extends PhaseTimeline { #clients = new Set() - #listeners = new Set<(snapshot: ProgressSnapshot) => void>() #heartbeat?: NodeJS.Timeout - #index = 0 #message = DEV_PHASES[0]!.message #status: ProgressStatus = 'loading' #error?: Error - #startedAt = Date.now() - #phaseStartedAt = Date.now() - #timings: PhaseTiming[] = [] #reload = false #baseMessage = DEV_PHASES[0]!.message #module?: ActiveHook @@ -216,52 +206,37 @@ export class DevProgress { #observed = new WeakSet() #ticker?: NodeJS.Timeout - get snapshot(): ProgressSnapshot { - const phase = DEV_PHASES[this.#index]! + constructor() { + super(DEV_PHASES) + } + + override get snapshot(): ProgressSnapshot { + const snapshot = super.snapshot return { + ...snapshot, status: this.#status, - phase: phase.id, message: this.#message, - index: this.#index, - total: DEV_PHASES.length - 1, - progress: this.#status === 'ready' - ? (this.#serving ? 1 : READY_PROGRESS) - : this.#index / (DEV_PHASES.length - 1), - elapsed: Date.now() - this.#startedAt, - phaseElapsed: Date.now() - this.#phaseStartedAt, + progress: this.#status === 'ready' ? (this.#serving ? 1 : READY_PROGRESS) : snapshot.progress, reload: this.#reload, serving: this.#serving, pending: this.#pending, - timings: this.#timings, error: this.#error && { name: this.#error.name, message: this.#error.message }, } } - get timings(): PhaseTiming[] { - return this.#timings - } - - onUpdate(listener: (snapshot: ProgressSnapshot) => void): () => void { - this.#listeners.add(listener) - return () => this.#listeners.delete(listener) - } - start(message?: string, reload = false): void { this.#hooks = [] - this.#clearModule() + this.#module = undefined this.#narrating = false if (this.#observing) { this.#startNarrating() } - this.#index = 0 + this.restart() this.#status = 'loading' this.#error = undefined - this.#timings = [] this.#reload = reload this.#serving = false this.#clearPending() - this.#startedAt = Date.now() - this.#phaseStartedAt = this.#startedAt this.#baseMessage = message || DEV_PHASES[0]!.message this.#message = this.#baseMessage this.#emit() @@ -377,23 +352,17 @@ export class DevProgress { } #advance(id: string, message: string | undefined, emit: boolean): void { - const index = DEV_PHASES.findIndex(phase => phase.id === id) - if (index === -1 || index < this.#index || this.#status === 'error') { + if (this.#status === 'error') { + return + } + const advanced = this.enter(id) + if (advanced === undefined) { return } - const advanced = index > this.#index if (advanced) { - const previous = DEV_PHASES[this.#index]! - this.#timings.push({ - phase: previous.id, - message: previous.message, - duration: Date.now() - this.#phaseStartedAt, - }) - this.#phaseStartedAt = Date.now() - this.#index = index - this.#clearModule() - } - const next = message || DEV_PHASES[index]!.message + this.#module = undefined + } + const next = message || DEV_PHASES[this.index]!.message if (!advanced && next === this.#baseMessage) { return } @@ -405,10 +374,6 @@ export class DevProgress { } } - #clearModule(): void { - this.#module = undefined - } - /** * Replace the phase label with whatever the load is actually waiting on, or * put the phase label back once it is waiting on nothing in particular. The @@ -422,7 +387,7 @@ export class DevProgress { const now = Date.now() // A phase this long in has stopped being explained by its own label, so // whatever is running is named as soon as it is seen. - const impatient = now - this.#phaseStartedAt >= PHASE_PATIENCE + const impatient = now - this.phaseStartedAt >= PHASE_PATIENCE let text: string | undefined // A module name reads better than the hook it was installed from, so an @@ -438,7 +403,7 @@ export class DevProgress { // A hook that was already running when the phase began is an ancestor of // the current work: the phase label describes it better, and naming it // would replace each new phase label with the hook awaiting it. - if (hook.at < this.#phaseStartedAt) { + if (hook.at < this.phaseStartedAt) { break } if (now - hook.at < (impatient ? IMPATIENT_HOOK_DWELL : HOOK_DWELL)) { @@ -456,7 +421,7 @@ export class DevProgress { // No current nuxt says which module it is installing, so a phase that has // named nothing counts the ones that have finished instead: the number // moving is the only sign that the ones left are being worked through. - if (!text && impatient && this.#index === MODULES_INDEX) { + if (!text && impatient && this.index === MODULES_INDEX) { const installed = this.#installedModules?.() ?? 0 if (installed) { text = `${this.#baseMessage} \u00B7 ${installed} installed` @@ -486,7 +451,7 @@ export class DevProgress { clearInterval(this.#ticker) this.#ticker = undefined this.#hooks = [] - this.#clearModule() + this.#module = undefined } /** @@ -557,7 +522,7 @@ export class DevProgress { // A module that finishes before it earned a mention must not be named // afterwards: whatever the load is waiting on by then is not this module. else if (name === MODULE_FINISHED && moduleName(args?.[0]) === this.#module?.name) { - this.#clearModule() + this.#module = undefined } else if (name === NITRO_HOOK) { this.#observe((args?.[0] as { hooks?: HookableLike } | undefined)?.hooks, NITRO_PREFIX) @@ -642,10 +607,7 @@ export class DevProgress { } #emit(): void { - const snapshot = this.snapshot - for (const listener of this.#listeners) { - listener(snapshot) - } + const snapshot = this.emit() for (const client of this.#clients) { this.#send(client, snapshot) } diff --git a/packages/nuxt-cli/src/dev/request-kind.ts b/packages/nuxt-cli/src/dev/request-kind.ts new file mode 100644 index 000000000..13692acb5 --- /dev/null +++ b/packages/nuxt-cli/src/dev/request-kind.ts @@ -0,0 +1,20 @@ +import type { IncomingMessage } from 'node:http' + +/** Bundler module-graph URLs (`/@id/`, `/@fs/`, `virtual:`, SFC block queries, Nuxt VFS). */ +const BUNDLER_URL_RE = /^\/(?:@|__|_nuxt\/|_vfs(?:\.json)?(?:$|[/?]))|\/node_modules\/|virtual:|[?&](?:vue&type=|import(?:&|=|$)|direct(?:&|=|$)|html-proxy|raw(?:&|=|$)|worker(?:&|=|$))/ + +/** Whether a request is a bundler subresource rather than app traffic, by `sec-fetch-dest` or URL shape. */ +export function isBundlerRequest(url: string, fetchDest?: string): boolean { + return fetchDest === 'script' || fetchDest === 'style' || BUNDLER_URL_RE.test(url) +} + +/** Whether a request is for a rendered page. */ +export function isDocumentRequest(req: IncomingMessage): boolean { + if ((req.method || 'GET') !== 'GET') { + return false + } + if (!String(req.headers.accept || '').includes('text/html')) { + return false + } + return !isBundlerRequest(req.url || '/', String(req.headers['sec-fetch-dest'] || '') || undefined) +} diff --git a/packages/nuxt-cli/src/dev/runtime-plugins.ts b/packages/nuxt-cli/src/dev/runtime-plugins.ts new file mode 100644 index 000000000..06b2dcbcd --- /dev/null +++ b/packages/nuxt-cli/src/dev/runtime-plugins.ts @@ -0,0 +1,58 @@ +import type { NuxtConfig } from '@nuxt/schema' + +import { readFileSync } from 'node:fs' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +import { resolveModulePath } from 'exsolve' +import { join } from 'pathe' + +import { debug } from '../utils/logger' +import { INSPECT_ENV } from './inspect' + +/** Nitro plugin attributing app logs to their request. Resolved via package exports, as the caller may be in any chunk. */ +function registerRequestContextPlugin(nitro: NitroConfigForHook, cwd: string): void { + try { + const source = fileURLToPath(import.meta.resolve('@nuxt/cli/runtime/dev-request-context')) + const id = join(nitro.buildDir || join(cwd, '.nuxt'), 'dev-request-context.mjs') + // A bare specifier would not resolve from the build dir. + const consola = resolveConsola(cwd) + nitro.virtual ||= {} + nitro.virtual[id] = () => readFileSync(source, 'utf8').replace('\'consola\'', JSON.stringify(consola)) + nitro.plugins ||= [] + nitro.plugins.push(id) + } + catch (error) { + debug('Could not resolve the request context plugin; app logs will not be attributed:', error) + } +} + +function registerRuntimePlugin(nitro: NitroConfigForHook, name: 'dev-close-sockets' | 'dev-inspector'): void { + try { + nitro.plugins ||= [] + nitro.plugins.push(fileURLToPath(import.meta.resolve(`@nuxt/cli/runtime/${name}`))) + } + catch (error) { + debug(`Could not resolve the ${name} plugin:`, error) + } +} + +/** The `consola` instance `@nuxt/nitro-server` wraps `console` with, rather than the CLI.s own. */ +function resolveConsola(cwd: string): string { + const nuxt = resolveModulePath('nuxt', { from: cwd, try: true }) + const from = [nuxt, cwd].filter(Boolean) as string[] + return resolveModulePath('consola', { from, try: true }) ?? fileURLToPath(import.meta.resolve('consola')) +} + +type NitroConfigForHook = Parameters['nitro:config']>>[0] + +/** Add the CLI's runtime plugins to the Nitro dev server's config. */ +export function registerDevPlugins(nitro: NitroConfigForHook, cwd: string, attributeRequests?: boolean): void { + registerRuntimePlugin(nitro, 'dev-close-sockets') + if (process.env[INSPECT_ENV]) { + registerRuntimePlugin(nitro, 'dev-inspector') + } + if (attributeRequests) { + registerRequestContextPlugin(nitro, cwd) + } +} diff --git a/packages/nuxt-cli/src/dev/serving-state.ts b/packages/nuxt-cli/src/dev/serving-state.ts index 2dda145e5..bfb49f2aa 100644 --- a/packages/nuxt-cli/src/dev/serving-state.ts +++ b/packages/nuxt-cli/src/dev/serving-state.ts @@ -1,3 +1,5 @@ +import type { IncomingMessage } from 'node:http' + import { AsyncLocalStorage } from 'node:async_hooks' import { randomUUID } from 'node:crypto' @@ -9,10 +11,10 @@ export interface InflightRequest { const storage = new AsyncLocalStorage() /** Carries the request across the boundary the async context cannot cross. */ -export const REQUEST_HEADER = 'x-nuxt-dev-request-id' +const REQUEST_HEADER = 'x-nuxt-dev-request-id' -/** Carries the request's `METHOD /path` alongside {@link REQUEST_HEADER}. */ -export const REQUEST_LABEL_HEADER = 'x-nuxt-dev-request-label' +/** Carries the request's `METHOD /path`, URI-encoded, alongside {@link REQUEST_HEADER}. */ +const REQUEST_LABEL_HEADER = 'x-nuxt-dev-request-label' /** * Identify a request, so logs and reports can be attributed to it. @@ -23,32 +25,36 @@ export function createRequest(label: string): InflightRequest { return { id: randomUUID(), label } } +/** Stamp `req` with a fresh identity, overwriting any client-supplied copy in `headers` and `rawHeaders`. */ +export function attachRequest(req: IncomingMessage): InflightRequest { + if (req.headers[REQUEST_HEADER] !== undefined || req.headers[REQUEST_LABEL_HEADER] !== undefined) { + for (let i = req.rawHeaders.length - 2; i >= 0; i -= 2) { + const name = req.rawHeaders[i]?.toLowerCase() + if (name === REQUEST_HEADER || name === REQUEST_LABEL_HEADER) { + req.rawHeaders.splice(i, 2) + } + } + } + const request = createRequest(`${req.method || 'GET'} ${req.url || '/'}`) + const label = encodeURIComponent(request.label) + req.headers[REQUEST_HEADER] = request.id + req.headers[REQUEST_LABEL_HEADER] = label + req.rawHeaders.push(REQUEST_HEADER, request.id, REQUEST_LABEL_HEADER, label) + return request +} + /** * Serve `run` inside a context that identifies the request, so any log it causes * can be attributed to it. * - * The dev server, the build tooling and the app all log through the same consola - * on the same thread, so the call site says nothing about who is logging. - * * The context reaches everything on the handler's own async chain, timers and * microtasks included, but not the app: Nuxt's dev pipeline re-dispatches * through the Vite module runner, which is a message boundary rather than an * async one. The app's own logs are attributed by * `runtime/dev-request-context.mjs` instead. */ -export function runWithRequest(request: InflightRequest | string, run: (request: InflightRequest) => T): T { - const inflight = typeof request === 'string' ? createRequest(request) : request - return storage.run(inflight, () => run(inflight)) -} - -/** The value of {@link REQUEST_LABEL_HEADER} for `request`, encoded for a header. */ -export function encodeRequestLabel(request: InflightRequest): string { - return encodeURIComponent(request.label) -} - -/** Whether this code is running to serve a request, rather than to build. */ -export function isServingRequest(): boolean { - return storage.getStore() !== undefined +export function runWithRequest(request: InflightRequest, run: () => T): T { + return storage.run(request, run) } /** diff --git a/packages/nuxt-cli/src/dev/shutdown.ts b/packages/nuxt-cli/src/dev/shutdown.ts index 24361dd7b..66a7c66ee 100644 --- a/packages/nuxt-cli/src/dev/shutdown.ts +++ b/packages/nuxt-cli/src/dev/shutdown.ts @@ -1,3 +1,8 @@ +import process from 'node:process' + +import { summariseActiveResources } from '../utils/hang' +import { logger } from '../utils/logger' + /** * How long a dev server process is given to run its `close` hooks. Nitro plugins * use these to close database connections and the like, so anything that signals @@ -9,7 +14,7 @@ export const DEV_SHUTDOWN_TIMEOUT_MS = 10_000 * How long the process supervising a dev server waits for it to go away. Longer * than the budget above, since the fork spends that budget before exiting. */ -export const SUPERVISOR_SHUTDOWN_TIMEOUT_MS = 15_000 +const SUPERVISOR_SHUTDOWN_TIMEOUT_MS = 15_000 /** How long a signalled process has to disappear before we give up on it. */ export const FORCE_KILL_TIMEOUT_MS = 2000 @@ -31,3 +36,48 @@ export function isShutdownAdopted(): boolean { export function adoptShutdown(): void { adopted = true } + +const SHUTDOWN_NOTICE_MS = 1500 + +/** + * Shut the dev server down on `SIGINT`/`SIGTERM`, which any listener (dev UI, + * profiler) otherwise stops from exiting. A second Ctrl-C skips the graceful wait. + */ +export function handleShutdownSignals(close: () => Promise): void { + adoptShutdown() + let closing = false + for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.on(signal, () => { + if (closing) { + process.exit(130) + } + closing = true + + const deadline = setTimeout(() => { + const summary = summariseActiveResources() + logger.warn(`The dev server did not shut down within ${SUPERVISOR_SHUTDOWN_TIMEOUT_MS / 1000}s${summary ? `: ${summary}` : ''}. Exiting anyway.`) + process.exit() + }, SUPERVISOR_SHUTDOWN_TIMEOUT_MS) + + void import('../utils/spinner').then(({ withSpinner }) => withSpinner('Cleaning up', async (indicator) => { + const notice = setTimeout(() => { + indicator.update('Cleaning up... press Ctrl-C again to exit immediately') + }, SHUTDOWN_NOTICE_MS) + notice.unref?.() + try { + await close() + } + catch (error) { + console.error(error) + process.exitCode = 1 + } + finally { + clearTimeout(notice) + clearTimeout(deadline) + } + }, { done: 'Stopped the dev server' })).finally(() => { + process.exit() + }) + }) + } +} diff --git a/packages/nuxt-cli/src/dev/takeover.ts b/packages/nuxt-cli/src/dev/takeover.ts index 99ffc444e..86521725d 100644 --- a/packages/nuxt-cli/src/dev/takeover.ts +++ b/packages/nuxt-cli/src/dev/takeover.ts @@ -152,19 +152,25 @@ async function performTakeover(buildDir: string, existing: LockInfo, timeouts: T ? [existing.pid, existing.parentPid] : [existing.pid] - // On Windows `SIGTERM` is not delivered as a signal and terminates the process - // outright, so the graceful window below simply passes quickly there. - signalAll(pids, 'SIGTERM') - if (await waitForRelease(pids, port, existing.hostname, timeouts.graceful ?? DEV_SHUTDOWN_TIMEOUT_MS)) { - progress.stop(`Stopped the dev server on port ${port} (PID ${existing.pid})`) - return { action: 'taken', port, pid: existing.pid } - } - - progress.update(`Waiting for the dev server on port ${port} to exit`) - signalAll(pids, 'SIGKILL') - if (await waitForRelease(pids, port, existing.hostname, timeouts.force ?? TAKEOVER_KILL_TIMEOUT_MS)) { - progress.stop(`Stopped the dev server on port ${port} (PID ${existing.pid})`) - return { action: 'taken', port, pid: existing.pid } + // On Windows `SIGTERM` terminates outright. + const phases = [ + ['SIGTERM', timeouts.graceful ?? DEV_SHUTDOWN_TIMEOUT_MS], + ['SIGKILL', timeouts.force ?? TAKEOVER_KILL_TIMEOUT_MS], + ] as const + for (const [signal, timeout] of phases) { + if (signal === 'SIGKILL') { + progress.update(`Waiting for the dev server on port ${port} to exit`) + } + for (const pid of pids) { + try { + process.kill(pid, signal) + } + catch {} + } + if (await waitForRelease(pids, port, existing.hostname, timeout)) { + progress.stop(`Stopped the dev server on port ${port} (PID ${existing.pid})`) + return { action: 'taken', port, pid: existing.pid } + } } progress.fail(`Could not stop the dev server on port ${port}`) @@ -256,15 +262,6 @@ async function promptForTakeover(existing: LockInfo, defaultChoice: TakeoverChoi return choice } -function signalAll(pids: number[], signal: NodeJS.Signals): void { - for (const pid of pids) { - try { - process.kill(pid, signal) - } - catch {} - } -} - async function isPortFree(port: number, hostname?: string): Promise { return await checkPort(port, hostname || 'localhost') !== false } diff --git a/packages/nuxt-cli/src/dev/tui/events.ts b/packages/nuxt-cli/src/dev/tui/events.ts index 1b3336eb3..171efa7fd 100644 --- a/packages/nuxt-cli/src/dev/tui/events.ts +++ b/packages/nuxt-cli/src/dev/tui/events.ts @@ -1,4 +1,4 @@ -import { stripAnsi } from './width' +import { stripAnsi } from '../../utils/formatting' export type DevLogSource = 'cli' | 'build' | 'runtime' diff --git a/packages/nuxt-cli/src/dev/tui/first-frame.ts b/packages/nuxt-cli/src/dev/tui/first-frame.ts index 2a2260fbd..efd5614d0 100644 --- a/packages/nuxt-cli/src/dev/tui/first-frame.ts +++ b/packages/nuxt-cli/src/dev/tui/first-frame.ts @@ -3,7 +3,7 @@ import type { DevUISupportOptions } from './support' import process from 'node:process' -import { getPkgVersion } from '../../utils/pkg' +import { getNuxtPkgVersion } from '../../utils/pkg' import { resolveBackground } from '../../utils/terminal-theme' import { DEFAULT_HINTS, renderPanel } from './panel' import { resolveDevUISupport, supportsUnicode } from './support' @@ -20,12 +20,16 @@ export interface PanelStartOptions extends DevUISupportOptions { startTime?: number } +export function resolveNuxtVersion(options: { version?: string, cwd?: string }): string | undefined { + const cwd = options.cwd || process.cwd() + return options.version || getNuxtPkgVersion(cwd) || undefined +} + /** The panel as it looks before anything has been loaded. */ export function createPanelState(options: PanelStartOptions = {}): PanelState { - const cwd = options.cwd || process.cwd() return { status: 'starting', - version: options.version || getPkgVersion(cwd, 'nuxt') || getPkgVersion(cwd, 'nuxt-nightly') || undefined, + version: resolveNuxtVersion(options), warnings: 0, errors: 0, ascii: !supportsUnicode(), diff --git a/packages/nuxt-cli/src/dev/tui/index.ts b/packages/nuxt-cli/src/dev/tui/index.ts index 28601069f..bea9c8ad1 100644 --- a/packages/nuxt-cli/src/dev/tui/index.ts +++ b/packages/nuxt-cli/src/dev/tui/index.ts @@ -15,13 +15,13 @@ import process from 'node:process' import { styleText } from 'node:util' import { resolveStackVersions } from '../../utils/banner' import { withDirectStdout } from '../../utils/console' +import { formatDuration, terminalLink } from '../../utils/formatting' import { startupElapsedMs } from '../../utils/startup-clock' import { registerTerminalHost } from '../../utils/terminal-host' -import { terminalLink } from '../../utils/terminal-link' import { MUTED, paint } from '../../utils/terminal-theme' import { checkForUpdate, isUpdateCheckEnabled, releaseNotesUrl } from '../../utils/update-check' -import { openBrowser } from '../listen' +import { openBrowser, writeClipboard } from '../listen' import { setupShortcuts } from '../shortcuts' import { isShutdownAdopted } from '../shutdown' import { NOOP_CONTROLLER } from './controller' @@ -177,10 +177,6 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) // the animation for whatever it has just put there. session.onProgressChange(refresh) - function clearActivity(): void { - update({ active: false }) - } - function advanceFrame(): void { const working = state.status !== 'ready' && state.status !== 'error' update({ @@ -273,6 +269,10 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) noticeTimer.unref?.() } + function clearActivity(): void { + update({ active: false }) + } + const repaintTicker = createTickerRepainter(refresh) /** Drop the session's history: log events, the request table and the counts. */ @@ -746,12 +746,10 @@ async function copyURL(context: ShortcutContext, notify: (text: string, tone: 'i notify('no server to copy the url of yet', 'warn') return } - try { - const { writeText } = await import('tinyclip') - await writeText(url) + if (await writeClipboard(url)) { notify(`copied ${url} to the clipboard`, 'success') } - catch { + else { notify('no clipboard available', 'warn') } } @@ -805,9 +803,9 @@ function describeSession( { heading: 'session', entries: [ - ['uptime', formatUptime(Date.now() - sessionStart)], + ['uptime', formatDuration(Date.now() - sessionStart)], ['requests', String(requests.total)], - ['median', requests.total ? `${requests.medianDuration()}ms` : undefined], + ['median', requests.total ? formatDuration(requests.medianDuration()) : undefined], ['directory', cwd], ], }, @@ -857,10 +855,3 @@ async function resolveUpdate(current?: string): Promise { return undefined } } - -function formatUptime(elapsed: number): string { - const seconds = Math.floor(elapsed / 1000) - const hours = Math.floor(seconds / 3600) - const minutes = Math.floor((seconds % 3600) / 60) - return [...hours ? [`${hours}h`] : [], ...hours || minutes ? [`${minutes}m`] : [], `${seconds % 60}s`].join(' ') -} diff --git a/packages/nuxt-cli/src/dev/tui/info-overlay.ts b/packages/nuxt-cli/src/dev/tui/info-overlay.ts index ae306b8ee..2c2ac8bae 100644 --- a/packages/nuxt-cli/src/dev/tui/info-overlay.ts +++ b/packages/nuxt-cli/src/dev/tui/info-overlay.ts @@ -2,10 +2,10 @@ import type { OverlayEntry } from './screen' import { styleText } from 'node:util' -import { MUTED } from '../../utils/terminal-theme' +import { stripAnsi, visibleWidth } from '../../utils/formatting' +import { MUTED } from '../../utils/terminal-theme' import { formatHints, ScreenOverlay } from './screen' -import { stripAnsi, visibleWidth } from './width' export interface InfoSection { heading: string diff --git a/packages/nuxt-cli/src/dev/tui/overlay.ts b/packages/nuxt-cli/src/dev/tui/overlay.ts index 415324227..698cd7d55 100644 --- a/packages/nuxt-cli/src/dev/tui/overlay.ts +++ b/packages/nuxt-cli/src/dev/tui/overlay.ts @@ -5,10 +5,10 @@ import type { OverlayEntry } from './screen' import { styleText } from 'node:util' -import { MUTED, paint } from '../../utils/terminal-theme' +import { truncate } from '../../utils/formatting' +import { MUTED, paint } from '../../utils/terminal-theme' import { formatHints, ScreenOverlay } from './screen' -import { truncate } from './width' type LevelFilter = 'all' | 'warn' | 'error' diff --git a/packages/nuxt-cli/src/dev/tui/panel.ts b/packages/nuxt-cli/src/dev/tui/panel.ts index fdf56c315..a1851bf24 100644 --- a/packages/nuxt-cli/src/dev/tui/panel.ts +++ b/packages/nuxt-cli/src/dev/tui/panel.ts @@ -2,10 +2,9 @@ import type { TerminalBackground } from '../../utils/terminal-theme' import { styleText } from 'node:util' -import { terminalLink } from '../../utils/terminal-link' +import { decapitalise, formatDuration, stripAnsi, terminalLink, truncate, visibleWidth } from '../../utils/formatting' import { MUTED, paint } from '../../utils/terminal-theme' import { renderLogo } from './logo' -import { stripAnsi, truncate, visibleWidth } from './width' export type DevStatus = 'starting' | 'building' | 'warming' | 'ready' | 'restarting' | 'error' @@ -251,7 +250,7 @@ function renderProgress(state: PanelState, columns: number): string { const filled = Math.round(fraction * PROGRESS_BAR_WIDTH) const glyph = state.ascii ? '=' : '\u2501' const bar = styleText('green', glyph.repeat(filled)) + styleText(MUTED, glyph.repeat(PROGRESS_BAR_WIDTH - filled)) - const elapsed = state.elapsedMs === undefined ? '' : `${SEPARATOR}${styleText(MUTED, `${(state.elapsedMs / 1000).toFixed(1)}s`)}` + const elapsed = state.elapsedMs === undefined ? '' : `${SEPARATOR}${styleText(MUTED, formatDuration(state.elapsedMs))}` return truncate(` ${bar} ${styleText(MUTED, `${Math.round(fraction * 100)}%`)}${elapsed}`, columns) } @@ -269,7 +268,7 @@ function renderSummary(state: PanelState, columns: number): string[] { if (state.task) { const glyph = state.ascii ? TASK_FRAMES_ASCII : TASK_FRAMES const mark = glyph[(state.frame ?? 0) % glyph.length]! - const elapsed = styleText(MUTED, `${((Date.now() - state.task.startedAt) / 1000).toFixed(1)}s`) + const elapsed = styleText(MUTED, formatDuration(Date.now() - state.task.startedAt)) return [truncate(` ${styleText('cyan', mark)} ${decapitalise(state.task.label)}${SEPARATOR}${elapsed}`, columns)] } @@ -301,10 +300,6 @@ function renderSummary(state: PanelState, columns: number): string[] { * sentences ("Restarting Nuxt...", the progress phases). Only a leading * capital followed by lowercase is folded, so acronyms survive. */ -function decapitalise(text: string): string { - return /^[A-Z][a-z]/.test(text) ? text[0]!.toLowerCase() + text.slice(1) : text -} - const NOTICE_TONES = { info: { mark: { unicode: '\u2139', ascii: 'i' }, paint: (glyph: string) => styleText('cyan', glyph) }, warn: { mark: { unicode: '\u26A0', ascii: '!' }, paint: (glyph: string, background?: TerminalBackground) => paint('warning', glyph, background) }, @@ -357,7 +352,7 @@ function renderRenderElapsed(state: PanelState): string { if (state.renderingMs === undefined || state.renderingMs < RENDER_ELAPSED_THRESHOLD) { return '' } - return ` \u00B7 ${(state.renderingMs / 1000).toFixed(1)}s` + return ` \u00B7 ${formatDuration(state.renderingMs)}` } /** @@ -369,7 +364,7 @@ function renderPhaseElapsed(state: PanelState): string { if (!state.note || state.phaseElapsedMs === undefined || state.phaseElapsedMs < PHASE_ELAPSED_THRESHOLD) { return '' } - return ` \u00B7 ${(state.phaseElapsedMs / 1000).toFixed(1)}s` + return ` \u00B7 ${formatDuration(state.phaseElapsedMs)}` } /** @@ -383,7 +378,7 @@ function renderTicker(state: PanelState, room: number): string { } const head = `${SEPARATOR}${styleText('bold', request.method)} ` - const tail = `${SEPARATOR}${paintStatus(request.status, undefined, state.background)}${SEPARATOR}${styleText(MUTED, `${request.duration}ms`)}` + const tail = `${SEPARATOR}${paintStatus(request.status, undefined, state.background)}${SEPARATOR}${styleText(MUTED, formatDuration(request.duration))}` const available = room - visibleWidth(head) - visibleWidth(tail) if (available < 8) { return '' @@ -431,10 +426,6 @@ function plural(count: number, word: string): string { return count === 1 ? word : `${word}s` } -function formatDuration(ms: number): string { - return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(2)}s` -} - /** The panel as plain text, for asserting layout without styling. */ export function renderPanelText(state: PanelState, columns: number, rows?: number): string { return renderPanel(state, columns, rows).map(line => stripAnsi(line).trimEnd()).join('\n') diff --git a/packages/nuxt-cli/src/dev/tui/request-overlay.ts b/packages/nuxt-cli/src/dev/tui/request-overlay.ts index 82d8e02c1..5638e5054 100644 --- a/packages/nuxt-cli/src/dev/tui/request-overlay.ts +++ b/packages/nuxt-cli/src/dev/tui/request-overlay.ts @@ -11,12 +11,12 @@ import { styleText } from 'node:util' import { link } from 'clickable-path' -import { MUTED, paint } from '../../utils/terminal-theme' +import { formatDuration, truncate } from '../../utils/formatting' +import { MUTED, paint } from '../../utils/terminal-theme' import { formatEvent, formatTime } from './overlay' import { paintStatus } from './panel' import { formatHints, ScreenOverlay } from './screen' -import { truncate } from './width' type TrafficFilter = 'all' | 'errors' | 'slow' @@ -104,12 +104,12 @@ export class RequestOverlay extends ScreenOverlay { if (this.#detail) { const request = this.#detail const status = paintStatus(request.status) - return ` ${styleText('bold', 'trace')} · ${styleText('bold', `${request.method} ${request.url}`)} · ${status} · ${request.duration}ms${this.renderSearch()}` + return ` ${styleText('bold', 'trace')} · ${styleText('bold', `${request.method} ${request.url}`)} · ${status} · ${formatDuration(request.duration)}${this.renderSearch()}` } const shown = this.#matching().length const label = this.#filter === 'all' ? 'all' : this.#filter const median = this.#requests.medianDuration() - const summary = styleText(MUTED, `${this.#requests.total} total · median ${median}ms`) + const summary = styleText(MUTED, `${this.#requests.total} total · median ${formatDuration(median)}`) const hiddenInternal = this.#showInternal ? 0 : this.#requests.recent(SCAN_LIMIT, request => !!request.internal).length const bundler = hiddenInternal ? ` · ${styleText(MUTED, `${hiddenInternal} bundler hidden`)}` : '' return ` ${styleText('bold', 'traffic')} · ${label} (${shown}) · ${summary}${bundler}${this.renderPosition()}${this.renderSearch()}` @@ -126,7 +126,7 @@ export class RequestOverlay extends ScreenOverlay { const errors = this.#errorCounts() return matching.map(request => ({ lines: [this.#format(request, columns, request.id === undefined ? 0 : errors.get(request.id) ?? 0)], - copy: `${request.method} ${request.url} ${request.status} ${request.duration}ms`, + copy: describeRequest(request), })) } @@ -160,7 +160,7 @@ export class RequestOverlay extends ScreenOverlay { const summary: OverlayEntry[] = [ { lines: [this.#format(request, columns, 0)], - copy: `${request.method} ${request.url} ${request.status} ${request.duration}ms`, + copy: describeRequest(request), }, ...file ? [{ lines: [`${' '.repeat(12)}${styleText(MUTED, 'served by ')}${link(file, { cwd: this.#cwd })}`], copy: file }] : [], { lines: [''] }, @@ -307,17 +307,17 @@ function renderTimeline(request: DevRequest, spans: DevRequestSpan[], columns: n const longest = Math.max(...rows.map(row => row.label.length)) const labelWidth = Math.max(LABEL_MIN_WIDTH, Math.min(longest, Math.floor(columns * 0.4))) const width = Math.max(TIMELINE_MIN_WIDTH, columns - labelWidth - TIMELINE_CHROME) - const totalLabel = formatSpanDuration(total) + const totalLabel = formatDuration(total) const axis = styleText(MUTED, `${'0ms'.padEnd(width - totalLabel.length)}${totalLabel}`) return [ { lines: [`${styleText('bold', 'timeline'.padEnd(labelWidth + 11))} ${axis}`] }, ...rows.map((row) => { const label = truncate(row.label, labelWidth).padEnd(labelWidth) - const time = formatSpanDuration(row.duration).padStart(9) + const time = formatDuration(row.duration).padStart(9) return { lines: [`${styleText(MUTED, row.kind.padEnd(10))} ${label} ${drawBar(row, origin, total, width)} ${styleText(MUTED, time)}`], - copy: `+${formatSpanDuration(row.segments[0]![0] - origin)} ${row.kind} ${row.label.trim()} ${formatSpanDuration(row.duration)}`, + copy: `+${formatDuration(row.segments[0]![0] - origin)} ${row.kind} ${row.label.trim()} ${formatDuration(row.duration)}`, } }), { lines: [''] }, @@ -367,8 +367,8 @@ function renderCompileBreakdown(compiled: DevRequestSpan[], columns: number): Ov const modules = [...compiled].sort((a, b) => b.duration - a.duration).slice(0, TOP_MODULES) const nameWidth = Math.max(0, columns - 12) const row = (name: string, duration: number): OverlayEntry => ({ - lines: [` ${formatSpanDuration(duration).padStart(8)} ${truncate(name, nameWidth)}`], - copy: `${formatSpanDuration(duration)} ${name}`, + lines: [` ${formatDuration(duration).padStart(8)} ${truncate(name, nameWidth)}`], + copy: `${formatDuration(duration)} ${name}`, }) return [ ...plugins.length @@ -384,12 +384,12 @@ function renderCompileBreakdown(compiled: DevRequestSpan[], columns: number): Ov ] } -function formatSpanDuration(duration: number): string { - return duration < 10 ? `${Math.max(0, duration).toFixed(1)}ms` : `${Math.round(duration)}ms` +function describeRequest(request: DevRequest): string { + return `${request.method} ${request.url} ${request.status} ${formatDuration(request.duration)}` } -function formatDuration(duration: number): string { - const text = `${duration}ms`.padStart(7) +function paintDuration(duration: number): string { + const text = formatDuration(duration).padStart(7) if (duration >= VERY_SLOW_MS) { return styleText('red', text) } @@ -400,11 +400,11 @@ function formatRequest(request: DevRequest, columns: number, target?: { file: st const time = styleText(MUTED, formatTime(request.time).padStart(11)) const method = styleText('bold', request.method.padEnd(6)) const status = paintStatus(request.status, String(request.status).padEnd(4)) - const duration = formatDuration(request.duration) + const duration = paintDuration(request.duration) const marker = errors ? ` ${styleText(['red', 'bold'], `✗ ${errors}`)}` : '' // The four fixed columns above, plus the spaces between them and the marker. const room = Math.max(10, columns - 34 - (errors ? ` ✗ ${errors}`.length : 0)) - const label = request.url.length > room ? `${request.url.slice(0, room - 1)}…` : request.url + const label = truncate(request.url, room) const url = target ? link(target.file, { cwd: target.cwd, formatter: () => label }) : label return `${time} ${method} ${status} ${duration} ${url}${marker}` } diff --git a/packages/nuxt-cli/src/dev/tui/route-overlay.ts b/packages/nuxt-cli/src/dev/tui/route-overlay.ts index b6f5bec65..769114dec 100644 --- a/packages/nuxt-cli/src/dev/tui/route-overlay.ts +++ b/packages/nuxt-cli/src/dev/tui/route-overlay.ts @@ -8,10 +8,10 @@ import { styleText } from 'node:util' import { link } from 'clickable-path' -import { MUTED } from '../../utils/terminal-theme' +import { truncate } from '../../utils/formatting' +import { MUTED } from '../../utils/terminal-theme' import { formatHints, ScreenOverlay } from './screen' -import { truncate } from './width' type RouteFilter = 'all' | 'page' | 'server' diff --git a/packages/nuxt-cli/src/dev/tui/screen.ts b/packages/nuxt-cli/src/dev/tui/screen.ts index 25d3d4956..0284ad306 100644 --- a/packages/nuxt-cli/src/dev/tui/screen.ts +++ b/packages/nuxt-cli/src/dev/tui/screen.ts @@ -3,9 +3,10 @@ import type { Key } from './keys' import process from 'node:process' import { styleText } from 'node:util' +import { stripAnsi, truncate, visibleWidth } from '../../utils/formatting' import { MUTED, paint } from '../../utils/terminal-theme' -import { stripAnsi, truncate, visibleWidth } from './width' +import { writeClipboard } from '../listen' const RENDER_DELAY_MS = 50 @@ -294,14 +295,6 @@ export abstract class ScreenOverlay { : this.renderHints(columns) } - /** - * Move the selection, stopping at both ends. A list that loops has no start - * or end to get your bearings from, least of all a log that is still growing. - * - * With nothing selected, moving up starts at the bottom and moving down - * starts at the top of what is on screen, so either arrow is a way in and - * neither one jumps somewhere else. - */ /** Views lay out inside the gutter, so their own truncation stays exact. */ #entries(): OverlayEntry[] { return this.renderEntries((process.stdout.columns || 80) - GUTTER_WIDTH) @@ -371,16 +364,7 @@ export abstract class ScreenOverlay { } async #copy(text: string, done: string): Promise { - try { - const { writeText } = await import('tinyclip') - // What lands on the clipboard is going into an issue or a search box, - // so it should carry no colour or hyperlink escapes. - await writeText(stripAnsi(text)) - this.notify(`${done} to clipboard`) - } - catch { - this.notify('no clipboard available') - } + this.notify(await writeClipboard(stripAnsi(text)) ? `${done} to clipboard` : 'no clipboard available') } /** Replace the hint line with `text` for a moment. */ diff --git a/packages/nuxt-cli/src/dev/tui/session.ts b/packages/nuxt-cli/src/dev/tui/session.ts index 1d8b6886b..78b229e55 100644 --- a/packages/nuxt-cli/src/dev/tui/session.ts +++ b/packages/nuxt-cli/src/dev/tui/session.ts @@ -10,42 +10,26 @@ import { formatWithOptions, styleText } from 'node:util' import { consola } from 'consola' import { KEEPS_PROCESS_ALIVE } from '../../utils/errors' +import { stripAnsi } from '../../utils/formatting' import { debug, isEmittingCliLog, setLoggerImpl } from '../../utils/logger' -import { getPkgVersion } from '../../utils/pkg' import { READY_MESSAGE } from '../../utils/progress-snapshot' import { startupElapsedMs } from '../../utils/startup-clock' import { registerTerminalHost } from '../../utils/terminal-host' -import { currentRequest, isServingRequest } from '../serving-state' +import { currentRequest } from '../serving-state' import { isShutdownAdopted } from '../shutdown' import { queryBackground } from './background' import { DevEventLog, isBoxedNotice, normaliseMessage, noteRoute } from './events' -import { createPanelState, renderPanelState } from './first-frame' +import { createPanelState, renderPanelState, resolveNuxtVersion } from './first-frame' import { LOGO_FRAME_MS } from './logo' import { describeListenURLs } from './panel' import { resolveDevUISupport } from './support' import { PanelSurface } from './surface' -import { stripAnsi } from './width' const SHOW_CURSOR = '\u001B[?25h' /** Long enough for a forwarded log to be paired with its printed output. */ const ERROR_SURFACE_DELAY_MS = 60 -/** An error as it belongs in scrollback: as printed, or as reported. */ -function renderErrorLine(event: DevLogEvent): string { - return event.rendered ?? `${styleText(['red', 'bold'], 'ERROR')} ${event.message}` -} - -/** A boxed notice as it belongs in scrollback: as printed, or as reported. */ -function renderNoticeBlock(event: DevLogEvent): string { - return event.rendered ?? `${event.message}\n` -} - -/** A warning as it belongs in scrollback: as printed, or as reported. */ -function renderWarningLine(event: DevLogEvent): string { - return event.rendered ?? `${styleText(['yellow', 'bold'], 'WARN')} ${event.message}` -} - /** Cursor movement and erasure: output that repaints rather than appends. */ // eslint-disable-next-line no-control-regex const REWRITE_RE = /\r(?!\n)|\u001B\[[0-9;]*[A-GJK]/ @@ -54,11 +38,6 @@ const REWRITE_RE = /\r(?!\n)|\u001B\[[0-9;]*[A-GJK]/ // eslint-disable-next-line no-control-regex const CURSOR_RE = /\u001B\[[0-9;]*[A-GJK]|\u001B\[\?25[hl]/g -/** Whether `chunk` rewrites earlier output instead of adding to it. */ -function isRewrite(chunk: string): boolean { - return REWRITE_RE.test(chunk) -} - /** * What a run of self-rewriting output leaves on screen: each line keeps only * what follows its last carriage return, and the cursor control goes. @@ -333,7 +312,7 @@ export function beginDevUI(options: PanelStartOptions & { start?: PanelStart } = noteRoute(owner, 'output') return } - const rewriting = isRewrite(chunk) + const rewriting = REWRITE_RE.test(chunk) const message = (rewriting ? settleRewrites(plain) : plain).replace(/\n+$/, '') if (!message.trim()) { return @@ -349,15 +328,16 @@ export function beginDevUI(options: PanelStartOptions & { start?: PanelStart } = transient = undefined return } + const request = currentRequest() const event: DevLogEvent = { time: Date.now(), level: 2, type: 'log', message, rendered: chunk, - source: isServingRequest() ? 'runtime' : 'build', - request: currentRequest()?.label, - requestId: currentRequest()?.id, + source: request ? 'runtime' : 'build', + request: request?.label, + requestId: request?.id, } const stored = events.push(event, { route: 'output' }) // Only an entry of this run's own may be rewritten by its later frames: @@ -366,14 +346,10 @@ export function beginDevUI(options: PanelStartOptions & { start?: PanelStart } = transient = rewriting && stored === event ? stored : undefined } - /** - * Write text into scrollback above the panel, once the event it was rendered - * from has had time to be paired with its printed form. - * - * Delayed by a beat because a log forwarded from a fork arrives before the - * output that renders it, and the rendered form is what should be shown. - */ - function surfaceLater(render: () => string): void { + // Deferred: a forwarded log arrives before its printed form. + function surfaceLater(event: DevLogEvent, fallback: string): void { + event.surfaced = true + const render = () => event.rendered ?? fallback const timer: NodeJS.Timeout = setTimeout(() => { pendingSurfaces.delete(timer) surfaceText(render()) @@ -382,53 +358,32 @@ export function beginDevUI(options: PanelStartOptions & { start?: PanelStart } = pendingSurfaces.set(timer, render) } - function surfaceError(event: DevLogEvent): void { - // Once the server has been ready, errors belong to the panel's badge and the - // log view. Before that, one may be the last thing the process ever says. - if (state.readyMs !== undefined) { - return - } + // Boxed notices always reach scrollback; errors and CLI warnings only before first ready. + function surfaceEvent(event: DevLogEvent): void { const text = normaliseMessage(event.message) - // A watcher that keeps failing the same way should say so once. - if (event.surfaced || !text || text === lastSurfacedError) { + if (event.surfaced || !text) { return } - event.surfaced = true - lastSurfacedError = text - surfaceLater(() => renderErrorLine(event)) - } - - /** - * Write a warning the CLI raised during startup into scrollback above the - * panel. The panel holds a badge for it, but a badge has one truncated line - * and these run to a sentence or two. - */ - function surfaceWarning(event: DevLogEvent): void { - if (event.surfaced || state.readyMs !== undefined || !normaliseMessage(event.message)) { - return + if (isBoxedNotice(event)) { + return surfaceLater(event, `${event.message}\n`) } - event.surfaced = true - surfaceLater(() => renderWarningLine(event)) - } - - /** - * Write a boxed notice into scrollback above the panel, at any point in the - * session: it carries something (a URL, a token) that has to be readable and - * selectable, which a status line cannot offer. - */ - function surfaceNotice(event: DevLogEvent): void { - // Repeats within the dedupe window are merged into the entry already shown; - // a later request is news again, and has to be answered again. - if (event.surfaced || !normaliseMessage(event.message)) { + if (state.readyMs !== undefined) { return } - event.surfaced = true - surfaceLater(() => renderNoticeBlock(event)) + // A watcher that keeps failing the same way should say so once. + if (event.level <= 0 && text !== lastSurfacedError) { + lastSurfacedError = text + surfaceLater(event, `${styleText(['red', 'bold'], 'ERROR')} ${event.message}`) + } + else if (event.level === 1 && event.source === 'cli') { + surfaceLater(event, `${styleText(['yellow', 'bold'], 'WARN')} ${event.message}`) + } } const reporter = { log(logObj: { level: number, type: string, tag?: string, args: unknown[] }) { const cli = isEmittingCliLog() + const request = cli ? undefined : currentRequest() expectRender(events.push({ time: Date.now(), level: logObj.level, @@ -438,9 +393,9 @@ export function beginDevUI(options: PanelStartOptions & { start?: PanelStart } = // The app, the build and the CLI share this consola instance on one // thread, so origin is inferred: the CLI marks its own calls, and // anything logged while a request is open belongs to the runtime. - source: cli ? 'cli' : isServingRequest() ? 'runtime' : 'build', - request: cli ? undefined : currentRequest()?.label, - requestId: cli ? undefined : currentRequest()?.id, + source: cli ? 'cli' : request ? 'runtime' : 'build', + request: request?.label, + requestId: request?.id, }, { // A log the CLI wrote itself reaches the UI no other way. route: cli ? undefined : 'reporter', @@ -497,17 +452,7 @@ export function beginDevUI(options: PanelStartOptions & { start?: PanelStart } = onTeardown: task => void teardownTasks.push(task), } - events.onEvent((event) => { - if (isBoxedNotice(event)) { - surfaceNotice(event) - } - else if (event.level <= 0) { - surfaceError(event) - } - else if (event.level === 1 && event.source === 'cli') { - surfaceWarning(event) - } - }) + events.onEvent(surfaceEvent) consola.addReporter(reporter) @@ -574,8 +519,7 @@ export function beginDevUI(options: PanelStartOptions & { start?: PanelStart } = current = session retargetCurrent = (next) => { - const nextCwd = next.cwd || cwd - const version = next.version || getPkgVersion(nextCwd, 'nuxt') || getPkgVersion(nextCwd, 'nuxt-nightly') || undefined + const version = resolveNuxtVersion({ version: next.version, cwd: next.cwd || cwd }) if (version === state.version) { return } diff --git a/packages/nuxt-cli/src/dev/tui/width.ts b/packages/nuxt-cli/src/dev/tui/width.ts deleted file mode 100644 index ece3fda2f..000000000 --- a/packages/nuxt-cli/src/dev/tui/width.ts +++ /dev/null @@ -1,53 +0,0 @@ -// eslint-disable-next-line no-control-regex -const ANSI_RE = /\u001B\[[0-9;]*m|\u001B\]8;[^\u0007]*\u0007/g - -/** Strip colour and hyperlink escapes, leaving the characters a user sees. */ -export function stripAnsi(text: string): string { - return text.replace(ANSI_RE, '') -} - -/** Columns `text` occupies once escape sequences are discounted. */ -export function visibleWidth(text: string): number { - return stripAnsi(text).length -} - -/** Ends whatever the cut interrupted, so nothing leaks onto the next line. */ -const LINK_TERMINATOR = '\u001B]8;;\u0007' - -/** - * Cut `text` to `columns`, ignoring escape sequences when measuring and closing - * whatever they opened, so a truncated line cannot leak its styling or turn the - * rest of the screen into a hyperlink. - */ -export function truncate(text: string, columns: number): string { - if (columns <= 0) { - return '' - } - if (visibleWidth(text) <= columns) { - return text - } - - const limit = columns - 1 - let visible = 0 - let index = 0 - let styled = false - let linked = false - ANSI_RE.lastIndex = 0 - while (index < text.length && visible < limit) { - ANSI_RE.lastIndex = index - const match = ANSI_RE.exec(text) - if (match?.index === index) { - if (match[0].startsWith('\u001B]8;')) { - linked = match[0] !== LINK_TERMINATOR - } - else { - styled = true - } - index += match[0].length - continue - } - index++ - visible++ - } - return `${text.slice(0, index)}\u2026${linked ? LINK_TERMINATOR : ''}${styled ? '\u001B[0m' : ''}` -} diff --git a/packages/nuxt-cli/src/dev/utils.ts b/packages/nuxt-cli/src/dev/utils.ts index 5871488b6..4122908ee 100644 --- a/packages/nuxt-cli/src/dev/utils.ts +++ b/packages/nuxt-cli/src/dev/utils.ts @@ -2,7 +2,7 @@ import type { Nuxt, NuxtConfig, NuxtOptions, ViteConfig } from '@nuxt/schema' import type { ErrorReport } from 'my-bad' import type { createDevServer } from 'nitro/builder' import type { NitroDevServer } from 'nitropack' -import type { FSWatcher, Stats } from 'node:fs' +import type { FSWatcher } from 'node:fs' import type { Server as HttpServer, IncomingMessage, RequestListener, ServerResponse } from 'node:http' import type { PendingRender } from '../utils/progress-snapshot' @@ -14,17 +14,13 @@ import type { BoundServer, DevListenOverrides, Listener, ListenOptions, ListenUR import type { ServerLogEvent } from './log-channel' import type { DevRestartReason } from './reason' import type { DevRequestSpan } from './span-channel' -import { Buffer } from 'node:buffer' -import { hash } from 'node:crypto' import EventEmitter from 'node:events' -import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, statSync, watch } from 'node:fs' +import { existsSync, watch } from 'node:fs' import { mkdir } from 'node:fs/promises' import process from 'node:process' -import { fileURLToPath } from 'node:url' import { styleText } from 'node:util' import defu from 'defu' -import { resolveModulePath } from 'exsolve' import { toNodeListener } from 'h3' import { join, resolve } from 'pathe' import { debounce } from 'perfect-debounce' @@ -40,64 +36,21 @@ import { acquireLock, formatLockError, getTakeoverPid, updateLock } from '../uti import { debug, logger, writeNotice } from '../utils/logger' import { loadNuxtManifest, resolveNuxtManifest, writeNuxtManifest } from '../utils/nuxt' import { resolveServerBuild } from '../utils/server-build' -import { createCliReport, DEFAULT_ERROR_CHANNEL, ERROR_CHANNEL_ENV, handleErrorChannelRequest, isErrorChannelRequest, isThreadRunner, openErrorBridge, publishCliProgress, renderErrorPage, resolveChannelPath, summariseReport, useErrorChannel, withErrorChannel } from './error-channel' +import { createConfigWatcher, FileChangeTracker, getLocalLayerDirs } from './config-watcher' +import { createCliReport, DEFAULT_ERROR_CHANNEL, ERROR_CHANNEL_ENV, handleErrorChannelRequest, isErrorChannelRequest, isLocalPeer, isThreadRunner, openErrorBridge, publishCliProgress, renderErrorPage, resolveChannelPath, summariseReport, useErrorChannel, withErrorChannel } from './error-channel' import { sendErrorResponse } from './error-response' -import { isAllowedHost, isLoopbackAddress } from './host-check' -import { INSPECT_ENV } from './inspect' +import { isAllowedHost } from './host-check' import { bindListener, createListener, matchesBoundTarget, openBrowser, resolveOpenURL } from './listen' import { RECOVERY_SCRIPT, withProgress } from './loading-page' import { resolveDefaultLoadingTemplate } from './loading-template' import { resolvePortlessURLs } from './portless' import { DEV_INTERNAL_PREFIX, DevProgress } from './progress' import { formatChangedKeys, formatRestartReason, formatSkippedReload, mergeRestartReasons, withConfigKeys } from './reason' -import { createRequest, currentRequest, encodeRequestLabel, REQUEST_HEADER, REQUEST_LABEL_HEADER, runWithRequest } from './serving-state' +import { isBundlerRequest, isDocumentRequest } from './request-kind' +import { registerDevPlugins } from './runtime-plugins' +import { attachRequest, currentRequest, runWithRequest } from './serving-state' import { WarmupGate } from './warmup-gate' -/** - * Nitro plugin that attributes the app's logs to the request that caused them, - * from inside the module runner's realm. Resolved through this package's own - * exports because the caller may be bundled into any chunk. - */ -function registerRequestContextPlugin(nitro: NitroConfigForHook, cwd: string): void { - try { - const source = fileURLToPath(import.meta.resolve('@nuxt/cli/runtime/dev-request-context')) - const id = join(nitro.buildDir || join(cwd, '.nuxt'), 'dev-request-context.mjs') - // The build dir is not a package, so `consola` is pinned to the copy the app - // itself logs through; a bare specifier would not resolve from there. - const consola = resolveConsola(cwd) - nitro.virtual ||= {} - nitro.virtual[id] = () => readFileSync(source, 'utf8').replace('\'consola\'', JSON.stringify(consola)) - nitro.plugins ||= [] - nitro.plugins.push(id) - } - catch (error) { - debug('Could not resolve the request context plugin; app logs will not be attributed:', error) - } -} - -function registerRuntimePlugin(nitro: NitroConfigForHook, name: 'dev-close-sockets' | 'dev-inspector'): void { - try { - nitro.plugins ||= [] - nitro.plugins.push(fileURLToPath(import.meta.resolve(`@nuxt/cli/runtime/${name}`))) - } - catch (error) { - debug(`Could not resolve the ${name} plugin:`, error) - } -} - -/** - * The `consola` the app itself logs through, which is the one - * `@nuxt/nitro-server` wraps `console` with: its own, not the CLI's. Reporting - * from any other instance sees none of the app's logs. - */ -function resolveConsola(cwd: string): string { - const nuxt = resolveModulePath('nuxt', { from: cwd, try: true }) - const from = [nuxt, cwd].filter(Boolean) as string[] - return resolveModulePath('consola', { from, try: true }) ?? fileURLToPath(import.meta.resolve('consola')) -} - -type NitroConfigForHook = Parameters['nitro:config']>>[0] - export type NuxtParentIPCMessage = | { type: 'nuxt:internal:dev:context', context: NuxtDevContext, listenOverrides: DevListenOverrides, inspect?: InspectOptions } | { type: 'nuxt:internal:dev:shutdown' } @@ -167,8 +120,6 @@ function devForkParentPid(): number | undefined { return process.ppid > 1 ? process.ppid : undefined } -// https://regex101.com/r/7HkR5c/1 -const RESTART_RE = /^(?:nuxt\.config\.[a-z0-9]+|\.nuxtignore|\.nuxtrc|\.config\/nuxt(?:\.config)?\.[a-z0-9]+)$/ const TRAILING_SLASH_RE = /\/$/ /** @@ -180,117 +131,6 @@ function noDevServerMessage(builder: string, built: boolean): string { + ` A ${styleText('cyan', 'server.builder')} must expose a \`handler\`, \`fetch\` or \`app\` on \`nuxt.server\` to be served by ${styleText('cyan', 'nuxt dev')}.` } -/** - * Files above this size are tracked by mtime alone. - */ -const MAX_HASHED_FILE_SIZE = 256 * 1024 - -interface TrackedFile { - mtimeMs: number - /** Absent for directories and for files too large to hash. */ - contentHash?: string -} - -function hashFileContents(path: string, size: number): string | undefined { - if (size > MAX_HASHED_FILE_SIZE) { - return undefined - } - let fd: number | undefined - try { - fd = openSync(path, 'r') - // The stat'd size can be stale, so cap the read rather than trusting it; an - // extra byte means the file outgrew the limit and falls back to mtime. - const buffer = Buffer.allocUnsafe(MAX_HASHED_FILE_SIZE + 1) - let read = 0 - while (read < buffer.length) { - const bytes = readSync(fd, buffer, read, buffer.length - read, read) - if (bytes === 0) { - break - } - read += bytes - } - if (read > MAX_HASHED_FILE_SIZE) { - return undefined - } - return hash('sha1', buffer.subarray(0, read), 'hex') - } - catch { - return undefined - } - finally { - if (fd !== undefined) { - try { - closeSync(fd) - } - catch {} - } - } -} - -function trackFile(path: string, stats: Stats): TrackedFile { - if (stats.isDirectory()) { - return { mtimeMs: stats.mtimeMs } - } - return { mtimeMs: stats.mtimeMs, contentHash: hashFileContents(path, stats.size) } -} - -export class FileChangeTracker { - private entries = new Map() - - /** - * Whether a watcher event for `filePath` represents a real change. - * - * Regular files are compared by content, so identical rewrites (atomic saves, - * formatters, `git checkout` of the same revision) do not trigger a reload. - * Directories and files over `MAX_HASHED_FILE_SIZE` fall back to mtime. - */ - shouldEmitChange(filePath: string): boolean { - const resolved = resolve(filePath) - try { - const stats = statSync(resolved) - const previous = this.entries.get(resolved) - const current = trackFile(resolved, stats) - - this.entries.set(resolved, current) - - if (previous === undefined) { - return true - } - if (previous.contentHash !== undefined && current.contentHash !== undefined) { - return previous.contentHash !== current.contentHash - } - return previous.mtimeMs !== current.mtimeMs - } - catch { - // remove from cache if it has been deleted or is inaccessible - this.entries.delete(resolved) - return true - } - } - - prime(filePath: string, recursive: boolean = false): void { - const resolved = resolve(filePath) - const stat = statSync(resolved) - this.entries.set(resolved, trackFile(resolved, stat)) - if (stat.isDirectory()) { - const entries = readdirSync(resolved) - for (const entry of entries) { - const fullPath = resolve(resolved, entry) - try { - const stats = statSync(fullPath) - this.entries.set(fullPath, trackFile(fullPath, stats)) - if (recursive && stats.isDirectory()) { - this.prime(fullPath, recursive) - } - } - catch { - // ignore - } - } - } - } -} - type NuxtWithServer = Omit & { server?: NitroDevServer | ReturnType } type ViteServerOptions = NonNullable @@ -367,33 +207,6 @@ export interface DevRequestEvent { internal?: boolean } -/** Vite/webpack module-graph URLs: `/@id/...`, `/@fs/...`, `virtual:` modules, SFC block queries, plus Nuxt's dev-only virtual file system endpoint. */ -const BUNDLER_URL_RE = /^\/(?:@|__|_nuxt\/|_vfs(?:\.json)?(?:$|[/?]))|\/node_modules\/|virtual:|[?&](?:vue&type=|import(?:&|=|$)|direct(?:&|=|$)|html-proxy|raw(?:&|=|$)|worker(?:&|=|$))/ - -/** - * Whether a request is the bundler talking to itself rather than the app being - * used. There is no dedicated header, but in dev every script and style - * subresource is served through the bundler pipeline, so `sec-fetch-dest` - * identifies most of it and the URL shape catches the rest. - */ -export function isBundlerRequest(url: string, fetchDest?: string): boolean { - return fetchDest === 'script' || fetchDest === 'style' || BUNDLER_URL_RE.test(url) -} - -/** - * Whether a request is one the app renders a page for, rather than the bundler - * fetching a module or a client asking for data. - */ -export function isDocumentRequest(req: IncomingMessage): boolean { - if ((req.method || 'GET') !== 'GET') { - return false - } - if (!String(req.headers.accept || '').includes('text/html')) { - return false - } - return !isBundlerRequest(req.url || '/', String(req.headers['sec-fetch-dest'] || '') || undefined) -} - interface DevServerEventMap { 'loading:error': [error: Error] 'loading': [loadingMessage: string] @@ -444,8 +257,9 @@ export class NuxtDevServer extends EventEmitter { #openedEagerly = false #progress = new DevProgress() #warmup = new WarmupGate() + #closed = false - loadDebounced: () => void + loadDebounced: ReturnType> handler: RequestListener /** Live startup progress, streamed to the loading page and the terminal. */ progress: DevProgress = this.#progress @@ -479,10 +293,6 @@ export class NuxtDevServer extends EventEmitter { }) this.handler = async (req, res) => { - // Only the CLI's own dispatch may set the request-attribution header; - // anything arriving on the wire is stripped so an external client cannot - // forge or steal another request's identity in the logs. - stripRequestHeader(req) // Internal endpoints answer before Nuxt exists, so they are matched ahead // of anything that waits on the first successful load, and they stay out // of the request feed. @@ -504,10 +314,7 @@ export class NuxtDevServer extends EventEmitter { if (options.captureUIEvents) { this.#internalResponses.add(res) } - // A peer on another machine is served the channel scoped to its own - // request, since every header is forgeable over a direct connection. - const trusted = isLoopbackAddress(req.socket?.remoteAddress) - await handleErrorChannelRequest(req, res, this.#errorChannelOptions(), { trusted }).catch((error) => { + await handleErrorChannelRequest(req, res, this.#errorChannelOptions()).catch((error) => { debug('Could not answer an error channel request:', error) if (!res.writableEnded) { res.end() @@ -515,16 +322,12 @@ export class NuxtDevServer extends EventEmitter { }) return } - const method = req.method || 'GET' - const url = req.url || '/' - const request = createRequest(`${method} ${url}`) - const label = encodeRequestLabel(request) - req.headers[REQUEST_HEADER] = request.id - req.headers[REQUEST_LABEL_HEADER] = label - req.rawHeaders.push(REQUEST_HEADER, request.id, REQUEST_LABEL_HEADER, label) + const request = attachRequest(req) if (!options.captureUIEvents) { return this.#serve(req, res) } + const method = req.method || 'GET' + const url = req.url || '/' const start = performance.now() const fetchDest = String(req.headers['sec-fetch-dest'] || '') || undefined this.#inflight.set(request.id, { id: request.id, internal: isBundlerRequest(url, fetchDest) }) @@ -720,7 +523,7 @@ export class NuxtDevServer extends EventEmitter { const html = await renderErrorPage(report, { cwd: this.#rootDir(), channel: channel && this.#errorChannel, - history: isLoopbackAddress(req.socket?.remoteAddress) ? channel?.history : undefined, + history: isLocalPeer(req) ? channel?.history : undefined, }) res.statusCode = 500 res.setHeader('Content-Type', 'text/html') @@ -853,8 +656,11 @@ export class NuxtDevServer extends EventEmitter { this.#configWatcher?.() } - /** Stop listening for forwarded reports and bundler timings. Call only on final shutdown, not during reloads. */ - closeErrorBridge(): void { + /** Stop watching and reloading for good. Reloads use `closeWatchers` instead. */ + shutdown(): void { + this.#closed = true + this.loadDebounced.cancel() + this.closeWatchers() this.#closeErrorBridge?.() this.#closeErrorBridge = undefined this.#unsubscribeCompileTiming?.() @@ -871,6 +677,9 @@ export class NuxtDevServer extends EventEmitter { } async load(reload?: boolean, reason?: DevRestartReason): Promise { + if (this.#closed) { + return + } try { this.closeWatchers() @@ -881,7 +690,9 @@ export class NuxtDevServer extends EventEmitter { catch (error) { await this.#reportLoadFailure(error, !!reload) } - this.#watchConfig() + if (!this.#closed) { + this.#watchConfig() + } } /** Serve and report a load that failed, in place of the app it would have served. */ @@ -927,13 +738,7 @@ export class NuxtDevServer extends EventEmitter { hooks: { ...this.options.overrides.hooks, 'nitro:config': (nitro) => { - registerRuntimePlugin(nitro, 'dev-close-sockets') - if (process.env[INSPECT_ENV]) { - registerRuntimePlugin(nitro, 'dev-inspector') - } - if (captureUIEvents) { - registerRequestContextPlugin(nitro, this.options.cwd) - } + registerDevPlugins(nitro, this.options.cwd, captureUIEvents) return this.options.overrides.hooks?.['nitro:config']?.(nitro) }, } satisfies NuxtConfig['hooks'], @@ -1297,7 +1102,6 @@ export class NuxtDevServer extends EventEmitter { } this.#currentNuxt.hooks.hookOnce('close', () => { - this.#closeWebSocketConnections() this.listener.server.removeAllListeners('upgrade') }) @@ -1570,25 +1374,6 @@ export class NuxtDevServer extends EventEmitter { } } -/** - * Remove any wire-supplied copy of the request-attribution headers, from both - * the parsed headers and `rawHeaders` (which some frameworks reconstruct - * requests from), before the CLI sets its own values. - */ -function stripRequestHeader(req: IncomingMessage): void { - if (req.headers[REQUEST_HEADER] === undefined && req.headers[REQUEST_LABEL_HEADER] === undefined) { - return - } - delete req.headers[REQUEST_HEADER] - delete req.headers[REQUEST_LABEL_HEADER] - for (let i = req.rawHeaders.length - 2; i >= 0; i -= 2) { - const name = req.rawHeaders[i]?.toLowerCase() - if (name === REQUEST_HEADER || name === REQUEST_LABEL_HEADER) { - req.rawHeaders.splice(i, 2) - } - } -} - /** Whether anyone is watching this terminal, directly or through the panel. */ function isInteractive(): boolean { return !!process.stdout.isTTY || !!process.env.__NUXT_DEV_PIPED_TTY__ @@ -1662,116 +1447,3 @@ function resolveDevServerDefaults(listenOptions: { hostname?: string, https: boo return defaultConfig } - -// Skips the root (already watched) and external layers (`node_modules` or out of tree) whose config -// isn't expected to change during local dev. -export function getLocalLayerDirs(layers: ReadonlyArray<{ cwd?: string, config?: { rootDir?: string } | null }>, cwd: string): string[] { - const root = resolve(cwd) - const dirs = new Set() - for (const layer of layers) { - const dir = layer.cwd || layer.config?.rootDir - const resolved = dir && resolve(dir) - if (resolved && resolved !== root && resolved.startsWith(`${root}/`) && !resolved.includes('/node_modules/')) { - dirs.add(resolved) - } - } - return [...dirs] -} - -function createConfigWatcher(cwd: string, dotenvFileName: string | string[] = '.env', onRestart: (file: string) => void, onReload: (file: string) => void, layerDirs: string[] = []) { - const dotenvFileNames = new Set(Array.isArray(dotenvFileName) ? dotenvFileName : [dotenvFileName]) - - // each local layer dir is watched alongside the root, but only the root restarts on dotenv changes. - const closers = [ - watchConfigDir(cwd, onReload, (file, path) => dotenvFileNames.has(file) && onRestart(path)), - ...layerDirs.map(dir => watchConfigDir(dir, onReload)), - ] - - return () => { - for (const close of closers) { - close() - } - } -} - -/** - * Collapse the burst of watcher events a single save produces into one call per - * file. A truncate-then-write save is briefly observable as an empty file, and - * evaluating it mid-write would report a spurious change. - */ -export function perFile(handler: (file: string) => void, delay = 30): { listener: (event: unknown, file: string | null) => void, cancel: () => void } { - const timers = new Map() - return { - listener: (_event, file) => { - if (!file) { - return - } - clearTimeout(timers.get(file)) - const timer = setTimeout(() => { - timers.delete(file) - handler(file) - }, delay) - timer.unref?.() - timers.set(file, timer) - }, - cancel: () => { - for (const timer of timers.values()) { - clearTimeout(timer) - } - timers.clear() - }, - } -} - -function watchConfigDir(dir: string, onReload: (path: string) => void, onFile?: (file: string, path: string) => void) { - const fileWatcher = new FileChangeTracker() - fileWatcher.prime(dir) - const watcher = watch(dir) - let configDirWatcher = existsSync(join(dir, '.config')) ? createConfigDirWatcher(dir, onReload) : undefined - - const { listener, cancel } = perFile((file) => { - if (!fileWatcher.shouldEmitChange(resolve(dir, file))) { - return - } - - onFile?.(file, resolve(dir, file)) - - if (RESTART_RE.test(file)) { - onReload(resolve(dir, file)) - } - - if (file === '.config') { - configDirWatcher ||= createConfigDirWatcher(dir, onReload) - } - }) - watcher.on('change', listener) - - return () => { - cancel() - watcher.close() - configDirWatcher?.() - } -} - -function createConfigDirWatcher(cwd: string, onReload: (path: string) => void) { - const configDir = join(cwd, '.config') - const fileWatcher = new FileChangeTracker() - - fileWatcher.prime(configDir) - const configDirWatcher = watch(configDir) - const { listener, cancel } = perFile((file) => { - if (!fileWatcher.shouldEmitChange(resolve(configDir, file))) { - return - } - - if (RESTART_RE.test(file)) { - onReload(resolve(configDir, file)) - } - }) - configDirWatcher.on('change', listener) - - return () => { - cancel() - configDirWatcher.close() - } -} diff --git a/packages/nuxt-cli/src/main.ts b/packages/nuxt-cli/src/main.ts index 82e9b4a47..29d557f77 100644 --- a/packages/nuxt-cli/src/main.ts +++ b/packages/nuxt-cli/src/main.ts @@ -16,6 +16,7 @@ import { setupGlobalConsole } from './utils/console' import { debug, logger } from './utils/logger' import { setupProxySupport } from './utils/network' import { findInPath, withLocalBinPath } from './utils/path-env' +import { suggestCommand } from './utils/suggest' import { templateNames } from './utils/templates/names' import { findUnknownFlags, replaceFlag, suggestFlags } from './utils/unknown-args' import { scheduleUpdateNudge } from './utils/update-lazy' @@ -177,7 +178,6 @@ function resolveLazy(value: T | (() => T | Promise) | undefined): Promise< * showing usage. */ async function reportUnknownCommand(command: string, rawArgs: string[]): Promise { - const { suggestCommand } = await import('./utils/suggest-command') const names = Object.keys(commands).filter(name => !name.startsWith('_')) const suggestion = await suggestCommand(command, names) if (!suggestion) { diff --git a/packages/nuxt-cli/src/utils/banner.ts b/packages/nuxt-cli/src/utils/banner.ts index a413c85b6..59d17c21e 100644 --- a/packages/nuxt-cli/src/utils/banner.ts +++ b/packages/nuxt-cli/src/utils/banner.ts @@ -4,7 +4,7 @@ import { styleText } from 'node:util' import { logger } from './logger' import { getNitroVersion } from './nitro' -import { getPkgJSON, getPkgVersion } from './pkg' +import { getNuxtPkgVersion, getPkgJSON, getPkgVersion } from './pkg' export function getBuilder(cwd: string, builder: Exclude): { name: string, version: string, provider?: { name: string, version: string } } { switch (builder) { @@ -43,7 +43,7 @@ export interface StackVersions { /** The versions of the framework packages in use, for the banner and the dev UI. */ export function resolveStackVersions(cwd: string, options: { nuxtVersion?: string, builder?: NuxtOptions['builder'] | NuxtConfig['builder'] } = {}): StackVersions { return { - nuxt: options.nuxtVersion || getPkgVersion(cwd, 'nuxt') || getPkgVersion(cwd, 'nuxt-nightly'), + nuxt: options.nuxtVersion || getNuxtPkgVersion(cwd), nitro: getNitroVersion(cwd), builder: getBuilder(cwd, options.builder as Exclude), vue: getPkgVersion(cwd, 'vue', { via: ['nuxt'] }) || null, diff --git a/packages/nuxt-cli/src/utils/build-progress.ts b/packages/nuxt-cli/src/utils/build-progress.ts index c4575e3b6..09c9244bb 100644 --- a/packages/nuxt-cli/src/utils/build-progress.ts +++ b/packages/nuxt-cli/src/utils/build-progress.ts @@ -1,16 +1,13 @@ -import type { PhaseTiming, ProgressSnapshot } from './progress-snapshot' +import type { Phase, ProgressSnapshot } from './progress-snapshot' -interface BuildPhase { - id: string - message: string -} +import { PhaseTimeline } from './progress-snapshot' /** * Build phases, in the order they are reached. A phase is never re-entered, so * the index only ever moves forward and the breakdown printed at the end lists * each phase once. */ -const BUILD_PHASES: readonly BuildPhase[] = [ +const BUILD_PHASES: readonly Phase[] = [ { id: 'config', message: 'Loading Nuxt config' }, { id: 'modules', message: 'Setting up modules' }, { id: 'app', message: 'Preparing app' }, @@ -57,13 +54,8 @@ interface NuxtLike { * `nuxt dev` shows can report `nuxt build`, and the completion summary can list * where the time went. */ -export class BuildProgress { - #listeners = new Set<(snapshot: ProgressSnapshot) => void>() - #index = 0 +export class BuildProgress extends PhaseTimeline { #message = BUILD_PHASES[0]!.message - #startedAt = Date.now() - #phaseStartedAt = Date.now() - #timings: PhaseTiming[] = [] #finished = false /** * With the Vite environment API a single build covers both environments, so @@ -72,49 +64,25 @@ export class BuildProgress { */ #splitBundles = true - get snapshot(): ProgressSnapshot { - const phase = BUILD_PHASES[this.#index]! - return { - status: 'loading', - phase: phase.id, - message: this.#message, - index: this.#index, - total: BUILD_PHASES.length - 1, - progress: this.#index / (BUILD_PHASES.length - 1), - elapsed: Date.now() - this.#startedAt, - phaseElapsed: Date.now() - this.#phaseStartedAt, - reload: false, - serving: true, - timings: this.#timings, - } + constructor() { + super(BUILD_PHASES) } - get timings(): PhaseTiming[] { - return this.#timings + override get snapshot(): ProgressSnapshot { + return { ...super.snapshot, message: this.#message } } - onUpdate(listener: (snapshot: ProgressSnapshot) => void): () => void { - this.#listeners.add(listener) + override onUpdate(listener: (snapshot: ProgressSnapshot) => void): () => void { + const off = super.onUpdate(listener) listener(this.snapshot) - return () => this.#listeners.delete(listener) + return off } setPhase(id: string, message?: string): void { - const index = BUILD_PHASES.findIndex(phase => phase.id === id) - if (this.#finished || index === -1 || index < this.#index) { + if (this.#finished || this.enter(id) === undefined) { return } - if (index > this.#index) { - const previous = BUILD_PHASES[this.#index]! - this.#timings.push({ - phase: previous.id, - message: previous.message, - duration: Date.now() - this.#phaseStartedAt, - }) - this.#phaseStartedAt = Date.now() - this.#index = index - } - this.setMessage(message || BUILD_PHASES[index]!.message) + this.setMessage(message || BUILD_PHASES[this.index]!.message) } setMessage(message: string): void { @@ -122,7 +90,7 @@ export class BuildProgress { return } this.#message = message - this.#emit() + this.emit() } /** @@ -131,16 +99,10 @@ export class BuildProgress { * what is said about it finishing. */ finish(): void { - if (this.#finished) { - return + if (!this.#finished) { + this.closePhase() + this.#finished = true } - const phase = BUILD_PHASES[this.#index]! - this.#timings.push({ - phase: phase.id, - message: phase.message, - duration: Date.now() - this.#phaseStartedAt, - }) - this.#finished = true } /** @@ -173,7 +135,7 @@ export class BuildProgress { hooks?.beforeEach?.(({ name, args }) => { // Nitro's hooks also run while Nuxt is still bundling the app, and those // are not what the build is waiting on. - if (BUILD_PHASES[this.#index]!.id !== 'nitro') { + if (BUILD_PHASES[this.index]!.id !== 'nitro') { return } const message = NITRO_MESSAGES[name] @@ -188,11 +150,4 @@ export class BuildProgress { } }) } - - #emit(): void { - const snapshot = this.snapshot - for (const listener of this.#listeners) { - listener(snapshot) - } - } } diff --git a/packages/nuxt-cli/src/utils/catalog.ts b/packages/nuxt-cli/src/utils/catalog.ts index 01c179045..c6c7a8f1e 100644 --- a/packages/nuxt-cli/src/utils/catalog.ts +++ b/packages/nuxt-cli/src/utils/catalog.ts @@ -16,11 +16,7 @@ export interface CatalogEntry { specifier?: string } -export interface CatalogConfig { - /** Absolute path of the `pnpm-workspace.yaml` the catalogs are declared in. */ - filePath: string - catalogs: Record> -} +type Catalogs = Record> /** * The catalog a `catalog:` / `catalog:name` specifier refers to, or `undefined` @@ -47,7 +43,7 @@ export function findPnpmWorkspaceYaml(cwd: string): string | undefined { } } -const configCache = new Map() +const configCache = new Map() /** Discard memoised catalog configuration, so a later read sees changes on disk. */ export function clearCatalogCache(): void { @@ -61,7 +57,7 @@ export function clearCatalogCache(): void { * Results are memoised per workspace file, as commands such as `nuxi info` query * dozens of dependencies in a row. */ -export function readCatalogConfig(cwd: string): CatalogConfig | undefined { +export function readCatalogConfig(cwd: string): Catalogs | undefined { const filePath = findPnpmWorkspaceYaml(cwd) if (!filePath) { return undefined @@ -81,7 +77,7 @@ interface WorkspaceYaml { catalogs?: Record> } -function parseCatalogConfig(filePath: string): CatalogConfig | undefined { +function parseCatalogConfig(filePath: string): Catalogs | undefined { let json: WorkspaceYaml try { json = parseYAML(readFileSync(filePath, 'utf-8')) || {} @@ -90,16 +86,12 @@ function parseCatalogConfig(filePath: string): CatalogConfig | undefined { return undefined } - const catalogs: CatalogConfig['catalogs'] = { ...json.catalogs } + const catalogs: Catalogs = { ...json.catalogs } if (json.catalog) { catalogs[DEFAULT_CATALOG] = json.catalog } - if (Object.keys(catalogs).length === 0) { - return undefined - } - - return { filePath, catalogs } + return Object.keys(catalogs).length > 0 ? catalogs : undefined } /** @@ -115,7 +107,7 @@ export function resolveCatalogEntry(cwd: string, pkgJson: PackageJson | null | u } const config = readCatalogConfig(cwd) - return { catalog, specifier: config?.catalogs[catalog]?.[pkg] } + return { catalog, specifier: config?.[catalog]?.[pkg] } } /** The outcome of a {@link updateCatalogEntries} call. */ diff --git a/packages/nuxt-cli/src/utils/console.ts b/packages/nuxt-cli/src/utils/console.ts index 1f3390e67..522a0a417 100644 --- a/packages/nuxt-cli/src/utils/console.ts +++ b/packages/nuxt-cli/src/utils/console.ts @@ -13,7 +13,6 @@ import { withStartupClockPaused } from './startup-clock' import { isInteractiveSession, trackOutputSpacing } from './stdout' import { useTerminalHost } from './terminal-host' -// TODO: Use better API from consola for intercepting logs function wrapReporter(reporter: ConsolaReporter) { return ({ log(logObj, ctx) { @@ -22,8 +21,7 @@ function wrapReporter(reporter: ConsolaReporter) { } const msg = logObj.args[0] if (typeof msg === 'string' && !process.env.DEBUG) { - // TODO: resolve upstream in Vite - // Hide sourcemap warnings related to node_modules + // Vite warns about sourcemaps in third-party packages, which users cannot fix. if (msg.startsWith('Sourcemap') && msg.includes('node_modules')) { return } @@ -148,6 +146,10 @@ export async function withDirectStdout(fn: () => T | Promise): Promise } } +export function printJson(value: unknown): Promise { + return withDirectStdout(() => process.stdout.write(`${JSON.stringify(value, null, 2)}\n`)) +} + type PromptFn = NonNullable /** Resolves once the prompt currently on screen, if any, has been answered. */ diff --git a/packages/nuxt-cli/src/utils/formatting.ts b/packages/nuxt-cli/src/utils/formatting.ts index 87a3fdb47..bd57c14b1 100644 --- a/packages/nuxt-cli/src/utils/formatting.ts +++ b/packages/nuxt-cli/src/utils/formatting.ts @@ -1,45 +1,133 @@ import process from 'node:process' -import { stripVTControlCharacters, styleText } from 'node:util' +import { styleText } from 'node:util' -const AT_MENTION_RE = /\b@([^, ]+)/g -const BACKTICK_RE = /`([^`]*)`/g +import { supportsHyperlinks } from 'clickable-path' -function getStringWidth(str: string): number { - const stripped = stripVTControlCharacters(str) - let width = 0 +/** Render `label` as an OSC 8 link to `url`, where `clickable-path` reports support. */ +export function terminalLink(label: string, url: string, options: { stream?: { isTTY?: boolean } } = {}): string { + if (!supportsHyperlinks(options.stream ?? process.stdout)) { + return label + } + return `\u001B]8;;${url}\u0007${label}\u001B]8;;\u0007` +} + +// eslint-disable-next-line no-control-regex +const ANSI_RE = /\u001B\[[0-9;]*m|\u001B\]8;[^\u0007]*\u0007/g +// eslint-disable-next-line no-control-regex +const NARROW_RE = /^[\u0000-\u02FF\u2010-\u2027\u2500-\u257F\u2800-\u28FF]*$/ +// `\p{}` literals are compiled at parse time, so these are built on first use. +let widthPatterns: { zero: RegExp, wide: RegExp } | undefined + +function getWidthPatterns(): { zero: RegExp, wide: RegExp } { + return widthPatterns ??= { + // eslint-disable-next-line prefer-regex-literals + zero: new RegExp(String.raw`[\p{Mn}\p{Me}\u200B-\u200F]`, 'u'), + // eslint-disable-next-line prefer-regex-literals + wide: new RegExp(String.raw`[\p{Emoji_Presentation}\u1100-\u115F\u2E80-\u303E\u3041-\u33FF\u3400-\u4DBF\u4E00-\u9FFF\uA000-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6\u{20000}-\u{3FFFD}]`, 'u'), + } +} + +/** Strip colour and hyperlink escapes. */ +export function stripAnsi(text: string): string { + return text.replace(ANSI_RE, '') +} +function charWidth(char: string): number { + const { zero, wide } = getWidthPatterns() + if (zero.test(char)) { + return 0 + } + return wide.test(char) ? 2 : 1 +} + +/** Terminal columns `text` occupies. */ +export function visibleWidth(text: string): number { + const stripped = stripAnsi(text) + if (NARROW_RE.test(stripped)) { + return stripped.length + } + let width = 0 for (const char of stripped) { - const code = char.codePointAt(0) - if (!code) { - continue - } + width += charWidth(char) + } + return width +} - // Variation selectors don't add width - if (code >= 0xFE00 && code <= 0xFE0F) { - continue - } +const LINK_TERMINATOR = '\u001B]8;;\u0007' + +/** Cut `text` to `columns` with an ellipsis, closing any styling or link it cuts through. */ +export function truncate(text: string, columns: number): string { + if (columns <= 0) { + return '' + } + if (visibleWidth(text) <= columns) { + return text + } - // Emoji and wide characters (simplified heuristic) - // Most emojis are in these ranges - if ( - (code >= 0x1F300 && code <= 0x1F9FF) // Emoticons, symbols, pictographs - || (code >= 0x1F600 && code <= 0x1F64F) // Emoticons - || (code >= 0x1F680 && code <= 0x1F6FF) // Transport and map symbols - || (code >= 0x2600 && code <= 0x26FF) // Miscellaneous symbols (includes ❤) - || (code >= 0x2700 && code <= 0x27BF) // Dingbats - || (code >= 0x1F900 && code <= 0x1F9FF) // Supplemental symbols and pictographs - || (code >= 0x1FA70 && code <= 0x1FAFF) // Symbols and Pictographs Extended-A - ) { - width += 2 + const limit = columns - 1 + let visible = 0 + let index = 0 + let styled = false + let linked = false + ANSI_RE.lastIndex = 0 + while (index < text.length && visible < limit) { + ANSI_RE.lastIndex = index + const match = ANSI_RE.exec(text) + if (match?.index === index) { + if (match[0].startsWith('\u001B]8;')) { + linked = match[0] !== LINK_TERMINATOR + } + else { + styled = true + } + index += match[0].length + continue } - else { - width += 1 + const code = text.codePointAt(index)! + const char = code < 0x300 ? text[index]! : String.fromCodePoint(code) + const width = code < 0x300 ? 1 : charWidth(char) + if (visible + width > limit) { + break } + index += char.length + visible += width } + return `${text.slice(0, index)}\u2026${linked ? LINK_TERMINATOR : ''}${styled ? '\u001B[0m' : ''}` +} - return width +/** Lower-case a leading capital, leaving acronyms alone. */ +export function decapitalise(text: string): string { + return /^[A-Z][a-z]/.test(text) ? text[0]!.toLowerCase() + text.slice(1) : text +} + +/** Format milliseconds with fixed precision per unit (`4.2ms`, `420ms`, `4.20s`, `42.0s`, `4m 02s`, `1h 04m 02s`). */ +export function formatDuration(ms: number): string { + ms = Math.max(0, ms) + if (ms < 10) { + return `${ms.toFixed(1)}ms` + } + if (ms < 1000) { + return `${Math.round(ms)}ms` + } + if (ms < 10_000) { + return `${(Math.floor(ms / 10) / 100).toFixed(2)}s` + } + if (ms < 60_000) { + return `${(Math.floor(ms / 100) / 10).toFixed(1)}s` + } + + const total = Math.floor(ms / 1000) + const hours = Math.floor(total / 3600) + const minutes = Math.floor((total % 3600) / 60) + const seconds = String(total % 60).padStart(2, '0') + return hours + ? `${hours}h ${String(minutes).padStart(2, '0')}m ${seconds}s` + : `${minutes}m ${seconds}s` } +const AT_MENTION_RE = /\b@([^, ]+)/g +const BACKTICK_RE = /`([^`]*)`/g + export function formatInfoBox(infoObj: Record): string { let firstColumnLength = 0 let ansiFirstColumnLength = 0 @@ -67,7 +155,7 @@ export function formatInfoBox(infoObj: Record): stri let currentLine = '' for (const word of words) { - const wordLength = getStringWidth(word) + const wordLength = visibleWidth(word) const spaceLength = currentLine ? 1 : 0 if (boxRowLength + wordLength + spaceLength > terminalWidth) { @@ -93,22 +181,3 @@ export function formatInfoBox(infoObj: Record): stri return boxStr } - -/** - * Format an elapsed duration in milliseconds for display, using `ms` below a - * second and seconds (or minutes and seconds) above it. - */ -export function formatDuration(ms: number): string { - if (ms < 1000) { - return `${Math.round(ms)}ms` - } - - const totalSeconds = ms / 1000 - if (totalSeconds < 60) { - return `${Number(totalSeconds.toFixed(totalSeconds < 10 ? 2 : 1))}s` - } - - const minutes = Math.floor(totalSeconds / 60) - const seconds = Math.round(totalSeconds - minutes * 60) - return seconds ? `${minutes}m ${seconds}s` : `${minutes}m` -} diff --git a/packages/nuxt-cli/src/utils/headless.ts b/packages/nuxt-cli/src/utils/headless.ts index f830baf4b..166c9daee 100644 --- a/packages/nuxt-cli/src/utils/headless.ts +++ b/packages/nuxt-cli/src/utils/headless.ts @@ -8,12 +8,9 @@ import { basename } from 'pathe' import { isWindows } from 'std-env' import { debug } from './logger' +import { quoteArgument } from './shell-quote' const BIN_EXTENSION_RE = /\.[cm]?js$/ -const NEEDS_QUOTING_RE = /[\s"'$`\\]/ -const SINGLE_QUOTE_RE = /'/g -const BACKSLASHES_BEFORE_QUOTE_RE = /(\\*)"/g -const TRAILING_BACKSLASHES_RE = /(\\*)$/ const CREATE_BIN_RE = /^create-nuxt(?:-app)?$/ // `@latest` everywhere: package managers happily reuse a cached `create-nuxt`, @@ -120,28 +117,6 @@ function getContinuation(windows: boolean, env: NodeJS.ProcessEnv): string { return env.PSModulePath ? '`' : '^' } -/** - * Quote a value so the shell passes it through unchanged. POSIX shells still - * expand `$` and backticks inside double quotes, so single quotes are used - * there; cmd.exe and PowerShell have no single-quoted form in common. - * - * Windows argument parsing only treats a backslash as an escape when a quote - * follows it, so each run of backslashes is doubled in exactly those two places - * it would otherwise escape the quote we are adding. - */ -function quoteArgument(value: string, windows: boolean): string { - if (!NEEDS_QUOTING_RE.test(value)) { - return value - } - if (!windows) { - return `'${value.replace(SINGLE_QUOTE_RE, `'\\''`)}'` - } - const escaped = value - .replace(BACKSLASHES_BEFORE_QUOTE_RE, '$1$1\\"') - .replace(TRAILING_BACKSLASHES_RE, '$1$1') - return `"${escaped}"` -} - export interface HeadlessCommandOptions { prefix?: string dir: string diff --git a/packages/nuxt-cli/src/utils/install.ts b/packages/nuxt-cli/src/utils/install.ts index 1a0401db0..abb6cdb5d 100644 --- a/packages/nuxt-cli/src/utils/install.ts +++ b/packages/nuxt-cli/src/utils/install.ts @@ -142,6 +142,10 @@ export interface InstallLog { finish: (result: InstallResult) => void } +export function isVerboseInstall(logLevel?: string): boolean { + return logLevel === 'verbose' || Boolean(process.env.DEBUG) +} + /** * Collects a package manager's output and prints it once the install is over: on * failure, or on success when `verbose` is set. It is printed as plain gutter diff --git a/packages/nuxt-cli/src/utils/lockfile.ts b/packages/nuxt-cli/src/utils/lockfile.ts index 628d43626..84f8073eb 100644 --- a/packages/nuxt-cli/src/utils/lockfile.ts +++ b/packages/nuxt-cli/src/utils/lockfile.ts @@ -55,7 +55,11 @@ export function isProcessAlive(pid: number): boolean { /** Read the lock held for `buildDir`, if there is one. */ export function readLock(buildDir: string): LockInfo | undefined { - return readLockFile(join(buildDir, LOCK_FILENAME)) + return readLockFile(lockPathFor(buildDir)) +} + +function lockPathFor(buildDir: string): string { + return join(buildDir, LOCK_FILENAME) } /** The lock on `buildDir` when another process is currently holding it. */ @@ -73,14 +77,11 @@ export function readActiveLock(buildDir: string): LockInfo | undefined { * The lock is re-read and matched on identity, so one that has been replaced * since the caller inspected it is left alone. */ -export function clearStaleLock(buildDir: string, info: LockInfo): boolean { - const lockPath = join(buildDir, LOCK_FILENAME) - const current = readLockFile(lockPath) - if (!current || current.pid !== info.pid || current.startedAt !== info.startedAt) { - return false +export function clearStaleLock(buildDir: string, info: LockInfo): void { + const current = readLock(buildDir) + if (current?.pid === info.pid && current.startedAt === info.startedAt) { + tryUnlink(lockPathFor(buildDir)) } - tryUnlink(lockPath) - return true } /** @@ -89,12 +90,10 @@ export function clearStaleLock(buildDir: string, info: LockInfo): boolean { * process, and never creates one. */ export function markTakenOver(buildDir: string, byPid: number): void { - const lockPath = join(buildDir, LOCK_FILENAME) - const current = readLockFile(lockPath) - if (!current || current.pid === byPid) { - return + const current = readLock(buildDir) + if (current && current.pid !== byPid) { + writeLockFile(lockPathFor(buildDir), { ...current, takenOverBy: byPid }) } - writeLockFile(lockPath, { ...current, takenOverBy: byPid }) } /** @@ -102,13 +101,10 @@ export function markTakenOver(buildDir: string, byPid: number): void { * process's claim is left alone. */ export function clearTakeover(buildDir: string, byPid: number): void { - const lockPath = join(buildDir, LOCK_FILENAME) - const current = readLockFile(lockPath) - if (!current || current.takenOverBy !== byPid) { - return + const current = readLock(buildDir) + if (current?.takenOverBy === byPid) { + writeLockFile(lockPathFor(buildDir), { ...current, takenOverBy: undefined }) } - const { takenOverBy: _claim, ...rest } = current - writeLockFile(lockPath, rest) } /** PID that claimed our own lock, if this process is being taken over. */ @@ -233,16 +229,9 @@ function tryUnlink(lockPath: string): void { } function isLockActive(info: LockInfo): boolean { - if (info.pid === process.pid) { - return false - } - if (!isProcessAlive(info.pid)) { - return false - } - if (Date.now() - info.startedAt > MAX_LOCK_AGE_MS) { - return false - } - return true + return info.pid !== process.pid + && Date.now() - info.startedAt <= MAX_LOCK_AGE_MS + && isProcessAlive(info.pid) } /** @@ -251,14 +240,8 @@ function isLockActive(info: LockInfo): boolean { * and `NUXT_LOCK=0` opt out. */ export function isLockEnabled(): boolean { - if (isEnvFlagSet(process.env.NUXT_IGNORE_LOCK)) { - return false - } - return process.env.NUXT_LOCK !== '0' && process.env.NUXT_LOCK !== 'false' -} - -function isEnvFlagSet(value: string | undefined): boolean { - return !!value && value !== '0' && value !== 'false' + const { NUXT_IGNORE_LOCK: ignore, NUXT_LOCK: lock } = process.env + return (!ignore || ignore === '0' || ignore === 'false') && lock !== '0' && lock !== 'false' } type LockResult @@ -280,7 +263,7 @@ export function acquireLock( info: Omit, options: AcquireLockOptions = {}, ): LockResult { - return acquireLockAt(join(buildDir, LOCK_FILENAME), buildDir, info, options) + return acquireLockAt(lockPathFor(buildDir), buildDir, info, options) } /** @@ -367,9 +350,7 @@ export function updateLock( if (!isLockEnabled()) { return } - const lockPath = join(buildDir, LOCK_FILENAME) - const current = readLockFile(lockPath) - // Only overwrite our own lock; never touch another process's file. + const current = readLock(buildDir) if (current && current.pid !== process.pid) { return } @@ -380,7 +361,7 @@ export function updateLock( takenOverBy: current?.takenOverBy, ...info, } - writeLockFile(lockPath, next) + writeLockFile(lockPathFor(buildDir), next) } function makeRelease(lockPath: string): () => void { @@ -398,10 +379,6 @@ function makeRelease(lockPath: string): () => void { } } - // `exit` fires on normal termination, including after Node's default signal - // handling (SIGINT → exit 130) when no custom signal handler runs. We - // deliberately do not install SIGINT/SIGTERM listeners: that would suppress - // Node's default signal behavior and other shutdown logic. process.on('exit', release) return release diff --git a/packages/nuxt-cli/src/utils/network.ts b/packages/nuxt-cli/src/utils/network.ts index 8e1f1f98b..5b8ef55ce 100644 --- a/packages/nuxt-cli/src/utils/network.ts +++ b/packages/nuxt-cli/src/utils/network.ts @@ -6,6 +6,7 @@ import { basename } from 'pathe' import { isWindows } from 'std-env' import { logger } from './logger' +import { quoteArgument } from './shell-quote' const PROXY_ENV_VARS = [ 'HTTP_PROXY', @@ -24,34 +25,18 @@ export function hasProxyEnv(env: NodeJS.ProcessEnv = process.env): boolean { // (`--require=/tmp/--use-env-proxy.js`) is not mistaken for it being enabled. const USE_ENV_PROXY_RE = /(?:^|\s)--use-env-proxy(?:$|[\s=])/ -/** The flags the running Node.js accepts, i.e. `process.allowedNodeEnvironmentFlags`. */ -export interface NodeFlags { - has: (flag: string) => boolean -} - -/** - * Whether the current Node.js can route `fetch`/`http` through `HTTP_PROXY`, - * `HTTPS_PROXY` and `NO_PROXY` itself. - */ -export function supportsEnvProxy(flags: NodeFlags | undefined = process.allowedNodeEnvironmentFlags): boolean { - return flags?.has('--use-env-proxy') ?? false -} - /** * Whether the current process routes requests through the proxy environment * variables. Node.js resolves this during bootstrap, so it cannot be turned on * from within the process. */ -export function isEnvProxyActive(env: NodeJS.ProcessEnv = process.env, execArgv: string[] = process.execArgv, flags?: NodeFlags): boolean { - if (!supportsEnvProxy(flags)) { - return false - } +export function isEnvProxyActive(env: NodeJS.ProcessEnv = process.env, execArgv: string[] = process.execArgv): boolean { return env.NODE_USE_ENV_PROXY === '1' || execArgv.includes('--use-env-proxy') || USE_ENV_PROXY_RE.test(env.NODE_OPTIONS || '') } -export type ProxySetupResult = 'unused' | 'active' | 'children-only' | 'unsupported' +export type ProxySetupResult = 'unused' | 'active' | 'children-only' type EnableGlobalProxy = (env: NodeJS.ProcessEnv) => unknown @@ -64,19 +49,14 @@ let proxyHintShown = false * Route requests from this process and its children through the proxy * environment variables, and record whether this process is proxy-aware. */ -export function setupProxySupport(env: NodeJS.ProcessEnv = process.env, flags?: NodeFlags, enableGlobalProxy: EnableGlobalProxy | null = setGlobalProxyFromEnv ?? null): ProxySetupResult { +export function setupProxySupport(env: NodeJS.ProcessEnv = process.env, enableGlobalProxy: EnableGlobalProxy | null = setGlobalProxyFromEnv ?? null): ProxySetupResult { proxyHintShown = false if (!hasProxyEnv(env)) { envProxyActive = undefined return 'unused' } - if (!supportsEnvProxy(flags)) { - envProxyActive = false - return 'unsupported' - } - - envProxyActive = isEnvProxyActive(env, process.execArgv, flags) + envProxyActive = isEnvProxyActive(env) if (!envProxyActive && enableGlobalProxy && env.NODE_USE_ENV_PROXY !== '0') { enableGlobalProxy(env) envProxyActive = true @@ -88,19 +68,18 @@ export function setupProxySupport(env: NodeJS.ProcessEnv = process.env, flags?: const BIN_NAMES = new Set(['nuxi', 'nuxi-ng', 'nuxt', 'nuxt-cli']) const BIN_EXTENSION_RE = /\.[cm]?js$/ -const NEEDS_QUOTING_RE = /[\s"'$`]/ /** * The command the user typed, if it can be reconstructed. Returns `undefined` * when the CLI was reached indirectly (`npm create nuxt`, `npx`, programmatic * usage), where echoing `argv` back would suggest a command that does not exist. */ -function getCurrentCommand(argv: string[] = process.argv): string | undefined { +function getCurrentCommand(argv: string[], windows: boolean): string | undefined { const entry = argv[1] if (!entry || !BIN_NAMES.has(basename(entry).replace(BIN_EXTENSION_RE, ''))) { return } - const args = argv.slice(2).map(arg => NEEDS_QUOTING_RE.test(arg) ? JSON.stringify(arg) : arg) + const args = argv.slice(2).map(arg => quoteArgument(arg, windows)) return ['nuxt', ...args].join(' ') } @@ -108,7 +87,6 @@ export interface CommandContext { argv?: string[] env?: NodeJS.ProcessEnv windows?: boolean - flags?: NodeFlags } /** @@ -118,7 +96,7 @@ export interface CommandContext { */ export function formatRetryCommand(vars: Record, ctx: CommandContext = {}): string { const { argv = process.argv, env = process.env, windows = isWindows } = ctx - const command = getCurrentCommand(argv) + const command = getCurrentCommand(argv, windows) const entries = Object.entries(vars) if (windows) { @@ -300,7 +278,7 @@ export function describeNetworkError(err: unknown, url?: string): string { */ export function getProxyHint(kind: NetworkFailureKind = 'unknown', ctx: CommandContext = {}): string | undefined { const env = ctx.env ?? process.env - const proxyInUse = () => envProxyActive ?? isEnvProxyActive(env, process.execArgv, ctx.flags) + const proxyInUse = () => envProxyActive ?? isEnvProxyActive(env) // A server that answered is normally not a proxy problem, unless a proxy is // configured and being bypassed (a blocked egress often answers 403). @@ -320,10 +298,6 @@ export function getProxyHint(kind: NetworkFailureKind = 'unknown', ctx: CommandC return `If you are behind a proxy, set ${styleText('cyan', 'HTTPS_PROXY')} and ${styleText('cyan', 'NODE_USE_ENV_PROXY=1')} (plus ${styleText('cyan', 'NO_PROXY')} for internal hosts).` } - if (!supportsEnvProxy(ctx.flags)) { - return `A proxy is configured but this version of Node.js cannot use it; upgrade to Node.js 24 (or 22.18+) to enable ${styleText('cyan', 'NODE_USE_ENV_PROXY')}.` - } - if (!proxyInUse()) { return `A proxy is configured but Node.js only reads it at startup. Retry with ${styleText('cyan', formatRetryCommand({ NODE_USE_ENV_PROXY: '1' }, ctx))}` } diff --git a/packages/nuxt-cli/src/utils/nitro.ts b/packages/nuxt-cli/src/utils/nitro.ts index 2812f4fe7..6b5c6d0ce 100644 --- a/packages/nuxt-cli/src/utils/nitro.ts +++ b/packages/nuxt-cli/src/utils/nitro.ts @@ -1,5 +1,6 @@ import type { PackageJson } from 'pkg-types' +import { NUXT_PACKAGES } from './nuxt-packages' import { getPkgJSON, getPkgVersion } from './pkg' /** @@ -19,14 +20,6 @@ const NITRO_PKGS = ['nitro', 'nitropack'] */ const NITRO_SERVER_PKG = '@nuxt/nitro-server' -/** - * Packages whose declared Nitro dependency is authoritative. - * - * Nightly releases are usually aliased onto `nuxt`, so `nuxt-nightly` only - * covers an install under its own name. - */ -const NUXT_PKGS = ['nuxt', 'nuxt-nightly'] - interface NitroCandidate { /** The name Nitro is installed under. */ name: string @@ -54,7 +47,7 @@ function getNitroCandidates(cwd: string): NitroCandidate[] { function getNuxtNitroDependency(cwd: string): NitroCandidate | undefined { const readManifest = (name: string, via?: string[]) => getPkgJSON(cwd, name, { via, strict: true }) - for (const owner of NUXT_PKGS) { + for (const owner of NUXT_PACKAGES) { const manifest = readManifest(owner) if (!manifest) { continue diff --git a/packages/nuxt-cli/src/utils/nuxt-packages.ts b/packages/nuxt-cli/src/utils/nuxt-packages.ts new file mode 100644 index 000000000..0123d78da --- /dev/null +++ b/packages/nuxt-cli/src/utils/nuxt-packages.ts @@ -0,0 +1,2 @@ +/** Package names Nuxt is installed under. */ +export const NUXT_PACKAGES = ['nuxt', 'nuxt-nightly'] as const diff --git a/packages/nuxt-cli/src/utils/package-managers.ts b/packages/nuxt-cli/src/utils/package-managers.ts index a3dab4e3a..bcabf313f 100644 --- a/packages/nuxt-cli/src/utils/package-managers.ts +++ b/packages/nuxt-cli/src/utils/package-managers.ts @@ -1,5 +1,5 @@ import type { AgentName, DetectResult } from 'package-manager-detector' -import { execFileSync } from 'node:child_process' +import { execFile } from 'node:child_process' import process from 'node:process' import { LOCKS } from 'package-manager-detector/constants' @@ -32,13 +32,13 @@ export async function detectPackageManager(cwd: string, { includeParentDirs = tr return await detect({ cwd, stopDir: includeParentDirs ? undefined : cwd }).catch(() => null) ?? undefined } -export function getPackageManagerVersion(command: string) { +export function getPackageManagerVersion(command: string): Promise { // Package managers are `.cmd` shims on Windows, which cannot be spawned without a shell. const isWindows = process.platform === 'win32' - try { - return execFileSync(isWindows ? `"${command}"` : command, ['--version'], { shell: isWindows, stdio: ['ignore', 'pipe', 'ignore'] }).toString('utf8').trim() - } - catch { - return 'unknown' - } + return new Promise((resolve) => { + const child = execFile(isWindows ? `"${command}"` : command, ['--version'], { shell: isWindows }, (error, stdout) => { + resolve(error ? 'unknown' : stdout.trim()) + }) + child.stdin?.end() + }) } diff --git a/packages/nuxt-cli/src/utils/phase-reporter.ts b/packages/nuxt-cli/src/utils/phase-reporter.ts index 812b72bb2..59583fb7e 100644 --- a/packages/nuxt-cli/src/utils/phase-reporter.ts +++ b/packages/nuxt-cli/src/utils/phase-reporter.ts @@ -5,11 +5,10 @@ import { styleText } from 'node:util' import { isCI } from 'std-env' -import { formatDuration } from './formatting' +import { decapitalise, formatDuration, terminalLink } from './formatting' import { logger } from './logger' import { READY_MESSAGE } from './progress-snapshot' import { tapOutput } from './stdout' -import { terminalLink } from './terminal-link' const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] const FRAME_INTERVAL = 80 @@ -29,14 +28,6 @@ const SHOW_CURSOR = '\u001B[?25h' * A ticking elapsed time, in tenths of a second. Anything finer changes the * line on every frame, which is the whole cost the frame-only repaint avoids. */ -function formatTicking(ms: number): string { - if (ms < 60_000) { - return `${(ms / 1000).toFixed(1)}s` - } - const minutes = Math.floor(ms / 60_000) - return `${minutes}m ${Math.floor((ms - minutes * 60_000) / 1000)}s` -} - /** * How long the phase in flight has taken, and how long the command has been * running, where those have parted company. A phase can hold a command for most @@ -46,8 +37,8 @@ function formatElapsed(snapshot: ProgressSnapshot, drift: number): string { const total = snapshot.elapsed + drift const phase = snapshot.phaseElapsed + drift return total - phase >= PHASE_ELAPSED_THRESHOLD - ? `${formatTicking(phase)} \u00B7 ${formatTicking(total)}` - : formatTicking(total) + ? `${formatDuration(phase)} \u00B7 ${formatDuration(total)}` + : formatDuration(total) } export interface PhaseReporter { @@ -89,10 +80,6 @@ function formatURL(url: string): string { return ` ${styleText('dim', '\u2192')} ${styleText('cyan', terminalLink(url, url))}` } -function decapitalise(text: string): string { - return /^[A-Z][a-z]/.test(text) ? text[0]!.toLowerCase() + text.slice(1) : text -} - /** * The line a long-running command leaves behind once it is up. A command that * only builds never reaches `ready` and reports its own completion instead. @@ -173,7 +160,7 @@ export function createPhaseReporter(options: PhaseReporterOptions = {}): PhaseRe function describe(): string { const pending = summarised ? snapshot!.pending : undefined return pending - ? `rendering ${pending.label} ${styleText('dim', formatTicking(Date.now() - pending.startedAt))}` + ? `rendering ${pending.label} ${styleText('dim', formatDuration(Date.now() - pending.startedAt))}` : `${snapshot!.message} ${styleText('dim', formatElapsed(snapshot!, Date.now() - receivedAt))}` } @@ -234,7 +221,7 @@ export function createPhaseReporter(options: PhaseReporterOptions = {}): PhaseRe /** A render, as its own line, for output that cannot redraw one in place. */ function announce(label: string, elapsed?: number): string { - return `Rendering ${label}${elapsed === undefined ? '' : ` ${styleText('dim', `(${formatTicking(elapsed)})`)}`}` + return `Rendering ${label}${elapsed === undefined ? '' : ` ${styleText('dim', `(${formatDuration(elapsed)})`)}`}` } /** Take the line down, leaving the terminal as it was found. */ diff --git a/packages/nuxt-cli/src/utils/pkg.ts b/packages/nuxt-cli/src/utils/pkg.ts index 532f6254c..fb1895110 100644 --- a/packages/nuxt-cli/src/utils/pkg.ts +++ b/packages/nuxt-cli/src/utils/pkg.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs' import { resolveModulePath } from 'exsolve' +import { NUXT_PACKAGES } from './nuxt-packages' import { tryResolveNuxt } from './resolve-nuxt' export function getPkgVersion(cwd: string, pkg: string, options?: PkgJSONOptions) { @@ -64,3 +65,14 @@ export function getPkgJSON(cwd: string, pkg: string, options?: PkgJSONOptions) { return null } + +/** The installed Nuxt version in `cwd`. */ +export function getNuxtPkgVersion(cwd: string): string { + for (const name of NUXT_PACKAGES) { + const version = getPkgVersion(cwd, name) + if (version) { + return version + } + } + return '' +} diff --git a/packages/nuxt-cli/src/utils/progress-snapshot.ts b/packages/nuxt-cli/src/utils/progress-snapshot.ts index 5b3ec9c4d..55af2bec3 100644 --- a/packages/nuxt-cli/src/utils/progress-snapshot.ts +++ b/packages/nuxt-cli/src/utils/progress-snapshot.ts @@ -56,3 +56,79 @@ export interface ProgressSnapshot { timings: PhaseTiming[] error?: { name: string, message: string } } + +export interface Phase { + id: string + message: string +} + +/** Forward-only sequence of phases, timing each as it is left. */ +export class PhaseTimeline { + timings: PhaseTiming[] = [] + protected index: number = 0 + protected startedAt: number = Date.now() + protected phaseStartedAt: number = this.startedAt + protected readonly phases: readonly Phase[] + #listeners = new Set<(snapshot: ProgressSnapshot) => void>() + + constructor(phases: readonly Phase[]) { + this.phases = phases + } + + get snapshot(): ProgressSnapshot { + const now = Date.now() + return { + status: 'loading', + phase: this.phases[this.index]!.id, + message: this.phases[this.index]!.message, + index: this.index, + total: this.phases.length - 1, + progress: this.index / (this.phases.length - 1), + elapsed: now - this.startedAt, + phaseElapsed: now - this.phaseStartedAt, + reload: false, + serving: true, + timings: this.timings, + } + } + + onUpdate(listener: (snapshot: ProgressSnapshot) => void): () => void { + this.#listeners.add(listener) + return () => this.#listeners.delete(listener) + } + + protected restart(): void { + this.index = 0 + this.timings = [] + this.startedAt = Date.now() + this.phaseStartedAt = this.startedAt + } + + protected closePhase(): void { + const phase = this.phases[this.index]! + this.timings.push({ phase: phase.id, message: phase.message, duration: Date.now() - this.phaseStartedAt }) + this.phaseStartedAt = Date.now() + } + + /** Enter phase `id`; `undefined` if unknown or already passed. */ + protected enter(id: string): boolean | undefined { + const index = this.phases.findIndex(phase => phase.id === id) + if (index === -1 || index < this.index) { + return undefined + } + if (index === this.index) { + return false + } + this.closePhase() + this.index = index + return true + } + + protected emit(): ProgressSnapshot { + const snapshot = this.snapshot + for (const listener of this.#listeners) { + listener(snapshot) + } + return snapshot + } +} diff --git a/packages/nuxt-cli/src/utils/registry.ts b/packages/nuxt-cli/src/utils/registry.ts index d88083cf0..0c70b7cda 100644 --- a/packages/nuxt-cli/src/utils/registry.ts +++ b/packages/nuxt-cli/src/utils/registry.ts @@ -22,66 +22,38 @@ export interface RegistryMeta { authorization: string | null } -export function getRegistryFromContent(content: string, scope: string | null): string | null { - try { - const npmConfig = parseINI>(content) - - if (scope) { - const scopeKey = `${scope}:registry` - if (npmConfig[scopeKey]) { - return npmConfig[scopeKey].trim() - } - } +type NpmConfig = Record - if (npmConfig.registry) { - return npmConfig.registry.trim() - } +function registryFromConfig(config: NpmConfig, scope: string | null): string | null { + return (scope && config[`${scope}:registry`]?.trim()) || config.registry?.trim() || null +} - return null +export function getRegistryFromContent(content: string, scope: string | null): string | null { + try { + return registryFromConfig(parseINI(content), scope) } catch { return null } } -/** - * `.npmrc` files to consult, most specific first. Without a `cwd` only the user's - * own file is read, for requests a project should not be able to redirect. - */ -function getNpmrcPaths(cwd: string | undefined): string[] { - return cwd ? [join(cwd, '.npmrc'), join(homedir(), '.npmrc')] : [join(homedir(), '.npmrc')] -} - -async function getRegistryFromFile(paths: string[], scope: string | null) { - for (const npmrcPath of paths) { +/** Parsed `.npmrc` files, most specific first; only the user file without a `cwd`. */ +async function readNpmrcs(cwd: string | undefined): Promise { + const paths = cwd ? [join(cwd, '.npmrc'), join(homedir(), '.npmrc')] : [join(homedir(), '.npmrc')] + const configs = await Promise.all(paths.map(async (npmrcPath) => { let fd: FileHandle | undefined try { fd = await fs.promises.open(npmrcPath, 'r') if (await fd.stat().then(r => r.isFile())) { - const npmrcContent = await fd.readFile('utf-8') - const registry = getRegistryFromContent(npmrcContent, scope) - - if (registry) { - return registry - } + return parseINI(await fd.readFile('utf-8')) } } - catch { - // swallow errors as file does not exist - } + catch {} finally { await fd?.close() } - } - return null -} - -async function getRegistry(scope: string | null, cwd: string | undefined): Promise { - const registry = process.env.COREPACK_NPM_REGISTRY - || await getRegistryFromFile(getNpmrcPaths(cwd), scope) - || PUBLIC_REGISTRY - - return registry.replace(TRAILING_SLASH_RE, '') + })) + return configs.filter(c => !!c) } /** @@ -111,7 +83,7 @@ function expand(value: string): string { return value.trim().replace(ENV_REFERENCE_RE, (match, name: string) => process.env[name] ?? match) } -function readCredentials(config: Record, registry: string): Pick | undefined { +function readCredentials(config: NpmConfig, registry: string): Pick | undefined { for (const prefix of authKeyPrefixes(registry)) { const token = config[`${prefix}:_authToken`] if (token) { @@ -132,30 +104,6 @@ function readCredentials(config: Record, registry: s } } -async function getCredentials(registry: RegistryMeta['registry'], cwd: string | undefined): Promise> { - for (const npmrcPath of getNpmrcPaths(cwd)) { - let fd: FileHandle | undefined - try { - fd = await fs.promises.open(npmrcPath, 'r') - if (await fd.stat().then(r => r.isFile())) { - const config = parseINI>(await fd.readFile('utf-8')) - const credentials = readCredentials(config, registry) - if (credentials) { - return credentials - } - } - } - catch { - // swallow errors as file does not exist - } - finally { - await fd?.close() - } - } - - return { authToken: null, authorization: null } -} - /** * Registry and credentials for `scope`, from the project's `.npmrc` in `cwd` * (defaulting to the working directory) and then the user's. Pass `null` as @@ -164,11 +112,16 @@ async function getCredentials(registry: RegistryMeta['registry'], cwd: string | * a request the user did not ask for should not be steered by it. */ export async function detectNpmRegistry(scope: string | null, cwd: string | null = process.cwd()): Promise { - const paths = cwd ?? undefined - const registry = await getRegistry(scope, paths) - - return { - registry, - ...await getCredentials(registry, paths), + const configs = await readNpmrcs(cwd ?? undefined) + const registry = (process.env.COREPACK_NPM_REGISTRY + || configs.map(config => registryFromConfig(config, scope)).find(Boolean) + || PUBLIC_REGISTRY).replace(TRAILING_SLASH_RE, '') + + for (const config of configs) { + const credentials = readCredentials(config, registry) + if (credentials) { + return { registry, ...credentials } + } } + return { registry, authToken: null, authorization: null } } diff --git a/packages/nuxt-cli/src/utils/server-build.ts b/packages/nuxt-cli/src/utils/server-build.ts index 8f853fdac..f277b29dd 100644 --- a/packages/nuxt-cli/src/utils/server-build.ts +++ b/packages/nuxt-cli/src/utils/server-build.ts @@ -10,26 +10,16 @@ const DEFAULT_PUBLIC_DIR = '.output/public' const DEFAULT_TARGET_LABEL = 'preset' /** - * A server builder as the CLI needs to see it: what to call it, an optional - * deploy target within it, what it can do, where its build lands and how to - * preview it. - * - * Everything a builder can move during its own init is a getter rather than a - * value: Nitro resolves its preset and then `nitro:config` and - * `nitro.updateConfig()` can each change `output.dir`, so a snapshot taken - * before the build can be wrong by the time it is used. + * A server builder as the CLI sees it. Anything a builder can change during its + * own init is a getter, so it is read when used rather than snapshotted. */ export interface ServerBuild { - /** The configured server builder, e.g. `nitro` or `vite`. */ - readonly name: string /** The builder's display name, e.g. `Nitro` or `Vite SPA`. */ readonly label: string /** What the builder calls its deploy target axis, e.g. `preset`. */ targetLabel: string /** Whether Nuxt described this build itself, rather than the CLI inferring it. */ declared: boolean - /** Whether this build produces a server runtime. */ - readonly hasServer: boolean /** Whether this builder can serve `nuxt dev`. */ hasDevServer: boolean /** The deploy target within the builder, e.g. a Nitro preset. */ @@ -138,16 +128,6 @@ function normalizeBuilderName(specifier: string): string { return specifier.replace(/^@nuxt\//, '').replace(/-server$/, '') } -/** - * A directory path that compares equal to another naming the same directory. - * - * `nitro.options.output.*` carries a trailing slash, and these paths are used as - * lock keys and printed relative to the cwd. - */ -function normalizeDir(dir: string): string { - return resolve(dir) -} - /** * Describe a loaded Nuxt instance's build in builder-agnostic terms. * @@ -165,25 +145,19 @@ export function resolveServerBuild(kit: MaybeModernKit, nuxt: Nuxt): ServerBuild targetLabel: declared?.targetLabel ?? DEFAULT_TARGET_LABEL, declared: !!declared, hasDevServer: declared?.capabilities.dev ?? true, - get name() { - return (declared ? normalizeBuilderName(declared.name) : undefined) ?? inferBuilderName(nuxt) ?? (getNitro() ? 'nitro' : 'unknown') - }, get label() { return getServerBuilderName(nuxt, !!getNitro()) }, - get hasServer() { - return declared?.capabilities.server ?? !!getNitro() - }, get target() { return declared?.target?.() ?? getNitro()?.options.preset }, get dir() { - return normalizeDir(declared?.output.dir() + return resolve(declared?.output.dir() ?? getNitro()?.options.output?.dir ?? resolve(nuxt.options.rootDir, nuxt.options.nitro?.output?.dir || DEFAULT_OUTPUT_DIR)) }, get publicDir() { - return normalizeDir(declared?.output.publicDir() + return resolve(declared?.output.publicDir() ?? getNitro()?.options.output?.publicDir ?? resolve(nuxt.options.rootDir, nuxt.options.nitro?.output?.publicDir || DEFAULT_PUBLIC_DIR)) }, @@ -192,7 +166,7 @@ export function resolveServerBuild(kit: MaybeModernKit, nuxt: Nuxt): ServerBuild }, get previewStaticDir() { const dir = declared?.preview?.staticDir?.() - return dir ? normalizeDir(dir) : undefined + return dir ? resolve(dir) : undefined }, } } diff --git a/packages/nuxt-cli/src/utils/shell-quote.ts b/packages/nuxt-cli/src/utils/shell-quote.ts new file mode 100644 index 000000000..10db108a5 --- /dev/null +++ b/packages/nuxt-cli/src/utils/shell-quote.ts @@ -0,0 +1,21 @@ +const NEEDS_QUOTING_RE = /[\s"'$`\\]/ +const SINGLE_QUOTE_RE = /'/g +const BACKSLASHES_BEFORE_QUOTE_RE = /(\\*)"/g +const TRAILING_BACKSLASHES_RE = /(\\*)$/ + +/** + * Quote `value` for the shell: single quotes on POSIX (where `$` expands inside + * double quotes), double quotes on Windows with backslashes doubled before a quote. + */ +export function quoteArgument(value: string, windows: boolean): string { + if (!NEEDS_QUOTING_RE.test(value)) { + return value + } + if (!windows) { + return `'${value.replace(SINGLE_QUOTE_RE, `'\\''`)}'` + } + const escaped = value + .replace(BACKSLASHES_BEFORE_QUOTE_RE, '$1$1\\"') + .replace(TRAILING_BACKSLASHES_RE, '$1$1') + return `"${escaped}"` +} diff --git a/packages/nuxt-cli/src/utils/starter-templates.ts b/packages/nuxt-cli/src/utils/starter-templates.ts index 1c3c48b68..3a0216965 100644 --- a/packages/nuxt-cli/src/utils/starter-templates.ts +++ b/packages/nuxt-cli/src/utils/starter-templates.ts @@ -2,7 +2,7 @@ import process from 'node:process' import { fetchJson } from './fetch.ts' -export const hiddenTemplates = [ +const hiddenTemplates = [ 'doc-driven', 'v4', 'v4-compat', diff --git a/packages/nuxt-cli/src/utils/suggest-command.ts b/packages/nuxt-cli/src/utils/suggest-command.ts deleted file mode 100644 index 331f0428c..000000000 --- a/packages/nuxt-cli/src/utils/suggest-command.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { commandPolicy, suggestClosest } from './suggest' - -/** Best guess at the command a user meant to type. */ -export function suggestCommand(input: string, commands: string[]): Promise { - return suggestClosest(input, commands, commandPolicy) -} diff --git a/packages/nuxt-cli/src/utils/suggest.ts b/packages/nuxt-cli/src/utils/suggest.ts index 9c95f858f..125ee2ff7 100644 --- a/packages/nuxt-cli/src/utils/suggest.ts +++ b/packages/nuxt-cli/src/utils/suggest.ts @@ -1,4 +1,4 @@ -export interface SuggestionPolicy { +interface SuggestionPolicy { /** Minimum `fuzzysort` score for a subsequence match to be believed. */ threshold: number /** How far ahead of the runner up a subsequence match has to be. */ @@ -16,7 +16,7 @@ export interface SuggestionPolicy { * have read, so it has to be right; a wrong flag suggestion sits next to the * flag they typed, so a lower bar is worth the extra hits. */ -export const commandPolicy: SuggestionPolicy = { +const commandPolicy: SuggestionPolicy = { threshold: 0.6, margin: 0.1, tolerance: input => input.length <= 4 ? 1 : 2, @@ -24,11 +24,19 @@ export const commandPolicy: SuggestionPolicy = { requireUnique: true, } -export const flagPolicy: SuggestionPolicy = { +const flagPolicy: SuggestionPolicy = { threshold: 0.3, tolerance: input => input.length <= 4 ? 1 : 2, } +export function suggestCommand(input: string, commands: string[]): Promise { + return suggestClosest(input, commands, commandPolicy) +} + +export function suggestFlag(input: string, flags: string[]): Promise { + return suggestClosest(input, flags, flagPolicy) +} + /** * Best guess at the candidate an input was meant to be, or `undefined` when * nothing is close enough to be worth printing. @@ -37,7 +45,7 @@ export const flagPolicy: SuggestionPolicy = { * at zero (`biuld` against `build`, `dotnev` against `dotenv`), which is the * most common typo of all, so edit distance covers what it rejects. */ -export async function suggestClosest(input: string, candidates: string[], policy: SuggestionPolicy): Promise { +async function suggestClosest(input: string, candidates: string[], policy: SuggestionPolicy): Promise { const query = input.toLowerCase() if (!query || candidates.includes(input)) { return undefined diff --git a/packages/nuxt-cli/src/utils/terminal-link.ts b/packages/nuxt-cli/src/utils/terminal-link.ts deleted file mode 100644 index 2db5c4b7e..000000000 --- a/packages/nuxt-cli/src/utils/terminal-link.ts +++ /dev/null @@ -1,18 +0,0 @@ -import process from 'node:process' - -import { supportsHyperlinks } from 'clickable-path' - -/** - * Render `label` as a clickable link to `url`. - * - * `clickable-path` covers file paths, whose targets it builds itself; this is - * the same OSC 8 escape for a target that is already a URL. Terminal support - * is decided by `clickable-path` so both kinds of link appear together or not - * at all. - */ -export function terminalLink(label: string, url: string, options: { stream?: { isTTY?: boolean } } = {}): string { - if (!supportsHyperlinks(options.stream ?? process.stdout)) { - return label - } - return `\u001B]8;;${url}\u0007${label}\u001B]8;;\u0007` -} diff --git a/packages/nuxt-cli/src/utils/unknown-args.ts b/packages/nuxt-cli/src/utils/unknown-args.ts index 2902e91e4..c1bf2e086 100644 --- a/packages/nuxt-cli/src/utils/unknown-args.ts +++ b/packages/nuxt-cli/src/utils/unknown-args.ts @@ -1,6 +1,6 @@ import type { ArgsDef } from 'citty' -import { flagPolicy, suggestClosest } from './suggest' +import { suggestFlag } from './suggest' /** Always accepted by citty, so never reported as unknown. */ const BUILTIN_FLAGS = ['help', 'version'] @@ -53,7 +53,7 @@ export function findUnknownFlags(argsDef: ArgsDef, rawArgs: string[]): UnknownFl */ export async function suggestFlags({ flags, known }: UnknownFlags): Promise> { return Promise.all(flags.map(async (flag) => { - const match = await suggestClosest(flag.replace(NEGATION_RE, ''), known, flagPolicy) + const match = await suggestFlag(flag.replace(NEGATION_RE, ''), known) return { flag: `--${flag}`, suggestion: match && `--${match}` } })) } diff --git a/packages/nuxt-cli/src/utils/update-check.ts b/packages/nuxt-cli/src/utils/update-check.ts index 52154b132..ef875562a 100644 --- a/packages/nuxt-cli/src/utils/update-check.ts +++ b/packages/nuxt-cli/src/utils/update-check.ts @@ -7,10 +7,10 @@ import { isCI, isTest, provider } from 'std-env' import { isGreaterThan, tryParse } from 'verkit' import { fetchJson } from './fetch' +import { terminalLink } from './formatting' import { debug, writeNotice } from './logger' import { detectNpmRegistry } from './registry' import { trackOutputSpacing } from './stdout' -import { terminalLink } from './terminal-link' const RC_FILE = '.nuxtrc' const CACHE_KEY = 'updateCheck' diff --git a/packages/nuxt-cli/src/utils/versions.ts b/packages/nuxt-cli/src/utils/versions.ts index 45fa21f0c..1562731f4 100644 --- a/packages/nuxt-cli/src/utils/versions.ts +++ b/packages/nuxt-cli/src/utils/versions.ts @@ -4,6 +4,7 @@ import { coerce, findMaxSatisfying, normalize } from 'verkit' import { resolveCatalogEntry } from './catalog' import { fetchJson } from './fetch' import { debug } from './logger' +import { NUXT_PACKAGES } from './nuxt-packages' import { readDependencyPackageJson } from './package-json' import { detectNpmRegistry, PUBLIC_REGISTRY } from './registry' @@ -16,7 +17,7 @@ const FETCH_TIMEOUT = 10_000 * when the package exposes no `./package.json` export) is not mistaken for Nuxt's * own. */ -const NUXT_PACKAGE_NAMES = new Set(['nuxt', 'nuxt-nightly']) +const NUXT_PACKAGE_NAMES = new Set(NUXT_PACKAGES) /** Assumed Nuxt version when the project declares no resolvable one. */ export const DEFAULT_NUXT_VERSION = '3.0.0' diff --git a/packages/nuxt-cli/test/e2e/dev.spec.ts b/packages/nuxt-cli/test/e2e/dev.spec.ts index 9ba867163..33113c667 100644 --- a/packages/nuxt-cli/test/e2e/dev.spec.ts +++ b/packages/nuxt-cli/test/e2e/dev.spec.ts @@ -6,7 +6,7 @@ import { getPort } from 'get-port-please' import { afterEach, describe, expect, it, vi } from 'vitest' import { runCommand } from '../../src' import { NuxtDevServer } from '../../src/dev/utils' -import { createDevFixture } from '../utils' +import { createDevFixture, fetchWithPolling } from '../utils' const NEWLINE_RE = /\r?\n/ @@ -204,29 +204,17 @@ describe('dev server', () => { const { result: { close } } = await runCommand('dev', [`--host=${host}`, `--port=${port}`, `--cwd=${fixtureDir}`]) as any try { - // Wait for server to be ready - await new Promise(resolve => setTimeout(resolve, 2000)) - - // Make request to endpoint that sets multiple cookies - const response = await fetch(`http://${host}:${port}/api/test-cookies`) - - // Get all set-cookie headers + const response = (await fetchWithPolling(`http://${host}:${port}/api/test-cookies`))! const setCookies = response.headers.getSetCookie() - // Should have 3 separate cookies expect(setCookies).toHaveLength(3) - - // Each cookie should be separate (not joined with comma) expect(setCookies[0]).toContain('XSRF-TOKEN') expect(setCookies[1]).toContain('app-session') expect(setCookies[2]).toContain('user-pref') - - // Cookies should NOT contain each other (would happen if joined with comma) expect(setCookies[0]).not.toContain('app-session') expect(setCookies[1]).not.toContain('user-pref') expect(setCookies[0]).not.toContain('user-pref') - // Verify response body const data = await response.json() expect(data).toEqual({ ok: true, cookies: 3 }) } diff --git a/packages/nuxt-cli/test/e2e/runtimes.spec.ts b/packages/nuxt-cli/test/e2e/runtimes.spec.ts index 5efb1d748..663c1c401 100644 --- a/packages/nuxt-cli/test/e2e/runtimes.spec.ts +++ b/packages/nuxt-cli/test/e2e/runtimes.spec.ts @@ -1,6 +1,5 @@ import type { ChildProcess } from 'node:child_process' import type { MessageEvent } from 'undici' -import type { TestOptions } from 'vitest' import { spawn, spawnSync } from 'node:child_process' import { cpSync, rmSync } from 'node:fs' import { rm } from 'node:fs/promises' @@ -10,7 +9,7 @@ import { fileURLToPath } from 'node:url' import { checkPort, getPort, waitForPort } from 'get-port-please' import { isCI } from 'std-env' import { WebSocket } from 'undici' -import { it as _it, afterAll, beforeAll, describe, expect, vi } from 'vitest' +import { afterAll, describe, expect, it, vi } from 'vitest' const playgroundDir = fileURLToPath(new URL('../../../../playground', import.meta.url)) const nuxiPath = join(fileURLToPath(new URL('../..', import.meta.url)), 'bin/nuxi.mjs') @@ -23,61 +22,6 @@ const runtime = { node: true, } -type SupportStatus = boolean | { - start: boolean - fetching: boolean - websockets: boolean -} - -function createIt(runtimeName: typeof runtimes[number]) { - function it(description: string, fn: () => Promise): void - function it(description: string, options: TestOptions, fn: () => Promise): void - function it(description: string, _options: TestOptions | (() => Promise), _fn?: () => Promise): void { - const supportMatrix: Record = { - node: true, - bun: { - start: true, - fetching: true, - websockets: true, - }, - deno: true, - } - const status = supportMatrix[runtimeName] - - const fn = typeof _options === 'function' ? _options : _fn! - const options = typeof _options === 'function' ? {} : _options - - if (status === false) { - return _it.fails(`${description} [expected to fail with ${runtimeName}]`, options, fn) - } - if (status === true) { - return _it(description, options, fn) - } - if (description.includes('should start dev server')) { - if (!status.start) { - return _it.fails(description, options, fn) - } - return beforeAll(fn, options.timeout) - } - if (!status.start) { - return _it.todo(description) - } - if (description.includes('websocket')) { - if (!status.websockets) { - return _it.fails(`${description} [expected to fail with ${runtimeName}]`, options, fn) - } - return _it(description, options, fn) - } - // Handle fetching tests (all tests that are not websocket or start tests) - if (!status.fetching) { - return _it.fails(description, options, fn) - } - return _it(description, options, fn) - } - - return it -} - const requestTimeout = isCI ? 30_000 : 10_000 describe.sequential.each(runtimes)('dev server (%s)', (runtimeName) => { @@ -85,7 +29,7 @@ describe.sequential.each(runtimes)('dev server (%s)', (runtimeName) => { if (!isCI && !runtime[runtimeName]) { console.warn(`Not testing locally with ${runtimeName} as it is not installed.`) - _it.skip(`should pass with ${runtimeName}`) + it.skip(`should pass with ${runtimeName}`) return } @@ -96,8 +40,6 @@ describe.sequential.each(runtimes)('dev server (%s)', (runtimeName) => { await rm(cwd, { recursive: true, force: true }).catch(() => null) }) - const it = createIt(runtimeName) - it('should start dev server', { timeout: isCI ? 120_000 : 30_000 }, async () => { rmSync(cwd, { recursive: true, force: true }) cpSync(playgroundDir, cwd, { diff --git a/packages/nuxt-cli/test/fixtures/fork-pool-entry.mjs b/packages/nuxt-cli/test/fixtures/fork-pool-entry.mjs deleted file mode 100644 index 597a547be..000000000 --- a/packages/nuxt-cli/test/fixtures/fork-pool-entry.mjs +++ /dev/null @@ -1,6 +0,0 @@ -import process from 'node:process' - -// Node refs the IPC channel while a `message` listener is attached, which is -// what keeps this stand-in for a warm fork alive after it reports readiness. -process.on('message', () => {}) -process.send({ type: 'nuxt:internal:dev:fork-ready' }) diff --git a/packages/nuxt-cli/test/unit/bundler-request.spec.ts b/packages/nuxt-cli/test/unit/bundler-request.spec.ts index 7f63c6885..4be781175 100644 --- a/packages/nuxt-cli/test/unit/bundler-request.spec.ts +++ b/packages/nuxt-cli/test/unit/bundler-request.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { isBundlerRequest } from '../../src/dev/utils' +import { isBundlerRequest } from '../../src/dev/request-kind' describe('isBundlerRequest', () => { it.each([ diff --git a/packages/nuxt-cli/test/unit/commands/add-backwards-compat.spec.ts b/packages/nuxt-cli/test/unit/commands/add-backwards-compat.spec.ts index 3f99417fa..f16b05eeb 100644 --- a/packages/nuxt-cli/test/unit/commands/add-backwards-compat.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/add-backwards-compat.spec.ts @@ -1,60 +1,27 @@ import { existsSync } from 'node:fs' -import { rm } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { x } from 'tinyexec' import { describe, expect, it } from 'vitest' -const fixtureDir = fileURLToPath(new URL('../../../../../playground', import.meta.url)) +import { createPlaygroundFixture } from '../../utils' + +const fixtureDir = await createPlaygroundFixture('add-backwards-compat') const nuxi = fileURLToPath(new URL('../../../bin/nuxi.mjs', import.meta.url)) describe('nuxt add backwards compatibility', () => { - it('should create middleware file using deprecated syntax', async () => { - const file = join(fixtureDir, 'app/middleware/auth.ts') - await rm(file, { force: true }) - - const res = await x(nuxi, ['add', 'middleware', 'auth'], { + it.each([ + ['middleware', 'auth', 'app/middleware/auth.ts'], + ['page', 'test-page', 'app/pages/test-page.vue'], + ['composable', 'useTestComposable', 'app/composables/useTestComposable.ts'], + ])('should create a %s file using deprecated syntax', async (template, name, file) => { + const res = await x(nuxi, ['add', template, name], { nodeOptions: { stdio: 'pipe', cwd: fixtureDir }, }) - // Should show deprecation warning (check both stdout and stderr) const output = res.stdout + res.stderr expect(output).toContain('Deprecated') expect(output).toContain('add-template') - - // Should still create the file - expect(existsSync(file)).toBe(true) - - await rm(file, { force: true }) - }) - - it('should create page file using deprecated syntax', async () => { - const file = join(fixtureDir, 'app/pages/test-page.vue') - await rm(file, { force: true }) - - const res = await x(nuxi, ['add', 'page', 'test-page'], { - nodeOptions: { stdio: 'pipe', cwd: fixtureDir }, - }) - - const output = res.stdout + res.stderr - expect(output).toContain('Deprecated') - expect(existsSync(file)).toBe(true) - - await rm(file, { force: true }) - }) - - it('should create composable file using deprecated syntax', async () => { - const file = join(fixtureDir, 'app/composables/useTestComposable.ts') - await rm(file, { force: true }) - - const res = await x(nuxi, ['add', 'composable', 'useTestComposable'], { - nodeOptions: { stdio: 'pipe', cwd: fixtureDir }, - }) - - const output = res.stdout + res.stderr - expect(output).toContain('Deprecated') - expect(existsSync(file)).toBe(true) - - await rm(file, { force: true }) + expect(existsSync(join(fixtureDir, file))).toBe(true) }) }) diff --git a/packages/nuxt-cli/test/unit/commands/module/add-peers.spec.ts b/packages/nuxt-cli/test/unit/commands/module/add-peers.spec.ts index 122c3f369..ecab80662 100644 --- a/packages/nuxt-cli/test/unit/commands/module/add-peers.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/module/add-peers.spec.ts @@ -5,7 +5,7 @@ import { resolveRequiredPeerDependencies } from '../../../../src/commands/module describe('resolveRequiredPeerDependencies', () => { it('should include required peers the project does not have', () => { const peers = resolveRequiredPeerDependencies([ - { pkg: '@pinia/nuxt@1.0.1', pkgName: '@pinia/nuxt', specifier: '@pinia/nuxt', pkgVersion: '1.0.1', peerDependencies: { pinia: '^4.0.2' } }, + { pkg: '@pinia/nuxt@1.0.1', pkgName: '@pinia/nuxt', specifier: '@pinia/nuxt', peerDependencies: { pinia: '^4.0.2' } }, ], new Set(['nuxt', 'vue'])) expect(peers).toEqual(['pinia@^4.0.2']) @@ -17,11 +17,11 @@ describe('resolveRequiredPeerDependencies', () => { pkg: '@nuxt/example@1.0.0', pkgName: '@nuxt/example', specifier: '@nuxt/example', - pkgVersion: '1.0.0', + peerDependencies: { 'vue': '^3.0.0', 'some-optional': '^1.0.0', '@pinia/nuxt': '^1.0.0' }, optionalPeerDependencies: ['some-optional'], }, - { pkg: '@pinia/nuxt@1.0.1', pkgName: '@pinia/nuxt', specifier: '@pinia/nuxt', pkgVersion: '1.0.1' }, + { pkg: '@pinia/nuxt@1.0.1', pkgName: '@pinia/nuxt', specifier: '@pinia/nuxt' }, ], new Set(['nuxt', 'vue'])) expect(peers).toEqual([]) @@ -29,7 +29,7 @@ describe('resolveRequiredPeerDependencies', () => { it('should fall back to the bare name for ranges that are not valid specs', () => { const peers = resolveRequiredPeerDependencies([ - { pkg: 'mod@1.0.0', pkgName: 'mod', specifier: 'mod', pkgVersion: '1.0.0', peerDependencies: { a: '*', b: '>=3 <5' } }, + { pkg: 'mod@1.0.0', pkgName: 'mod', specifier: 'mod', peerDependencies: { a: '*', b: '>=3 <5' } }, ], new Set()) expect(peers).toEqual(['a', 'b']) diff --git a/packages/nuxt-cli/test/unit/commands/upgrade-run.spec.ts b/packages/nuxt-cli/test/unit/commands/upgrade-run.spec.ts index b4e662acc..1463b3e67 100644 --- a/packages/nuxt-cli/test/unit/commands/upgrade-run.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/upgrade-run.spec.ts @@ -30,6 +30,7 @@ vi.mock('@clack/prompts', async (importOriginal) => { vi.mock('../../../src/utils/package-managers', async importOriginal => ({ ...await importOriginal(), detectPackageManager, + getPackageManagerVersion: () => '10.0.0', })) vi.mock('../../../src/utils/install', async importOriginal => ({ diff --git a/packages/nuxt-cli/test/unit/dev-keys.spec.ts b/packages/nuxt-cli/test/unit/dev-keys.spec.ts index ae2cb52d8..8acf3deaa 100644 --- a/packages/nuxt-cli/test/unit/dev-keys.spec.ts +++ b/packages/nuxt-cli/test/unit/dev-keys.spec.ts @@ -2,7 +2,7 @@ import { Buffer } from 'node:buffer' import process from 'node:process' import { PassThrough } from 'node:stream' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { attachKeys } from '../../src/dev/tui/keys' import { filterTerminalReplies } from '../../src/dev/tui/terminal-replies' @@ -109,10 +109,12 @@ describe('panel keys', () => { }) it('should give the keyboard back when a reply is never terminated', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + restores.push(() => vi.useRealTimers()) const { keys, type } = attach() await type('\u001B]11;rgb:1e1e') - await new Promise(resolve => setTimeout(resolve, 250)) + vi.advanceTimersByTime(250) await type('o') expect(keys).toEqual(['o']) diff --git a/packages/nuxt-cli/test/unit/dev-tui.spec.ts b/packages/nuxt-cli/test/unit/dev-tui.spec.ts index 2eb16926d..43c221f23 100644 --- a/packages/nuxt-cli/test/unit/dev-tui.spec.ts +++ b/packages/nuxt-cli/test/unit/dev-tui.spec.ts @@ -6,9 +6,8 @@ import type { DevRoute } from '../../src/dev/utils' import process from 'node:process' import { consola } from 'consola' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createRequest, currentRequest, isServingRequest, runWithRequest } from '../../src/dev/serving-state' import { deferShortcutContext } from '../../src/dev/shortcut-context' import { adoptShutdown } from '../../src/dev/shutdown' import { DevEventLog, noteRoute } from '../../src/dev/tui/events' @@ -24,14 +23,11 @@ import { RequestLog } from '../../src/dev/tui/requests' import { RouteOverlay } from '../../src/dev/tui/route-overlay' import { resolveDevUISupport, supportsUnicode } from '../../src/dev/tui/support' import { PanelSurface } from '../../src/dev/tui/surface' -import { truncate } from '../../src/dev/tui/width' -import { nuxtIcon } from '../../src/utils/ascii' import { KEEPS_PROCESS_ALIVE } from '../../src/utils/errors' +import { terminalLink, truncate } from '../../src/utils/formatting' import { logger } from '../../src/utils/logger' import { useTerminalHost } from '../../src/utils/terminal-host' -import { terminalLink } from '../../src/utils/terminal-link' -import { paint, resolveBackground } from '../../src/utils/terminal-theme' -import { releaseNotesUrl } from '../../src/utils/update-check' +import { paint } from '../../src/utils/terminal-theme' import { render, screen } from '../utils/terminal' const opened: string[] = [] @@ -48,6 +44,13 @@ vi.mock('tinyclip', () => ({ }, })) +beforeEach(() => { + vi.stubEnv('DISPLAY', ':0') +}) +afterEach(() => { + vi.unstubAllEnvs() +}) + // eslint-disable-next-line no-control-regex const strip = (text: string) => text.replaceAll(/\u001B\[[0-9;]*m|\u001B\]8;[^\u0007]*\u0007/g, '') @@ -86,10 +89,10 @@ describe('dev tui panel', () => { it('should say which request it is busy with, and for how long', () => { const first = renderPanel({ ...READY, awaitingFirstRender: true, rendering: { label: 'GET /', startedAt: 0 }, renderingMs: 6400 }, 80, 30).map(strip) - expect(first.join('\n')).toContain('WARMUP rendering GET / · 6.4s') + expect(first.join('\n')).toContain('WARMUP rendering GET / · 6.40s') const later = renderPanel({ ...READY, rendering: { label: 'GET /about', startedAt: 0 }, renderingMs: 1200 }, 80, 30).map(strip) - expect(later.join('\n')).toContain('READY rendering GET /about · 1.2s') + expect(later.join('\n')).toContain('READY rendering GET /about · 1.20s') }) it('should keep the last request off the line while one is in flight', () => { @@ -100,15 +103,15 @@ describe('dev tui panel', () => { lastRequest: { method: 'GET', url: '/', status: 200, duration: 8442 }, }, 100, 30).map(strip) - expect(lines.join('\n')).toContain('READY rendering GET / · 6.7s') - expect(lines.join('\n')).not.toContain('8442ms') + expect(lines.join('\n')).toContain('READY rendering GET / · 6.70s') + expect(lines.join('\n')).not.toContain('8.44s') }) it('should not put a clock on a render that has only just arrived', () => { const lines = renderPanel({ ...READY, rendering: { label: 'GET /', startedAt: 0 }, renderingMs: 40 }, 80, 30).map(strip) expect(lines.join('\n')).toContain('READY rendering GET /') - expect(lines.join('\n')).not.toContain('0.0s') + expect(lines.join('\n')).not.toContain('40ms') }) it('should let a load in flight keep the status line from a render', () => { @@ -194,7 +197,7 @@ describe('dev tui panel', () => { const busy = { ...READY, requests: 188, medianMs: 11, task: { label: 'Installing with pnpm', startedAt: Date.now() - 4200 } } const lines = renderPanel(busy, 100, 30).map(strip) const line = lines.find(entry => entry.includes('installing with pnpm'))! - expect(line).toContain('4.2s') + expect(line).toContain('4.20s') expect(lines.join('\n')).not.toContain('188 requests') // The work borrows the line, so the panel keeps its shape. expect(lines).toHaveLength(renderPanel({ ...READY, requests: 188, medianMs: 11 }, 100, 30).length) @@ -217,7 +220,7 @@ describe('dev tui panel', () => { const lines = renderPanel({ ...READY, status: 'starting', readyMs: undefined, progress: 0.5, elapsedMs: 3200, note: 'Bundling app' }, 100, 30).map(strip) const bar = lines.find(line => line.includes('%')) expect(bar).toContain('50%') - expect(bar).toContain('3.2s') + expect(bar).toContain('3.20s') expect(bar).toContain('\u2501') // The bar borrows the summary line, so the panel keeps its shape. expect(lines).toHaveLength(renderPanel({ ...READY }, 100, 30).length) @@ -572,114 +575,6 @@ describe('dev tui logo', () => { }) }) -describe('terminal background', () => { - it('takes an explicit setting at its word', () => { - expect(resolveBackground({ NUXT_TERM_THEME: 'light' })).toBe('light') - expect(resolveBackground({ NUXT_TERM_THEME: 'DARK' })).toBe('dark') - expect(resolveBackground({ NUXT_TERM_THEME: 'light', COLORFGBG: '15;0' })).toBe('light') - }) - - it('reads the background the terminal reports', () => { - expect(resolveBackground({ COLORFGBG: '15;0' })).toBe('dark') - expect(resolveBackground({ COLORFGBG: '0;15' })).toBe('light') - expect(resolveBackground({ COLORFGBG: '0;default;15' })).toBe('light') - expect(resolveBackground({ COLORFGBG: '15;default;0' })).toBe('dark') - }) - - it('admits to not knowing rather than assuming', () => { - expect(resolveBackground({})).toBe('unknown') - expect(resolveBackground({ COLORFGBG: '15;default' })).toBe('unknown') - expect(resolveBackground({ NUXT_TERM_THEME: 'solarized' })).toBe('unknown') - }) -}) - -describe('exact colours', () => { - const withTerminal = (depth: number, run: () => void) => { - const keys = ['getColorDepth', 'hasColors', 'isTTY'] as const - const originals = keys.map(key => [key, Object.getOwnPropertyDescriptor(process.stdout, key)] as const) - Object.defineProperty(process.stdout, 'getColorDepth', { value: () => depth, configurable: true }) - Object.defineProperty(process.stdout, 'hasColors', { value: () => depth > 1, configurable: true }) - Object.defineProperty(process.stdout, 'isTTY', { value: depth > 1, configurable: true }) - try { - run() - } - finally { - for (const [key, descriptor] of originals) { - if (descriptor) { - Object.defineProperty(process.stdout, key, descriptor) - } - else { - Reflect.deleteProperty(process.stdout, key) - } - } - } - } - - it('uses the exact colour only where the background is known', () => { - withTerminal(24, () => { - expect(paint('brand', 'Nuxt', 'dark')).toContain('\u001B[38;2;0;220;130m') - expect(paint('brand', 'Nuxt', 'light')).toContain('\u001B[38;2;0;145;92m') - expect(paint('brand', 'Nuxt', 'unknown')).not.toContain('38;2') - }) - }) - - it('darkens the warning amber on a light terminal, where yellow cannot be read', () => { - withTerminal(24, () => { - expect(paint('warning', '1 warning', 'dark')).toContain('\u001B[38;2;255;200;87m') - expect(paint('warning', '1 warning', 'light')).toContain('\u001B[38;2;138;90;0m') - }) - }) - - it('takes the nearest colour a 256-colour terminal can hold', () => { - withTerminal(8, () => { - // The cube entries closest to `#00DC82`, `#00915C`, `#FFC857` and `#8A5A00`. - expect(paint('brand', 'Nuxt', 'dark')).toContain('\u001B[38;5;42m') - expect(paint('brand', 'Nuxt', 'light')).toContain('\u001B[38;5;29m') - expect(paint('warning', '!', 'dark')).toContain('\u001B[38;5;221m') - expect(paint('warning', '!', 'light')).toContain('\u001B[38;5;94m') - }) - }) - - it('leaves the palette to the terminal below 256 colours', () => { - withTerminal(4, () => { - expect(paint('brand', 'Nuxt', 'dark')).not.toContain('38;') - expect(strip(paint('brand', 'Nuxt', 'dark'))).toBe('Nuxt') - }) - }) - - it('hands the colour back so nothing after it is tinted', () => { - for (const depth of [24, 8]) { - withTerminal(depth, () => { - for (const background of ['dark', 'light', 'unknown'] as const) { - for (const tone of ['brand', 'warning'] as const) { - // eslint-disable-next-line no-control-regex - expect(paint(tone, 'Nuxt', background)).toMatch(/\u001B\[(?:39|0)m$/) - } - } - }) - } - }) - - it('emits no escapes at all when there is no colour', () => { - withTerminal(1, () => { - expect(paint('brand', 'Nuxt', 'dark')).toBe('Nuxt') - expect(paint('warning', 'Nuxt', 'light')).toBe('Nuxt') - expect(paint('brand', 'Nuxt', 'unknown')).toBe('Nuxt') - }) - }) - - it('paints the init mark without leaving the terminal green', () => { - withTerminal(24, () => { - const icon = nuxtIcon() - expect(strip(icon).split('\n')).toHaveLength(8) - for (const line of icon.split('\n')) { - // eslint-disable-next-line no-control-regex - expect(line).toMatch(/\u001B\[(?:39|0)m$/) - } - }) - }) -}) - describe('dev event log', () => { const event = (overrides: Partial[0]>) => ({ time: 0, @@ -1001,87 +896,6 @@ describe('dev event log', () => { }) }) -describe('request attribution', () => { - const tick = () => new Promise(resolve => setTimeout(resolve, 0)) - const queue: Array<() => void> = [] - - it('has nothing to attribute a log to outside a request', () => { - expect(currentRequest()).toBeUndefined() - expect(isServingRequest()).toBe(false) - }) - - it('attributes work on the call stack to the request that started it', () => { - runWithRequest('GET /about', (request) => { - expect(isServingRequest()).toBe(true) - expect(currentRequest()?.label).toBe('GET /about') - expect(currentRequest()?.id).toBe(request.id) - }) - expect(currentRequest()).toBeUndefined() - }) - - it('keeps overlapping requests apart across await points', async () => { - const seen: Array<[string, string | undefined]> = [] - const serve = async (label: string, delay: number) => { - await new Promise(resolve => setTimeout(resolve, delay)) - seen.push([label, currentRequest()?.label]) - await tick() - seen.push([label, currentRequest()?.label]) - } - - await Promise.all([ - runWithRequest('GET /page', () => serve('GET /page', 4)), - runWithRequest('GET /_nuxt/app.js', () => serve('GET /_nuxt/app.js', 1)), - runWithRequest('GET /api/hello', () => serve('GET /api/hello', 2)), - ]) - - expect(seen).toHaveLength(6) - for (const [label, attributed] of seen) { - expect(attributed).toBe(label) - } - }) - - it('follows a request into a nested callback the handler creates', async () => { - const attributed = await runWithRequest('GET /nested', () => new Promise((resolve) => { - process.nextTick(() => { - setImmediate(() => { - queueMicrotask(() => resolve(currentRequest()?.label)) - }) - }) - })) - expect(attributed).toBe('GET /nested') - }) - - it('gives a request an identity that cannot be guessed from its route or its neighbours', () => { - const ids = Array.from({ length: 50 }, () => createRequest('GET /boom-page').id) - const value = (id: string) => BigInt(`0x${id.replaceAll('-', '')}`) - - expect(new Set(ids).size).toBe(ids.length) - for (const id of ids) { - expect(id.replaceAll('-', '')).toMatch(/^[0-9a-f]{32}$/) - expect(id).not.toContain('boom-page') - expect(id).not.toContain('GET') - } - const distances = ids.slice(1).map((id, index) => value(id) - value(ids[index]!)) - expect(new Set(distances.map(String)).size).toBe(distances.length) - }) - - it('does not attribute work that has left the request context', async () => { - let escaped: string | undefined = 'unset' - runWithRequest('GET /leaky', () => { - // A queue the handler does not own loses the context, by design. - queue.push(() => { - escaped = currentRequest()?.label - }) - }) - const queued = queue.splice(0) - for (const run of queued) { - run() - } - await tick() - expect(escaped).toBeUndefined() - }) -}) - describe('log overlay', () => { beforeEach(() => { copied.length = 0 @@ -2328,10 +2142,16 @@ describe('panel surface', () => { surface.render(['--- footer ---']) surface.padToBottom() const before = read().length - await withStubbedColumnsAsync(30, async () => { - process.stdout.emit('resize') - await new Promise(resolve => setTimeout(resolve, 200)) - }) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + await withStubbedColumnsAsync(30, async () => { + process.stdout.emit('resize') + vi.advanceTimersByTime(200) + }) + } + finally { + vi.useRealTimers() + } written = read().slice(before) surface.close() })) @@ -2348,8 +2168,14 @@ describe('panel surface', () => { surface.padToBottom() const before = read().length Object.defineProperty(process.stdout, 'rows', { value: 20, configurable: true }) - process.stdout.emit('resize') - await new Promise(resolve => setTimeout(resolve, 200)) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + process.stdout.emit('resize') + vi.advanceTimersByTime(200) + } + finally { + vi.useRealTimers() + } written = read().slice(before) surface.close() })) @@ -2518,7 +2344,7 @@ describe('panel surface', () => { }) }) -describe('release notes links', () => { +describe('release notes links on the panel', () => { const linked = (label: string, url: string) => { // Terminal detection has many inputs; this is the switch that overrides them. vi.stubEnv('FORCE_HYPERLINK', '1') @@ -2530,20 +2356,6 @@ describe('release notes links', () => { } } - it('points at the tag for packages with a known repository', () => { - expect(releaseNotesUrl('nuxt', '4.6.0')).toBe('https://github.com/nuxt/nuxt/releases/tag/v4.6.0') - expect(releaseNotesUrl('@nuxt/cli', '3.1.0')).toBe('https://github.com/nuxt/cli/releases/tag/v3.1.0') - }) - - it('has nothing to link for nightlies or unknown packages', () => { - expect(releaseNotesUrl('nuxt', '4.6.0-nightly.20240101')).toBeUndefined() - expect(releaseNotesUrl('some-other-package', '1.0.0')).toBeUndefined() - }) - - it('emits a hyperlink only where the terminal supports one', () => { - expect(terminalLink('4.6.0', 'https://example.com', { stream: { isTTY: false } })).toBe('4.6.0') - }) - it('links the running version as well as the update', () => { const link = linked('4.5.1', 'https://github.com/nuxt/nuxt/releases/tag/v4.5.1') const lines = renderPanel({ status: 'ready', version: '4.5.1', versionLink: link, hints: HINTS }, 100, 30) diff --git a/packages/nuxt-cli/test/unit/dev/initialize.spec.ts b/packages/nuxt-cli/test/unit/dev/initialize.spec.ts index 70ea1b76c..5ae2b6c73 100644 --- a/packages/nuxt-cli/test/unit/dev/initialize.spec.ts +++ b/packages/nuxt-cli/test/unit/dev/initialize.spec.ts @@ -3,11 +3,10 @@ import process from 'node:process' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { closeErrorBridge, closeListener, closeNuxt, closeWatchers, devServers, releaseLock, startCpuProfile, stopCpuProfile } = vi.hoisted(() => ({ - closeErrorBridge: vi.fn(), +const { closeListener, closeNuxt, devServers, releaseLock, shutdown, startCpuProfile, stopCpuProfile } = vi.hoisted(() => ({ closeListener: vi.fn(() => Promise.resolve()), closeNuxt: vi.fn(() => Promise.resolve()), - closeWatchers: vi.fn(), + shutdown: vi.fn(), devServers: [] as any[], releaseLock: vi.fn(), startCpuProfile: vi.fn(() => Promise.resolve()), @@ -20,8 +19,7 @@ vi.mock('../../../src/utils/env.ts', () => ({ overrideEnv: vi.fn() })) vi.mock('../../../src/dev/utils', () => ({ NuxtDevServer: class extends EventEmitter { listener = { url: 'http://127.0.0.1:3000', close: closeListener } - closeWatchers = closeWatchers - closeErrorBridge = closeErrorBridge + shutdown = shutdown close = closeNuxt releaseLock = releaseLock load = vi.fn(() => Promise.resolve()) @@ -66,9 +64,9 @@ describe('initialize', () => { expect(seen).toEqual(['http://127.0.0.1:3000']) }) - it('should close the watchers, the listener and nuxt before releasing the lock', async () => { + it('should stop reloads, then close the listener and nuxt before releasing the lock', async () => { const order: string[] = [] - closeWatchers.mockImplementation(() => order.push('watchers')) + shutdown.mockImplementation(() => order.push('shutdown')) closeListener.mockImplementation(async () => void order.push('listener')) closeNuxt.mockImplementation(async () => void order.push('nuxt')) releaseLock.mockImplementation(() => order.push('lock')) @@ -76,9 +74,8 @@ describe('initialize', () => { const { close } = await initialize(context()) await close() - expect(order[0]).toBe('watchers') + expect(order[0]).toBe('shutdown') expect(order.at(-1)).toBe('lock') - expect(closeErrorBridge).toHaveBeenCalledTimes(1) expect(order).toContain('listener') expect(order).toContain('nuxt') }) diff --git a/packages/nuxt-cli/test/unit/dev/lifecycle.spec.ts b/packages/nuxt-cli/test/unit/dev/lifecycle.spec.ts index f6741d1a4..19ee08627 100644 --- a/packages/nuxt-cli/test/unit/dev/lifecycle.spec.ts +++ b/packages/nuxt-cli/test/unit/dev/lifecycle.spec.ts @@ -474,6 +474,20 @@ describe('dev server config reload', () => { await vi.waitFor(() => expect(loadNuxt).toHaveBeenCalledTimes(1), { timeout: 10_000 }) expect(loadNuxt).toHaveBeenCalledTimes(1) }) + + it('should not reload once shut down', async () => { + const server = createServer() + await server.init() + loadNuxt.mockClear() + + server.scheduleReload({ type: 'shortcut' }) + server.closeWatchers() + server.shutdown() + await server.load(true, { type: 'shortcut' }) + await new Promise(resolve => setTimeout(resolve, 50)) + + expect(loadNuxt).not.toHaveBeenCalled() + }) }) describe('dev server shutdown', () => { diff --git a/packages/nuxt-cli/test/unit/dev/responses.spec.ts b/packages/nuxt-cli/test/unit/dev/responses.spec.ts index 9264ddfe3..566310eef 100644 --- a/packages/nuxt-cli/test/unit/dev/responses.spec.ts +++ b/packages/nuxt-cli/test/unit/dev/responses.spec.ts @@ -14,7 +14,7 @@ interface FakeResponse { response: ServerResponse } -function createRequest(accept?: string, url = '/'): IncomingMessage { +function createIncomingMessage(accept?: string, url = '/'): IncomingMessage { return { url, method: 'GET', headers: accept ? { accept } : {}, rawHeaders: [] } as unknown as IncomingMessage } @@ -54,7 +54,7 @@ function createResponse(): FakeResponse { describe('sendErrorResponse', () => { it('should escape an error message in the html error page', async () => { const res = createResponse() - await sendErrorResponse(createRequest('text/html'), res.response, new Error('')) + await sendErrorResponse(createIncomingMessage('text/html'), res.response, new Error('')) expect(res.statusCode).toBe(500) expect(res.headers['content-type']).toBe('text/html') @@ -64,14 +64,14 @@ describe('sendErrorResponse', () => { it('should escape a reflected request url in the html error page', async () => { const res = createResponse() - await sendErrorResponse(createRequest('text/html', '/'), res.response, new Error('boom')) + await sendErrorResponse(createIncomingMessage('text/html', '/'), res.response, new Error('boom')) expect(res.body).not.toContain('') }) it('should answer a non-html client with json', async () => { const res = createResponse() - await sendErrorResponse(createRequest('application/json'), res.response, new Error('boom')) + await sendErrorResponse(createIncomingMessage('application/json'), res.response, new Error('boom')) expect(res.headers['content-type']).toBe('application/json') expect(JSON.parse(res.body)).toMatchObject({ error: true, status: 500, message: 'boom' }) @@ -79,7 +79,7 @@ describe('sendErrorResponse', () => { it('should send hardening headers with the error page', async () => { const res = createResponse() - await sendErrorResponse(createRequest('text/html'), res.response, new Error('boom')) + await sendErrorResponse(createIncomingMessage('text/html'), res.response, new Error('boom')) expect(res.headers).toMatchObject({ 'cache-control': 'no-store', @@ -92,14 +92,14 @@ describe('sendErrorResponse', () => { it('should not write a body once headers have been sent', async () => { const res = createResponse() res.headersSent = true - await sendErrorResponse(createRequest('text/html'), res.response, new Error('boom')) + await sendErrorResponse(createIncomingMessage('text/html'), res.response, new Error('boom')) expect(res.body).toBe('') }) it('should render a non-error rejection value', async () => { const res = createResponse() - await sendErrorResponse(createRequest('application/json'), res.response, 'just a string') + await sendErrorResponse(createIncomingMessage('application/json'), res.response, 'just a string') expect(JSON.parse(res.body)).toMatchObject({ status: 500, message: 'Unknown error' }) }) @@ -114,7 +114,7 @@ describe('dev server loading screen', () => { const server = createDevServer(({ loading }) => `

${loading}

`) const res = createResponse() - server.handler(createRequest('text/html'), res as unknown as ServerResponse) + server.handler(createIncomingMessage('text/html'), res as unknown as ServerResponse) await res.finished expect(res.statusCode).toBe(503) @@ -126,7 +126,7 @@ describe('dev server loading screen', () => { const server = createDevServer(() => '

ignored

') const res = createResponse() - server.handler(createRequest('application/json'), res as unknown as ServerResponse) + server.handler(createIncomingMessage('application/json'), res as unknown as ServerResponse) await res.finished expect(res.statusCode).toBe(503) @@ -137,7 +137,7 @@ describe('dev server loading screen', () => { const server = createDevServer(() => 'loading') const res = createResponse() - server.handler(createRequest('text/html'), res as unknown as ServerResponse) + server.handler(createIncomingMessage('text/html'), res as unknown as ServerResponse) await res.finished expect(res.headers).toMatchObject({ 'cache-control': 'no-store', 'refresh': '3' }) diff --git a/packages/nuxt-cli/test/unit/dev/tui-fuzz.spec.ts b/packages/nuxt-cli/test/unit/dev/tui-fuzz.spec.ts index 313fb34e9..71916d848 100644 --- a/packages/nuxt-cli/test/unit/dev/tui-fuzz.spec.ts +++ b/packages/nuxt-cli/test/unit/dev/tui-fuzz.spec.ts @@ -4,7 +4,7 @@ import fc from 'fast-check' import { describe, expect, it } from 'vitest' import { renderPanel } from '../../../src/dev/tui/panel' -import { stripAnsi, truncate, visibleWidth } from '../../../src/dev/tui/width' +import { stripAnsi, truncate, visibleWidth } from '../../../src/utils/formatting' const RUNS = Number(process.env.NUXT_CLI_FUZZ_RUNS) || 500 diff --git a/packages/nuxt-cli/test/unit/error-channel.spec.ts b/packages/nuxt-cli/test/unit/error-channel.spec.ts index 6609e6e5e..f55655fe7 100644 --- a/packages/nuxt-cli/test/unit/error-channel.spec.ts +++ b/packages/nuxt-cli/test/unit/error-channel.spec.ts @@ -263,10 +263,12 @@ describe('formatReportForTerminal', () => { }) describe('summariseReport', () => { + const repoRoot = join(import.meta.dirname, '../../../..') + it('should carry the rendering and the topmost frame of the project', async () => { const error = new Error('summarise me') - const report = await createCliReport(error, { cwd: process.cwd() }) - const summary = await summariseReport(report, { requestId: 'r7' }) + const report = await createCliReport(error, { cwd: repoRoot }) + const summary = await summariseReport(report, { requestId: 'r7' }, repoRoot) expect(summary).toMatchObject({ id: report.id, name: 'Error', message: 'summarise me', requestId: 'r7' }) expect(summary.file).toContain('error-channel.spec.ts') diff --git a/packages/nuxt-cli/test/unit/file-watcher.spec.ts b/packages/nuxt-cli/test/unit/file-watcher.spec.ts index 141775510..027f5ca85 100644 --- a/packages/nuxt-cli/test/unit/file-watcher.spec.ts +++ b/packages/nuxt-cli/test/unit/file-watcher.spec.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { FileChangeTracker, getLocalLayerDirs } from '../../src/dev/utils' +import { FileChangeTracker, getLocalLayerDirs } from '../../src/dev/config-watcher' describe('fileWatcher', () => { let tempDir: string diff --git a/packages/nuxt-cli/test/unit/fork-pool.spec.ts b/packages/nuxt-cli/test/unit/fork-pool.spec.ts deleted file mode 100644 index 8bd1d208e..000000000 --- a/packages/nuxt-cli/test/unit/fork-pool.spec.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { ChildProcess } from 'node:child_process' -import { fileURLToPath } from 'node:url' -import { afterEach, describe, expect, it, vi } from 'vitest' - -import { ForkPool } from '../../src/dev/pool' - -const devEntry = fileURLToPath(new URL('../fixtures/fork-pool-entry.mjs', import.meta.url)) - -const pools: ForkPool[] = [] - -function createPool(poolSize?: number) { - globalThis.__nuxt_cli__ = { ...globalThis.__nuxt_cli__, devEntry } as typeof globalThis.__nuxt_cli__ - const pool = new ForkPool({ rawArgs: [], poolSize, listenOverrides: {} }) - pools.push(pool) - return pool -} - -async function waitForReady(pool: ForkPool, count: number) { - await vi.waitFor(() => { - expect(pool.getStats().ready).toBe(count) - }, { timeout: 20_000, interval: 25 }) -} - -afterEach(async () => { - for (const pool of pools.splice(0)) { - await (pool as unknown as { killAll: (signal: number) => void }).killAll(0) - } -}) - -describe('forkPool', () => { - it('should warm a single fork by default', { timeout: 30_000 }, async () => { - const pool = createPool() - pool.startWarming() - await waitForReady(pool, 1) - expect(pool.getStats().total).toBe(1) - }) - - it('should reap a cold fork on shutdown', { timeout: 30_000 }, async () => { - const pool = createPool(0) - await pool.getFork({ cwd: '/some/project', args: {} }) - expect(pool.getStats()).toMatchObject({ total: 1, active: 1 }) - - const [child] = (pool as unknown as { pool: Array<{ process: ChildProcess }> }).pool.map(f => f.process) - ;(pool as unknown as { killAll: (signal: number) => void }).killAll(0) - await vi.waitFor(() => { - expect(child!.killed).toBe(true) - }, { timeout: 10_000, interval: 25 }) - }) - - it('should keep the pool warm after handing out a fork', { timeout: 30_000 }, async () => { - const pool = createPool() - await pool.getFork({ cwd: '/some/project', args: {} }) - await waitForReady(pool, 1) - expect(pool.getStats().active).toBe(1) - }) - - it('should never warm a fork when the pool is disabled', { timeout: 30_000 }, async () => { - const pool = createPool(0) - pool.startWarming() - await pool.getFork({ cwd: '/some/project', args: {} }) - await new Promise(resolve => setTimeout(resolve, 1000)) - expect(pool.getStats()).toMatchObject({ total: 1, active: 1, ready: 0, warming: 0 }) - }) - - it('should kill every fork on shutdown', { timeout: 30_000 }, async () => { - const pool = createPool(3) - pool.startWarming() - await waitForReady(pool, 3) - - const processes = (pool as unknown as { pool: Array<{ process: ChildProcess }> }).pool.map(f => f.process) - ;(pool as unknown as { killAll: (signal: number) => void }).killAll(0) - - await vi.waitFor(() => { - for (const child of processes) { - expect(child.killed).toBe(true) - } - }, { timeout: 10_000, interval: 25 }) - expect(pool.getStats().total).toBe(0) - }) -}) diff --git a/packages/nuxt-cli/test/unit/help.spec.ts b/packages/nuxt-cli/test/unit/help.spec.ts index ad5200d96..33eb2b66d 100644 --- a/packages/nuxt-cli/test/unit/help.spec.ts +++ b/packages/nuxt-cli/test/unit/help.spec.ts @@ -25,7 +25,7 @@ async function subCommand(parent: Resolvable, name: string): Promise } describe('help', () => { - it('nuxt', async () => { + it('nuxt', { timeout: 30_000 }, async () => { expect(await usage(main)).toMatchInlineSnapshot(` "Nuxt CLI (nuxt v0.0.0) diff --git a/packages/nuxt-cli/test/unit/listen.spec.ts b/packages/nuxt-cli/test/unit/listen.spec.ts index 66342cfd7..303261772 100644 --- a/packages/nuxt-cli/test/unit/listen.spec.ts +++ b/packages/nuxt-cli/test/unit/listen.spec.ts @@ -5,7 +5,7 @@ import { networkInterfaces } from 'node:os' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { copyURL, formatDisplayURL, getNetworkAddresses, isReusePortSupported, listen, matchesBoundTarget, openBrowser, parsePort, resolveOpenCommand, validateHostname } from '../../src/dev/listen' +import { copyURL, formatDisplayURL, getNetworkAddresses, isReusePortSupported, listen, matchesBoundTarget, openBrowser, parsePort, resolveOpenCommand, validateHostname, writeClipboard } from '../../src/dev/listen' const writeText = vi.hoisted(() => vi.fn()) const isolatedEnvironment = vi.hoisted(() => ({ current: undefined as string | undefined })) @@ -443,6 +443,27 @@ describe('listener.close', () => { }) }) +describe('writeClipboard', () => { + afterEach(() => { + restoreEnvironment() + vi.clearAllMocks() + }) + + it('should not reach for a clipboard tool without a display server on linux', async () => { + stubEnvironment('linux') + + await expect(writeClipboard('text')).resolves.toBe(false) + expect(writeText).not.toHaveBeenCalled() + }) + + it('should resolve false rather than throw when the write fails', async () => { + stubEnvironment('darwin') + writeText.mockRejectedValueOnce(new Error('no clipboard tool found')) + + await expect(writeClipboard('text')).resolves.toBe(false) + }) +}) + describe('copyURL', () => { afterEach(() => { restoreEnvironment() @@ -472,13 +493,6 @@ describe('copyURL', () => { expect(writeText).toHaveBeenCalledWith('http://localhost:3000/') }) - - it('should warn rather than throw when copying fails', async () => { - stubEnvironment('darwin') - writeText.mockRejectedValueOnce(new Error('no clipboard tool found')) - - await expect(copyURL('http://localhost:3000/')).resolves.toBeUndefined() - }) }) describe('openBrowser', () => { diff --git a/packages/nuxt-cli/test/unit/per-file.spec.ts b/packages/nuxt-cli/test/unit/per-file.spec.ts index 921de996e..e61fff531 100644 --- a/packages/nuxt-cli/test/unit/per-file.spec.ts +++ b/packages/nuxt-cli/test/unit/per-file.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { perFile } from '../../src/dev/utils' +import { perFile } from '../../src/dev/config-watcher' describe('perFile', () => { it('should collapse a burst of events for one file into a single call', async () => { diff --git a/packages/nuxt-cli/test/unit/phase-reporter.spec.ts b/packages/nuxt-cli/test/unit/phase-reporter.spec.ts index 34225f0f9..6c28f3c5a 100644 --- a/packages/nuxt-cli/test/unit/phase-reporter.spec.ts +++ b/packages/nuxt-cli/test/unit/phase-reporter.spec.ts @@ -60,7 +60,7 @@ describe('phase reporter', () => { startup.update(snapshot({ phase: 'bundle', message: 'Bundling app', index: 4 })) }) - expect(screen(renderer)).toMatch(/^. Bundling app 0\.0s$/) + expect(screen(renderer)).toMatch(/^. Bundling app 0\.0ms$/) }) it('should show how long the phase has taken alongside the total', async () => { @@ -117,7 +117,7 @@ describe('phase reporter', () => { expect(screen(renderer)).toMatchInlineSnapshot(` "│ - ◆ Ready in 2.4s + ◆ Ready in 2.40s │ config 320ms · bundle 940ms" `) }) @@ -210,9 +210,9 @@ describe('phase reporter', () => { expect(screen(renderer)).toMatchInlineSnapshot(` "│ - ◆ Ready in 2.4s · compiling the first request + ◆ Ready in 2.40s · compiling the first request │ - ◆ Serving in 8.1s" + ◆ Serving in 8.10s" `) }) @@ -226,7 +226,7 @@ describe('phase reporter', () => { expect(screen(renderer)).toMatchInlineSnapshot(` "│ - ◆ Ready in 2.4s → http://localhost:3000/" + ◆ Ready in 2.40s → http://localhost:3000/" `) }) @@ -246,7 +246,7 @@ describe('phase reporter', () => { })) }) - expect(screen(renderer)).toMatch(/^. rendering GET \/ 4\.2s$/m) + expect(screen(renderer)).toMatch(/^. rendering GET \/ 4\.20s$/m) }) it('should announce a render once where the line cannot be redrawn', async () => { @@ -262,11 +262,11 @@ describe('phase reporter', () => { expect(screen(renderer)).toMatchInlineSnapshot(` "│ - ◆ Ready in 2.4s + ◆ Ready in 2.40s │ ● Rendering GET / │ - ◆ First render in 6.4s" + ◆ First render in 6.40s" `) }) @@ -303,7 +303,7 @@ describe('phase reporter', () => { startup.update(snapshot({ status: 'ready', phase: 'ready', message: 'Ready', index: 6, serving: true, elapsed: 2600 })) }) - expect(screen(renderer)).toBe('│\n◆ Ready in 2.4s') + expect(screen(renderer)).toBe('│\n◆ Ready in 2.40s') }) it('should say nothing more after a build error, which is reported separately', async () => { @@ -350,7 +350,7 @@ describe('phase reporter', () => { } }) - expect(screen(renderer)).toMatch(/^. Bundling app 0\.0s$/) + expect(screen(renderer)).toMatch(/^. Bundling app 0\.0ms$/) }) it('should clear the line before output that consola writes through `__write`', async () => { diff --git a/packages/nuxt-cli/test/unit/pool.spec.ts b/packages/nuxt-cli/test/unit/pool.spec.ts index 4c5051675..c92bda746 100644 --- a/packages/nuxt-cli/test/unit/pool.spec.ts +++ b/packages/nuxt-cli/test/unit/pool.spec.ts @@ -54,8 +54,12 @@ vi.mock('node:child_process', () => ({ fork })) const context: NuxtDevContext = { cwd: '/app', args: {} as NuxtDevContext['args'] } -function createPool() { - return new ForkPool({ rawArgs: [], listenOverrides: { port: 3000 } }) +function createPool(poolSize?: number) { + return new ForkPool({ rawArgs: [], poolSize, listenOverrides: { port: 3000 } }) +} + +function killAll(pool: ForkPool) { + (pool as unknown as { killAll: (signal: NodeJS.Signals) => void }).killAll('SIGTERM') } describe('fork pool', () => { @@ -216,4 +220,35 @@ describe('fork pool', () => { expect(onMessage).toHaveBeenCalledExactlyOnceWith({ type: 'nuxt:internal:dev:restart' }) }) + + it('should warm a single fork by default, and only once', async () => { + const pool = createPool() + pool.startWarming() + pool.startWarming() + expect(fork).toHaveBeenCalledTimes(1) + }) + + it('should hand out the warm fork and warm a replacement', async () => { + const pool = createPool() + pool.startWarming() + await Promise.resolve() + const active = await pool.getFork(context) + expect(active.pid).toBe(forks[0]!.pid) + expect(fork).toHaveBeenCalledTimes(2) + }) + + it('should never warm a fork when the pool is disabled', async () => { + const pool = createPool(0) + pool.startWarming() + await pool.getFork(context) + expect(fork).toHaveBeenCalledTimes(1) + }) + + it('should kill every fork on exit', async () => { + const pool = createPool(3) + await pool.getFork(context) + expect(forks).toHaveLength(4) + killAll(pool) + expect(forks.map(f => f.killed)).toEqual(Array.from({ length: 4 }).fill('SIGTERM')) + }) }) diff --git a/packages/nuxt-cli/test/unit/serving-state.spec.ts b/packages/nuxt-cli/test/unit/serving-state.spec.ts new file mode 100644 index 000000000..7b05feacc --- /dev/null +++ b/packages/nuxt-cli/test/unit/serving-state.spec.ts @@ -0,0 +1,85 @@ +import process from 'node:process' + +import { describe, expect, it } from 'vitest' + +import { createRequest, currentRequest, runWithRequest } from '../../src/dev/serving-state' + +describe('request attribution', () => { + const tick = () => new Promise(resolve => setTimeout(resolve, 0)) + const queue: Array<() => void> = [] + + it('has nothing to attribute a log to outside a request', () => { + expect(currentRequest()).toBeUndefined() + }) + + it('attributes work on the call stack to the request that started it', () => { + const request = createRequest('GET /about') + runWithRequest(request, () => { + expect(currentRequest()).toBeDefined() + expect(currentRequest()?.label).toBe('GET /about') + expect(currentRequest()?.id).toBe(request.id) + }) + expect(currentRequest()).toBeUndefined() + }) + + it('keeps overlapping requests apart across await points', async () => { + const seen: Array<[string, string | undefined]> = [] + const serve = async (label: string, delay: number) => { + await new Promise(resolve => setTimeout(resolve, delay)) + seen.push([label, currentRequest()?.label]) + await tick() + seen.push([label, currentRequest()?.label]) + } + + await Promise.all([ + runWithRequest(createRequest('GET /page'), () => serve('GET /page', 4)), + runWithRequest(createRequest('GET /_nuxt/app.js'), () => serve('GET /_nuxt/app.js', 1)), + runWithRequest(createRequest('GET /api/hello'), () => serve('GET /api/hello', 2)), + ]) + + expect(seen).toHaveLength(6) + for (const [label, attributed] of seen) { + expect(attributed).toBe(label) + } + }) + + it('follows a request into a nested callback the handler creates', async () => { + const attributed = await runWithRequest(createRequest('GET /nested'), () => new Promise((resolve) => { + process.nextTick(() => { + setImmediate(() => { + queueMicrotask(() => resolve(currentRequest()?.label)) + }) + }) + })) + expect(attributed).toBe('GET /nested') + }) + + it('gives a request an identity that cannot be guessed from its route or its neighbours', () => { + const ids = Array.from({ length: 50 }, () => createRequest('GET /boom-page').id) + const value = (id: string) => BigInt(`0x${id.replaceAll('-', '')}`) + + expect(new Set(ids).size).toBe(ids.length) + for (const id of ids) { + expect(id.replaceAll('-', '')).toMatch(/^[0-9a-f]{32}$/) + expect(id).not.toContain('boom-page') + expect(id).not.toContain('GET') + } + const distances = ids.slice(1).map((id, index) => value(id) - value(ids[index]!)) + expect(new Set(distances.map(String)).size).toBe(distances.length) + }) + + it('does not attribute work that has left the request context', async () => { + let escaped: string | undefined = 'unset' + runWithRequest(createRequest('GET /leaky'), () => { + queue.push(() => { + escaped = currentRequest()?.label + }) + }) + const queued = queue.splice(0) + for (const run of queued) { + run() + } + await tick() + expect(escaped).toBeUndefined() + }) +}) diff --git a/packages/nuxt-cli/test/unit/utils/catalog.spec.ts b/packages/nuxt-cli/test/unit/utils/catalog.spec.ts index ed97c31c1..7fb556284 100644 --- a/packages/nuxt-cli/test/unit/utils/catalog.spec.ts +++ b/packages/nuxt-cli/test/unit/utils/catalog.spec.ts @@ -52,7 +52,7 @@ describe('catalog resolution', () => { ' typescript: ^5.9.0', ].join('\n')) - expect(readCatalogConfig(tempDir)?.catalogs).toEqual({ + expect(readCatalogConfig(tempDir)).toEqual({ default: { nuxt: '^4.2.0' }, dev: { typescript: '^5.9.0' }, }) @@ -160,10 +160,10 @@ describe('updateCatalogEntries', () => { it('should invalidate cached catalog config after a write', async () => { await writeFile(join(tempDir, 'pnpm-workspace.yaml'), 'catalog:\n nuxt: ^4.1.0\n') - expect(readCatalogConfig(tempDir)?.catalogs.default).toEqual({ nuxt: '^4.1.0' }) + expect(readCatalogConfig(tempDir)?.default).toEqual({ nuxt: '^4.1.0' }) updateCatalogEntries(tempDir, [{ catalog: 'default', pkg: 'nuxt', specifier: '^4.2.0' }]) - expect(readCatalogConfig(tempDir)?.catalogs.default).toEqual({ nuxt: '^4.2.0' }) + expect(readCatalogConfig(tempDir)?.default).toEqual({ nuxt: '^4.2.0' }) }) it('should keep an anchor when updating the entry that defines it', async () => { diff --git a/packages/nuxt-cli/test/unit/utils/config-property.spec.ts b/packages/nuxt-cli/test/unit/utils/config-property.spec.ts deleted file mode 100644 index 55768eee9..000000000 --- a/packages/nuxt-cli/test/unit/utils/config-property.spec.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { mkdir, mkdtemp, readFile, realpath, symlink, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { fileURLToPath } from 'node:url' - -import { dirname, join } from 'pathe' -import { parseSync } from 'rolldown/utils' -import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' - -import { addNuxtConfigEntries, readNuxtConfig, removeNuxtConfigEntries } from '../../../src/utils/config' - -const rolldownPath = dirname(fileURLToPath(import.meta.resolve('rolldown/package.json'))) - -interface Shape { - name: string - source: string - modules: string[] -} - -const WRAPPERS: [name: string, wrap: (object: string) => string][] = [ - ['defineNuxtConfig', object => `export default defineNuxtConfig(${object})`], - ['plain object', object => `export default ${object}`], - ['satisfies', object => `export default defineNuxtConfig(${object}) satisfies NuxtConfig`], - ['as const', object => `export default defineNuxtConfig(${object}) as NuxtConfig`], -] - -const ENTRIES: [name: string, entries: string[], modules: string[]][] = [ - ['no modules key', [], []], - ['empty array', [], []], - ['one entry', ['@nuxt/eslint'], ['@nuxt/eslint']], - ['three entries', ['@nuxt/eslint', '@nuxt/image', '@nuxt/fonts'], ['@nuxt/eslint', '@nuxt/image', '@nuxt/fonts']], - ['array-form entry', ['[\'@nuxt/image\', { quality: 80 }]'], ['@nuxt/image']], - ['mixed entries', ['@nuxt/eslint', '[\'@nuxt/image\', { quality: 80 }]'], ['@nuxt/eslint', '@nuxt/image']], -] - -const LAYOUTS: [name: string, render: (entries: string[], quote: string, indent: string, trailingComma: boolean) => string][] = [ - ['inline', (entries, quote, _indent, trailingComma) => `[${entries.map(entry => quoted(entry, quote)).join(', ')}${entries.length && trailingComma ? ',' : ''}]`], - ['multi-line', (entries, quote, indent, trailingComma) => entries.length === 0 - ? '[]' - : `[\n${entries.map(entry => `${indent}${indent}${quoted(entry, quote)}`).join(',\n')}${trailingComma ? ',' : ''}\n${indent}]`], -] - -function quoted(entry: string, quote: string): string { - return entry.startsWith('[') ? entry.replaceAll('\'', quote) : `${quote}${entry}${quote}` -} - -function* shapes(): Generator { - for (const [wrapperName, wrap] of WRAPPERS) { - for (const [entriesName, entries, modules] of ENTRIES) { - for (const [layoutName, render] of LAYOUTS) { - for (const quote of ['\'', '"']) { - for (const indent of [' ', ' ', '\t']) { - for (const trailingComma of [true, false]) { - const hasKey = entriesName !== 'no modules key' - const body = [ - `${indent}ssr: true,`, - hasKey ? `${indent}modules: ${render(entries, quote, indent, trailingComma)},` : '', - `${indent}// keep me`, - `${indent}devtools: { enabled: true },`, - ].filter(Boolean).join('\n') - yield { - name: `${wrapperName} / ${entriesName} / ${layoutName} / ${quote === '\'' ? 'single' : 'double'} / ${indent === '\t' ? 'tab' : `${indent.length} spaces`} / ${trailingComma ? 'trailing comma' : 'no trailing comma'}`, - source: `${wrap(`{\n${body}\n}`)}\n`, - modules, - } - } - } - } - } - } - } -} - -/** Read the `modules` list straight from the parser, independently of the editor. */ -function readModules(source: string): string[] { - const { program, errors } = parseSync('nuxt.config.ts', source) - if (errors.length) { - throw new Error(`config no longer parses: ${JSON.stringify(errors[0])}`) - } - const exported = (program.body as any[]).find(node => node.type === 'ExportDefaultDeclaration') - let object = exported?.declaration - while (object && ['TSAsExpression', 'TSSatisfiesExpression', 'ParenthesizedExpression'].includes(object.type)) { - object = object.expression - } - if (object?.type === 'CallExpression') { - object = object.arguments[0] - } - const property = (object?.properties as any[] | undefined)?.find(entry => entry.type === 'Property' && entry.key?.name === 'modules') - if (!property) { - return [] - } - return (property.value.elements as any[]).map((element) => { - const target = element?.type === 'ArrayExpression' ? element.elements[0] : element - return target?.value as string - }) -} - -const directories: string[] = [] - -async function createProject(parser: boolean): Promise { - const cwd = await realpath(await mkdtemp(join(tmpdir(), 'nuxi-config-property-'))) - directories.push(cwd) - if (parser) { - await mkdir(join(cwd, 'node_modules'), { recursive: true }) - await symlink(rolldownPath, join(cwd, 'node_modules/rolldown'), 'dir') - } - return cwd -} - -afterEach(() => { - vi.unstubAllEnvs() -}) - -afterAll(async () => { - const { rm } = await import('node:fs/promises') - await Promise.all(directories.splice(0).map(directory => rm(directory, { recursive: true, force: true }))) -}) - -describe.each([ - ['scanner', false], - ['parser', true], -])('config editing invariants (%s)', (engine, useParser) => { - it('should preserve the module list through an add and a remove', async () => { - if (!useParser) { - vi.stubEnv('NUXT_CLI_PARSER', 'scanner') - } - const cwd = await createProject(useParser) - const file = join(cwd, 'nuxt.config.ts') - const refused: string[] = [] - let checked = 0 - - for (const shape of shapes()) { - await writeFile(file, shape.source, 'utf8') - - let config - try { - config = await readNuxtConfig(cwd) - await addNuxtConfigEntries(config!, { modules: ['@nuxt/test-utils', '@nuxt/eslint'] }) - } - catch (error) { - refused.push(`${shape.name}: ${(error as Error).message}`) - expect(await readFile(file, 'utf8'), shape.name).toBe(shape.source) - continue - } - - const added = [...new Set([...shape.modules, '@nuxt/test-utils', '@nuxt/eslint'])] - const afterAdd = await readFile(file, 'utf8') - expect(readModules(afterAdd), `add: ${shape.name}`).toEqual(added) - expect(afterAdd, `add: ${shape.name}`).toContain('// keep me') - expect(afterAdd, `add: ${shape.name}`).toContain('ssr: true') - - await removeNuxtConfigEntries((await readNuxtConfig(cwd))!, { modules: ['@nuxt/test-utils', '@nuxt/fonts'] }) - - const afterRemove = await readFile(file, 'utf8') - expect(readModules(afterRemove), `remove: ${shape.name}`).toEqual(added.filter(name => name !== '@nuxt/test-utils' && name !== '@nuxt/fonts')) - expect(afterRemove, `remove: ${shape.name}`).toContain('devtools: { enabled: true }') - checked++ - } - - expect(checked, `${engine} refusals: ${refused.join('\n')}`).toBeGreaterThan(0) - expect(refused).toEqual([]) - }, 60_000) - - it('should keep CRLF endings and a nested array entry intact', async () => { - if (!useParser) { - vi.stubEnv('NUXT_CLI_PARSER', 'scanner') - } - const cwd = await createProject(useParser) - const file = join(cwd, 'nuxt.config.ts') - const source = 'export default defineNuxtConfig({\r\n modules: [\r\n [\'@nuxt/image\', { quality: 80 }],\r\n ],\r\n})\r\n' - await writeFile(file, source, 'utf8') - - await addNuxtConfigEntries((await readNuxtConfig(cwd))!, { modules: ['@nuxt/fonts'] }) - - const after = await readFile(file, 'utf8') - expect(after.split('\n').every(line => line === '' || line.endsWith('\r'))).toBe(true) - expect(after).toContain('{ quality: 80 }') - expect(readModules(after)).toEqual(['@nuxt/image', '@nuxt/fonts']) - }) -}) diff --git a/packages/nuxt-cli/test/unit/utils/formatting.spec.ts b/packages/nuxt-cli/test/unit/utils/formatting.spec.ts index 843318bdd..10d061e10 100644 --- a/packages/nuxt-cli/test/unit/utils/formatting.spec.ts +++ b/packages/nuxt-cli/test/unit/utils/formatting.spec.ts @@ -1,25 +1,58 @@ import { describe, expect, it } from 'vitest' -import { formatDuration } from '../../../src/utils/formatting' +import { formatDuration, truncate, visibleWidth } from '../../../src/utils/formatting' + +describe('visibleWidth', () => { + it('should ignore colour and hyperlink escapes', () => { + expect(visibleWidth('\u001B[31mabc\u001B[39m')).toBe(3) + expect(visibleWidth(`\u001B]8;;https://nuxt.com\u0007nuxt\u001B]8;;\u0007`)).toBe(4) + }) + + it('should count wide characters as two columns', () => { + expect(visibleWidth('✨ ready')).toBe(8) + expect(visibleWidth('🚀')).toBe(2) + expect(visibleWidth('日本語')).toBe(6) + expect(visibleWidth('full')).toBe(8) + }) + + it('should count text-presentation symbols and braille as one column', () => { + expect(visibleWidth('⚠ ✔ ℹ ✗')).toBe(7) + expect(visibleWidth('⣠⣦⣠⡀ ━━ ·')).toBe(9) + }) + + it('should not count combining marks or variation selectors', () => { + expect(visibleWidth('e\u0301')).toBe(1) + expect(visibleWidth('⚠\uFE0F')).toBe(1) + }) +}) + +describe('truncate', () => { + it('should cut by columns rather than characters', () => { + expect(truncate('日本語テキスト', 7)).toBe('日本語…') + expect(truncate('日本語テキスト', 8)).toBe('日本語…') + }) +}) describe('formatDuration', () => { - it('should render sub-second durations in milliseconds', () => { - expect(formatDuration(0)).toBe('0ms') + it('should render milliseconds below a second', () => { + expect(formatDuration(-1)).toBe('0.0ms') + expect(formatDuration(0.42)).toBe('0.4ms') expect(formatDuration(12.4)).toBe('12ms') expect(formatDuration(999)).toBe('999ms') }) - it('should render seconds above a second', () => { - expect(formatDuration(1000)).toBe('1s') + it('should render seconds with a fixed number of decimals', () => { + expect(formatDuration(1000)).toBe('1.00s') expect(formatDuration(1234)).toBe('1.23s') - expect(formatDuration(9999)).toBe('10s') - expect(formatDuration(12_300)).toBe('12.3s') - expect(formatDuration(59_000)).toBe('59s') + expect(formatDuration(9999)).toBe('9.99s') + expect(formatDuration(10_000)).toBe('10.0s') + expect(formatDuration(59_999)).toBe('59.9s') }) - it('should render minutes and seconds above a minute', () => { - expect(formatDuration(60_000)).toBe('1m') + it('should render minutes and hours with padded seconds', () => { + expect(formatDuration(60_000)).toBe('1m 00s') expect(formatDuration(90_000)).toBe('1m 30s') - expect(formatDuration(3_600_000)).toBe('60m') + expect(formatDuration(3_600_000)).toBe('1h 00m 00s') + expect(formatDuration(3_723_000)).toBe('1h 02m 03s') }) }) diff --git a/packages/nuxt-cli/test/unit/utils/install.spec.ts b/packages/nuxt-cli/test/unit/utils/install.spec.ts index b7b2057cc..5e460e943 100644 --- a/packages/nuxt-cli/test/unit/utils/install.spec.ts +++ b/packages/nuxt-cli/test/unit/utils/install.spec.ts @@ -109,7 +109,7 @@ describe('runInstall', () => { expect(result.ignoredBuilds).toEqual(['esbuild@0.28.1']) }) - it('should report a missing package manager instead of throwing', async () => { + it('should report a missing package manager instead of throwing when installing', async () => { vi.stubEnv('PATH', join(tmpdir(), 'nuxt-cli-nonexistent-bin')) const result = await runInstall({ cwd: tmpdir(), @@ -199,7 +199,7 @@ describe('runDedupe', () => { expect(result.error).toBe('Deduplication is not supported for bun') }) - it('should report a missing package manager instead of throwing', async () => { + it('should report a missing package manager instead of throwing when deduping', async () => { vi.stubEnv('PATH', join(tmpdir(), 'nuxt-cli-nonexistent-bin')) const result = await runDedupe({ cwd: tmpdir(), diff --git a/packages/nuxt-cli/test/unit/utils/logger.spec.ts b/packages/nuxt-cli/test/unit/utils/logger.spec.ts index 131ca699b..8e7f442f7 100644 --- a/packages/nuxt-cli/test/unit/utils/logger.spec.ts +++ b/packages/nuxt-cli/test/unit/utils/logger.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { stripAnsi } from '../../../src/dev/tui/width' +import { stripAnsi } from '../../../src/utils/formatting' import { logger } from '../../../src/utils/logger' const environment = vi.hoisted(() => ({ isCI: false })) diff --git a/packages/nuxt-cli/test/unit/utils/network.spec.ts b/packages/nuxt-cli/test/unit/utils/network.spec.ts index 1598c7d8e..b56d6be52 100644 --- a/packages/nuxt-cli/test/unit/utils/network.spec.ts +++ b/packages/nuxt-cli/test/unit/utils/network.spec.ts @@ -24,15 +24,11 @@ vi.mock('../../../src/utils/logger', () => ({ debug: () => {}, })) -const { classifyNetworkError, describeNetworkError, formatRetryCommand, getProxyHint, hasProxyEnv, isEnvProxyActive, logNetworkError, probeNetworkError, setupProxySupport, supportsEnvProxy } = await import('../../../src/utils/network') +const { classifyNetworkError, describeNetworkError, formatRetryCommand, getProxyHint, hasProxyEnv, isEnvProxyActive, logNetworkError, probeNetworkError, setupProxySupport } = await import('../../../src/utils/network') const { fetchJson } = await import('../../../src/utils/fetch') const NUXI_ARGV = ['/usr/bin/node', '/project/node_modules/.bin/nuxi.mjs', 'init', 'my app'] -/** Stand in for the flags Node.js accepts, so tests do not depend on the runtime. */ -const MODERN_NODE = new Set(['--use-env-proxy']) -const OLD_NODE = new Set() - function clean(message: string) { return stripVTControlCharacters(message) } @@ -81,7 +77,7 @@ describe('setupProxySupport', () => { it('propagates proxy support to child processes', () => { const env = { HTTP_PROXY: 'http://localhost:3128' } as NodeJS.ProcessEnv - expect(setupProxySupport(env, MODERN_NODE, null)).toBe('children-only') + expect(setupProxySupport(env, null)).toBe('children-only') expect(env.NODE_USE_ENV_PROXY).toBe('1') }) @@ -89,11 +85,11 @@ describe('setupProxySupport', () => { const proxy = await startTunnelProxy() try { const env = { HTTP_PROXY: proxy.proxyUrl, NO_PROXY: '127.0.0.1' } as NodeJS.ProcessEnv - expect(setupProxySupport(env, MODERN_NODE)).toBe('active') + expect(setupProxySupport(env)).toBe('active') expect(await fetch('http://nuxt.invalid/').then(r => r.text())).toBe('ok') expect(await fetch(proxy.targetUrl).then(r => r.text())).toBe('ok') expect(proxy.tunnelled).toEqual(['nuxt.invalid:80']) - expect(getProxyHint('refused', { env, flags: MODERN_NODE })).toBeUndefined() + expect(getProxyHint('refused', { env })).toBeUndefined() } finally { proxy.close() @@ -102,31 +98,17 @@ describe('setupProxySupport', () => { it('does not enable the proxy when NODE_USE_ENV_PROXY is explicitly disabled', () => { const enable = vi.fn() - expect(setupProxySupport({ HTTPS_PROXY: 'http://localhost:3128', NODE_USE_ENV_PROXY: '0' }, MODERN_NODE, enable)).toBe('children-only') + expect(setupProxySupport({ HTTPS_PROXY: 'http://localhost:3128', NODE_USE_ENV_PROXY: '0' }, enable)).toBe('children-only') expect(enable).not.toHaveBeenCalled() }) - - it('reports Node.js versions that cannot use the proxy', () => { - const env = { HTTP_PROXY: 'http://localhost:3128' } as NodeJS.ProcessEnv - expect(setupProxySupport(env, OLD_NODE)).toBe('unsupported') - expect(env.NODE_USE_ENV_PROXY).toBeUndefined() - }) - - it('detects support from the flags Node.js accepts', () => { - expect(supportsEnvProxy(new Set(['--use-env-proxy']))).toBe(true) - expect(supportsEnvProxy(new Set(['--enable-source-maps']))).toBe(false) - expect(supportsEnvProxy({ has: () => false })).toBe(false) - }) - it('reports the current process as proxy-aware when launched with the flag', () => { - expect(isEnvProxyActive({ NODE_USE_ENV_PROXY: '1' }, [], MODERN_NODE)).toBe(true) - expect(isEnvProxyActive({ NODE_OPTIONS: '--use-env-proxy' }, [], MODERN_NODE)).toBe(true) - expect(isEnvProxyActive({ NODE_OPTIONS: '--max-old-space-size=4096 --use-env-proxy' }, [], MODERN_NODE)).toBe(true) - expect(isEnvProxyActive({ NODE_OPTIONS: '--require=/tmp/--use-env-proxy.js' }, [], MODERN_NODE)).toBe(false) - expect(isEnvProxyActive({}, ['--use-env-proxy'], MODERN_NODE)).toBe(true) - expect(isEnvProxyActive({ HTTPS_PROXY: 'http://localhost:3128' }, [], MODERN_NODE)).toBe(false) - expect(isEnvProxyActive({ NODE_USE_ENV_PROXY: '1' }, [], OLD_NODE)).toBe(false) - expect(setupProxySupport({ HTTPS_PROXY: 'http://localhost:3128', NODE_USE_ENV_PROXY: '1' }, MODERN_NODE)).toBe('active') + expect(isEnvProxyActive({ NODE_USE_ENV_PROXY: '1' }, [])).toBe(true) + expect(isEnvProxyActive({ NODE_OPTIONS: '--use-env-proxy' }, [])).toBe(true) + expect(isEnvProxyActive({ NODE_OPTIONS: '--max-old-space-size=4096 --use-env-proxy' }, [])).toBe(true) + expect(isEnvProxyActive({ NODE_OPTIONS: '--require=/tmp/--use-env-proxy.js' }, [])).toBe(false) + expect(isEnvProxyActive({}, ['--use-env-proxy'])).toBe(true) + expect(isEnvProxyActive({ HTTPS_PROXY: 'http://localhost:3128' }, [])).toBe(false) + expect(setupProxySupport({ HTTPS_PROXY: 'http://localhost:3128', NODE_USE_ENV_PROXY: '1' })).toBe('active') }) }) @@ -269,7 +251,7 @@ describe.skipIf(!hasOpenSSL)('describeNetworkError with an untrusted certificate it('advises a root certificate for a self-signed chain, whatever the proxy state', async () => { const err = await captureError(() => fetch(url)) - const hint = clean(getProxyHint(classifyNetworkError(err).kind, { argv: NUXI_ARGV, env: {}, windows: false, flags: MODERN_NODE })!) + const hint = clean(getProxyHint(classifyNetworkError(err).kind, { argv: NUXI_ARGV, env: {}, windows: false })!) expect(hint).toContain('NODE_EXTRA_CA_CERTS=/path/to/corporate-ca.pem') }) }) @@ -327,7 +309,15 @@ describe('describeNetworkError', () => { describe('formatRetryCommand', () => { it('rebuilds the invocation with an environment prefix', () => { expect(formatRetryCommand({ NODE_USE_ENV_PROXY: '1' }, { argv: NUXI_ARGV, env: {}, windows: false })) - .toBe('NODE_USE_ENV_PROXY=1 nuxt init "my app"') + .toBe('NODE_USE_ENV_PROXY=1 nuxt init \'my app\'') + }) + + it('quotes arguments so the shell does not expand them', () => { + const argv = ['/usr/bin/node', '/project/node_modules/.bin/nuxi.mjs', 'init', '$HOME', '`id`', 'it\'s'] + expect(formatRetryCommand({ NODE_USE_ENV_PROXY: '1' }, { argv, env: {}, windows: false })) + .toBe(`NODE_USE_ENV_PROXY=1 nuxt init '$HOME' '\`id\`' 'it'\\''s'`) + expect(formatRetryCommand({ NODE_USE_ENV_PROXY: '1' }, { argv: [...argv.slice(0, 3), 'C:\\my app\\'], env: {}, windows: true })) + .toBe('set NODE_USE_ENV_PROXY=1 && nuxt init "C:\\my app\\\\"') }) it('uses PowerShell or cmd syntax on Windows', () => { @@ -355,26 +345,17 @@ describe('getProxyHint', () => { it('points out when a configured proxy is not in use', () => { const env = { HTTPS_PROXY: 'http://localhost:3128' } - const hint = clean(getProxyHint('dns', { argv: NUXI_ARGV, env, windows: false, flags: MODERN_NODE })!) - expect(hint).toContain('NODE_USE_ENV_PROXY=1 nuxt init "my app"') + const hint = clean(getProxyHint('dns', { argv: NUXI_ARGV, env, windows: false })!) + expect(hint).toContain('NODE_USE_ENV_PROXY=1 nuxt init \'my app\'') }) - - it('asks for a Node.js upgrade when the flag is unavailable', () => { - const env = { HTTPS_PROXY: 'http://localhost:3128' } - const hint = clean(getProxyHint('dns', { argv: NUXI_ARGV, env, windows: false, flags: OLD_NODE })!) - expect(hint).toContain('cannot use it') - expect(hint).toContain('Node.js 24 (or 22.18+)') - expect(hint).not.toContain('Retry with') - }) - it('stays quiet when the proxy is already in use', () => { const env = { HTTPS_PROXY: 'http://localhost:3128', NODE_USE_ENV_PROXY: '1' } - expect(getProxyHint('dns', { env, flags: MODERN_NODE })).toBeUndefined() + expect(getProxyHint('dns', { env })).toBeUndefined() }) it('suggests a root certificate for intercepted TLS', () => { const hint = clean(getProxyHint('tls', { argv: NUXI_ARGV, env: {}, windows: false })!) - expect(hint).toContain('NODE_EXTRA_CA_CERTS=/path/to/corporate-ca.pem nuxt init "my app"') + expect(hint).toContain('NODE_EXTRA_CA_CERTS=/path/to/corporate-ca.pem nuxt init \'my app\'') }) it('suggests credentials for a 407', () => { @@ -387,14 +368,14 @@ describe('getProxyHint', () => { it('suspects TLS interception when a proxy in use resets the connection', () => { const env = { HTTPS_PROXY: 'http://localhost:3128', NODE_USE_ENV_PROXY: '1' } - const hint = clean(getProxyHint('reset', { argv: NUXI_ARGV, env, windows: false, flags: MODERN_NODE })!) + const hint = clean(getProxyHint('reset', { argv: NUXI_ARGV, env, windows: false })!) expect(hint).toContain('re-signing TLS traffic') - expect(hint).toContain('NODE_EXTRA_CA_CERTS=/path/to/corporate-ca.pem nuxt init "my app"') + expect(hint).toContain('NODE_EXTRA_CA_CERTS=/path/to/corporate-ca.pem nuxt init \'my app\'') }) it('prefers the proxy-not-in-use hint over the certificate hint', () => { const env = { HTTPS_PROXY: 'http://localhost:3128' } - expect(clean(getProxyHint('reset', { argv: NUXI_ARGV, env, windows: false, flags: MODERN_NODE })!)) + expect(clean(getProxyHint('reset', { argv: NUXI_ARGV, env, windows: false })!)) .toContain('NODE_USE_ENV_PROXY=1') }) }) diff --git a/packages/nuxt-cli/test/unit/utils/package-managers.spec.ts b/packages/nuxt-cli/test/unit/utils/package-managers.spec.ts index 5050d8740..05e25444a 100644 --- a/packages/nuxt-cli/test/unit/utils/package-managers.spec.ts +++ b/packages/nuxt-cli/test/unit/utils/package-managers.spec.ts @@ -38,11 +38,11 @@ describe('getLockFiles', () => { }) describe('getPackageManagerVersion', () => { - it('returns the command version', () => { - expect(getPackageManagerVersion(process.execPath)).toBe(process.version) + it('returns the command version', async () => { + await expect(getPackageManagerVersion(process.execPath)).resolves.toBe(process.version) }) - it('does not fail when the package manager is unavailable', () => { - expect(getPackageManagerVersion('nuxt-cli-missing-package-manager')).toBe('unknown') + it('does not fail when the package manager is unavailable', async () => { + await expect(getPackageManagerVersion('nuxt-cli-missing-package-manager')).resolves.toBe('unknown') }) }) diff --git a/packages/nuxt-cli/test/unit/utils/paths.spec.ts b/packages/nuxt-cli/test/unit/utils/paths.spec.ts index cd40227cb..83ca836b5 100644 --- a/packages/nuxt-cli/test/unit/utils/paths.spec.ts +++ b/packages/nuxt-cli/test/unit/utils/paths.spec.ts @@ -58,7 +58,7 @@ describe('resolveRootDir', () => { describe('resolveProjectDir', () => { it('should follow the ROOTDIR positional a command will run against', () => { - expect(resolveProjectDir({ cwd: '.', _: ['dev', 'packages'] })).toBe(resolve('packages')) + expect(resolveProjectDir({ cwd: '.', _: ['dev', import.meta.dirname] })).toBe(resolve(import.meta.dirname)) }) it('should fall back to the working directory without a positional', () => { diff --git a/packages/nuxt-cli/test/unit/utils/release-notes.spec.ts b/packages/nuxt-cli/test/unit/utils/release-notes.spec.ts new file mode 100644 index 000000000..0f751c1d5 --- /dev/null +++ b/packages/nuxt-cli/test/unit/utils/release-notes.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' + +import { terminalLink } from '../../../src/utils/formatting' +import { releaseNotesUrl } from '../../../src/utils/update-check' + +describe('release notes links', () => { + it('points at the tag for packages with a known repository', () => { + expect(releaseNotesUrl('nuxt', '4.6.0')).toBe('https://github.com/nuxt/nuxt/releases/tag/v4.6.0') + expect(releaseNotesUrl('@nuxt/cli', '3.1.0')).toBe('https://github.com/nuxt/cli/releases/tag/v3.1.0') + }) + + it('has nothing to link for nightlies or unknown packages', () => { + expect(releaseNotesUrl('nuxt', '4.6.0-nightly.20240101')).toBeUndefined() + expect(releaseNotesUrl('some-other-package', '1.0.0')).toBeUndefined() + }) + + it('emits a hyperlink only where the terminal supports one', () => { + expect(terminalLink('4.6.0', 'https://example.com', { stream: { isTTY: false } })).toBe('4.6.0') + }) +}) diff --git a/packages/nuxt-cli/test/unit/utils/server-build.spec.ts b/packages/nuxt-cli/test/unit/utils/server-build.spec.ts index 35f21b750..befd0fedd 100644 --- a/packages/nuxt-cli/test/unit/utils/server-build.spec.ts +++ b/packages/nuxt-cli/test/unit/utils/server-build.spec.ts @@ -61,7 +61,7 @@ describe('getServerBuilderName', () => { }) it('should use the label Nuxt declares', () => { - const nuxt = makeNuxt({ server: { builder: 'vite' } }, { serverBuild: descriptor({ name: 'vite', label: 'Vite SPA' }) }) + const nuxt = makeNuxt({ server: { builder: 'vite' } }, { serverBuild: descriptor({ label: 'Vite SPA' }) }) expect(getServerBuilderName(nuxt)).toBe('Vite SPA') }) }) @@ -70,7 +70,7 @@ describe('resolveServerBuild', () => { it('should read output paths from the Nitro instance', () => { const build = resolveServerBuild(nitroKit, makeNuxt()) - expect(build).toMatchObject({ name: 'nitro', label: 'Nitro', declared: false, hasServer: true }) + expect(build).toMatchObject({ label: 'Nitro', declared: false }) expect(build.target).toBe('node-server') expect(build.previewCommand).toBe('node ./server/index.mjs') expect(build.dir).toBe('/project/.output') @@ -91,7 +91,7 @@ describe('resolveServerBuild', () => { }) const build = resolveServerBuild(nitroKit, nuxt) - expect(build).toMatchObject({ name: 'nitro', label: 'Nitro', targetLabel: 'preset', declared: true, hasServer: true, hasDevServer: true }) + expect(build).toMatchObject({ label: 'Nitro', targetLabel: 'preset', declared: true, hasDevServer: true }) expect(build.target).toBe('vercel') expect(build.previewCommand).toBe('node ./server/index.mjs') expect(build.dir).toBe('/project/.vercel/output') @@ -116,7 +116,7 @@ describe('resolveServerBuild', () => { const build = resolveServerBuild({ useNitro }, nuxt) - expect(build).toMatchObject({ name: 'vite', label: 'Vite SPA', declared: true, hasServer: false, hasDevServer: true }) + expect(build).toMatchObject({ label: 'Vite SPA', declared: true, hasDevServer: true }) expect(build.target).toBeUndefined() expect(build.previewCommand).toBeUndefined() expect(build.previewStaticDir).toBe('/project/.output/public') @@ -127,7 +127,7 @@ describe('resolveServerBuild', () => { const nuxt = makeNuxt({}, { serverBuild: descriptor({ name: '@nuxt/nitro-server' }) }) const build = resolveServerBuild(nitroKit, nuxt) - expect(build).toMatchObject({ name: 'nitro', label: 'Nitro', declared: true, hasServer: true }) + expect(build).toMatchObject({ label: 'Nitro', declared: true }) expect(build.target).toBe('node-server') expect(build.previewCommand).toBe('node ./server/index.mjs') expect(build.previewStaticDir).toBeUndefined() @@ -151,7 +151,7 @@ describe('resolveServerBuild', () => { it('should fall back to defaults when there is no server', () => { const build = resolveServerBuild(serverlessKit, makeNuxt({ server: { builder: 'vite' } })) - expect(build).toMatchObject({ name: 'vite', label: 'vite', declared: false, hasServer: false }) + expect(build).toMatchObject({ label: 'vite', declared: false }) expect(build.target).toBeUndefined() expect(build.dir).toBe('/project/.output') expect(build.publicDir).toBe('/project/.output/public') @@ -169,12 +169,12 @@ describe('resolveServerBuild', () => { } const build = resolveServerBuild(kit, makeNuxt()) - expect(build).toMatchObject({ name: 'unknown', label: 'unknown', hasServer: false }) + expect(build).toMatchObject({ label: 'unknown' }) expect(build.dir).toBe('/project/.output') nitro = { options: { preset: 'vercel', output: { dir: '/project/.vercel/output' }, commands: { preview: 'vercel dev' } } } - expect(build).toMatchObject({ name: 'nitro', label: 'Nitro', hasServer: true }) + expect(build).toMatchObject({ label: 'Nitro' }) expect(build.target).toBe('vercel') expect(build.dir).toBe('/project/.vercel/output') expect(build.previewCommand).toBe('vercel dev') diff --git a/packages/nuxt-cli/test/unit/utils/suggest-fuzz.spec.ts b/packages/nuxt-cli/test/unit/utils/suggest-fuzz.spec.ts index d27011fd5..7b20b4dc7 100644 --- a/packages/nuxt-cli/test/unit/utils/suggest-fuzz.spec.ts +++ b/packages/nuxt-cli/test/unit/utils/suggest-fuzz.spec.ts @@ -6,7 +6,7 @@ import fc from 'fast-check' import { describe, expect, it } from 'vitest' import { commands } from '../../../src/commands' -import { suggestCommand } from '../../../src/utils/suggest-command' +import { suggestCommand } from '../../../src/utils/suggest' import { findUnknownFlags, replaceFlag, suggestFlags } from '../../../src/utils/unknown-args' const RUNS = Number(process.env.NUXT_CLI_FUZZ_RUNS) || 200 diff --git a/packages/nuxt-cli/test/unit/utils/suggest.spec.ts b/packages/nuxt-cli/test/unit/utils/suggest.spec.ts index 50ff512e1..8071963d3 100644 --- a/packages/nuxt-cli/test/unit/utils/suggest.spec.ts +++ b/packages/nuxt-cli/test/unit/utils/suggest.spec.ts @@ -1,8 +1,7 @@ import { describe, expect, it } from 'vitest' import { commands } from '../../../src/commands' -import { commandPolicy, flagPolicy, suggestClosest } from '../../../src/utils/suggest' -import { suggestCommand } from '../../../src/utils/suggest-command' +import { suggestCommand, suggestFlag } from '../../../src/utils/suggest' const names = Object.keys(commands).filter(name => !name.startsWith('_')) @@ -44,25 +43,25 @@ describe('suggestCommand', () => { }) }) -describe('suggestClosest', () => { +describe('suggestFlag', () => { const candidates = ['dotenv', 'logLevel', 'strictPort', 'port'] it('matches a differently cased spelling', async () => { - await expect(suggestClosest('loglevel', candidates, flagPolicy)).resolves.toBe('logLevel') + await expect(suggestFlag('loglevel', candidates)).resolves.toBe('logLevel') }) it('leaves an exact candidate alone', async () => { - await expect(suggestClosest('logLevel', candidates, flagPolicy)).resolves.toBeUndefined() + await expect(suggestFlag('logLevel', candidates)).resolves.toBeUndefined() }) it('holds command suggestions to a higher bar than flag suggestions', async () => { - await expect(suggestClosest('dtnv', candidates, flagPolicy)).resolves.toBe('dotenv') - await expect(suggestClosest('dtnv', candidates, commandPolicy)).resolves.toBeUndefined() + await expect(suggestFlag('dtnv', candidates)).resolves.toBe('dotenv') + await expect(suggestCommand('dtnv', candidates)).resolves.toBeUndefined() }) it('rejects a tie only when the policy asks it to', async () => { const tied = ['task', 'test'] - await expect(suggestClosest('tesk', tied, commandPolicy)).resolves.toBeUndefined() - await expect(suggestClosest('tesk', tied, flagPolicy)).resolves.toBe('task') + await expect(suggestCommand('tesk', tied)).resolves.toBeUndefined() + await expect(suggestFlag('tesk', tied)).resolves.toBe('task') }) }) diff --git a/packages/nuxt-cli/test/unit/utils/terminal-theme.spec.ts b/packages/nuxt-cli/test/unit/utils/terminal-theme.spec.ts new file mode 100644 index 000000000..d5fbafcf5 --- /dev/null +++ b/packages/nuxt-cli/test/unit/utils/terminal-theme.spec.ts @@ -0,0 +1,117 @@ +import process from 'node:process' + +import { describe, expect, it } from 'vitest' + +import { nuxtIcon } from '../../../src/utils/ascii' +import { paint, resolveBackground } from '../../../src/utils/terminal-theme' + +// eslint-disable-next-line no-control-regex +const strip = (text: string) => text.replaceAll(/\u001B\[[0-9;]*m|\u001B\]8;[^\u0007]*\u0007/g, '') + +describe('terminal background', () => { + it('takes an explicit setting at its word', () => { + expect(resolveBackground({ NUXT_TERM_THEME: 'light' })).toBe('light') + expect(resolveBackground({ NUXT_TERM_THEME: 'DARK' })).toBe('dark') + expect(resolveBackground({ NUXT_TERM_THEME: 'light', COLORFGBG: '15;0' })).toBe('light') + }) + + it('reads the background the terminal reports', () => { + expect(resolveBackground({ COLORFGBG: '15;0' })).toBe('dark') + expect(resolveBackground({ COLORFGBG: '0;15' })).toBe('light') + expect(resolveBackground({ COLORFGBG: '0;default;15' })).toBe('light') + expect(resolveBackground({ COLORFGBG: '15;default;0' })).toBe('dark') + }) + + it('admits to not knowing rather than assuming', () => { + expect(resolveBackground({})).toBe('unknown') + expect(resolveBackground({ COLORFGBG: '15;default' })).toBe('unknown') + expect(resolveBackground({ NUXT_TERM_THEME: 'solarized' })).toBe('unknown') + }) +}) + +describe('exact colours', () => { + const withTerminal = (depth: number, run: () => void) => { + const keys = ['getColorDepth', 'hasColors', 'isTTY'] as const + const originals = keys.map(key => [key, Object.getOwnPropertyDescriptor(process.stdout, key)] as const) + Object.defineProperty(process.stdout, 'getColorDepth', { value: () => depth, configurable: true }) + Object.defineProperty(process.stdout, 'hasColors', { value: () => depth > 1, configurable: true }) + Object.defineProperty(process.stdout, 'isTTY', { value: depth > 1, configurable: true }) + try { + run() + } + finally { + for (const [key, descriptor] of originals) { + if (descriptor) { + Object.defineProperty(process.stdout, key, descriptor) + } + else { + Reflect.deleteProperty(process.stdout, key) + } + } + } + } + + it('uses the exact colour only where the background is known', () => { + withTerminal(24, () => { + expect(paint('brand', 'Nuxt', 'dark')).toContain('\u001B[38;2;0;220;130m') + expect(paint('brand', 'Nuxt', 'light')).toContain('\u001B[38;2;0;145;92m') + expect(paint('brand', 'Nuxt', 'unknown')).not.toContain('38;2') + }) + }) + + it('darkens the warning amber on a light terminal, where yellow cannot be read', () => { + withTerminal(24, () => { + expect(paint('warning', '1 warning', 'dark')).toContain('\u001B[38;2;255;200;87m') + expect(paint('warning', '1 warning', 'light')).toContain('\u001B[38;2;138;90;0m') + }) + }) + + it('takes the nearest colour a 256-colour terminal can hold', () => { + withTerminal(8, () => { + // The cube entries closest to `#00DC82`, `#00915C`, `#FFC857` and `#8A5A00`. + expect(paint('brand', 'Nuxt', 'dark')).toContain('\u001B[38;5;42m') + expect(paint('brand', 'Nuxt', 'light')).toContain('\u001B[38;5;29m') + expect(paint('warning', '!', 'dark')).toContain('\u001B[38;5;221m') + expect(paint('warning', '!', 'light')).toContain('\u001B[38;5;94m') + }) + }) + + it('leaves the palette to the terminal below 256 colours', () => { + withTerminal(4, () => { + expect(paint('brand', 'Nuxt', 'dark')).not.toContain('38;') + expect(strip(paint('brand', 'Nuxt', 'dark'))).toBe('Nuxt') + }) + }) + + it('hands the colour back so nothing after it is tinted', () => { + for (const depth of [24, 8]) { + withTerminal(depth, () => { + for (const background of ['dark', 'light', 'unknown'] as const) { + for (const tone of ['brand', 'warning'] as const) { + // eslint-disable-next-line no-control-regex + expect(paint(tone, 'Nuxt', background)).toMatch(/\u001B\[(?:39|0)m$/) + } + } + }) + } + }) + + it('emits no escapes at all when there is no colour', () => { + withTerminal(1, () => { + expect(paint('brand', 'Nuxt', 'dark')).toBe('Nuxt') + expect(paint('warning', 'Nuxt', 'light')).toBe('Nuxt') + expect(paint('brand', 'Nuxt', 'unknown')).toBe('Nuxt') + }) + }) + + it('paints the init mark without leaving the terminal green', () => { + withTerminal(24, () => { + const icon = nuxtIcon() + expect(strip(icon).split('\n')).toHaveLength(8) + for (const line of icon.split('\n')) { + // eslint-disable-next-line no-control-regex + expect(line).toMatch(/\u001B\[(?:39|0)m$/) + } + }) + }) +}) diff --git a/packages/nuxt-cli/test/unit/warmup-gate.spec.ts b/packages/nuxt-cli/test/unit/warmup-gate.spec.ts index eb421b725..400df5d28 100644 --- a/packages/nuxt-cli/test/unit/warmup-gate.spec.ts +++ b/packages/nuxt-cli/test/unit/warmup-gate.spec.ts @@ -2,7 +2,7 @@ import type { IncomingMessage } from 'node:http' import { describe, expect, it, vi } from 'vitest' -import { isDocumentRequest } from '../../src/dev/utils' +import { isDocumentRequest } from '../../src/dev/request-kind' import { WarmupGate } from '../../src/dev/warmup-gate' /** Just enough of a response for the gate: it only ever waits for `close`. */ diff --git a/scripts/generate-completions-data.ts b/scripts/generate-completions-data.ts deleted file mode 100644 index 28e4deeb2..000000000 --- a/scripts/generate-completions-data.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** generate completion data from nitropack and Nuxt starter repo */ - -import { writeFile } from 'node:fs/promises' -import { dirname, join } from 'node:path' -import process from 'node:process' -import { pathToFileURL } from 'node:url' -import { resolveModulePath } from 'exsolve' - -import { hiddenTemplates } from '../packages/nuxt-cli/src/utils/starter-templates.ts' - -interface PresetMeta { - _meta?: { name: string } -} - -const outputPath = new URL('../packages/nuxt-cli/src/utils/completions-data.ts', import.meta.url) - -export async function generateCompletionData() { - const data = { - nitroPresets: [] as string[], - templates: {} as Record, - templateDefaultDirs: {} as Record, - } - - const nitropackPath = dirname(resolveModulePath('nitropack/package.json', { from: outputPath })) - const presetsPath = join(nitropackPath, 'dist/presets/_all.gen.mjs') - const { default: allPresets } = await import(pathToFileURL(presetsPath).toString()) as { default: PresetMeta[] } - - data.nitroPresets = allPresets - .map(preset => preset._meta?.name) - .filter((name): name is string => Boolean(name)) - .filter(name => !['base-worker', 'nitro-dev', 'nitro-prerender'].includes(name)) - .filter((name, index, array) => array.indexOf(name) === index) - .sort() - - const response = await fetch( - 'https://api.github.com/repos/nuxt/starter/contents/templates?ref=templates', - ) - - if (!response.ok) { - throw new Error(`GitHub API error: ${response.status}`) - } - - const files = await response.json() as Array<{ name: string, type: string, download_url?: string }> - - const jsonFiles = files.filter(file => file.type === 'file' && file.name.endsWith('.json')) - - for (const file of jsonFiles) { - try { - const templateName = file.name.replace('.json', '') - if (hiddenTemplates.includes(templateName)) { - continue - } - data.templates[templateName] = '' - const fileResponse = await fetch(file.download_url!) - if (fileResponse.ok) { - const json = await fileResponse.json() as { description?: string, defaultDir?: string } - data.templates[templateName] = json.description || '' - if (json.defaultDir) { - data.templateDefaultDirs[templateName] = json.defaultDir - } - } - } - catch (error) { - console.warn(`Could not fetch description for ${file.name}:`, error) - } - } - - const content = `/** Auto-generated file */ - -export const nitroPresets = ${JSON.stringify(data.nitroPresets, null, 2)} as const - -export const templates = ${JSON.stringify(data.templates, null, 2)} as const - -export const templateDefaultDirs = ${JSON.stringify(data.templateDefaultDirs, null, 2)} as const -` - - await writeFile(outputPath, content, 'utf-8') -} - -generateCompletionData().catch((error) => { - console.error('Failed to generate completion data:', error) - process.exit(1) -})