From 15f725b49557db6e6d24d02ae3c17bb2c11a6046 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 24 Aug 2026 10:05:47 +0000 Subject: [PATCH 1/4] feat(build): report build phases and where the time went --- capture/output/nuxt-dev-plain-restart.svg | 42 ++-- packages/nuxt-cli/src/commands/build.ts | 32 ++- packages/nuxt-cli/src/dev/index.ts | 8 +- packages/nuxt-cli/src/dev/loading-client.ts | 6 +- packages/nuxt-cli/src/dev/loading-page.ts | 6 +- packages/nuxt-cli/src/dev/progress.ts | 42 +--- packages/nuxt-cli/src/dev/tui/session.ts | 6 +- packages/nuxt-cli/src/utils/build-progress.ts | 197 ++++++++++++++++++ .../phase-reporter.ts} | 84 ++++++-- .../nuxt-cli/src/utils/progress-snapshot.ts | 32 +++ .../nuxt-cli/test/unit/build-progress.spec.ts | 106 ++++++++++ .../nuxt-cli/test/unit/loading-client.spec.ts | 4 +- .../nuxt-cli/test/unit/loading-page.spec.ts | 4 +- ...tup-log.spec.ts => phase-reporter.spec.ts} | 41 +++- 14 files changed, 515 insertions(+), 95 deletions(-) create mode 100644 packages/nuxt-cli/src/utils/build-progress.ts rename packages/nuxt-cli/src/{dev/startup-log.ts => utils/phase-reporter.ts} (57%) create mode 100644 packages/nuxt-cli/src/utils/progress-snapshot.ts create mode 100644 packages/nuxt-cli/test/unit/build-progress.spec.ts rename packages/nuxt-cli/test/unit/{dev-startup-log.spec.ts => phase-reporter.spec.ts} (80%) diff --git a/capture/output/nuxt-dev-plain-restart.svg b/capture/output/nuxt-dev-plain-restart.svg index e0531066f..02de54c16 100644 --- a/capture/output/nuxt-dev-plain-restart.svg +++ b/capture/output/nuxt-dev-plain-restart.svg @@ -16,25 +16,27 @@ svg{--bg:#ffffff;--fg:#24292f;--chrome:#f6f8fa;--dot:#d0d7de} nuxt dev (plain output, restart on config change) - - Starting Nuxt... 0ms -Nuxt 4.5.2 (with Nitro 2.13.4, Vite 8.2.2 and Vue 3.5.41)Local: http://localhost:3000/Network: use --host to expose Setting up modules 92ms -Nuxt 4.5.2 (with Nitro 2.13.4, Vite 8.2.2 and Vue 3.5.41)Local: http://localhost:3000/Network: use --host to expose ➜ DevTools: press Shift + Alt + D in the browser (v3.4.1) Preparing app 187ms -Nuxt 4.5.2 (with Nitro 2.13.4, Vite 8.2.2 and Vue 3.5.41)Local: http://localhost:3000/Network: use --host to expose ➜ DevTools: press Shift + Alt + D in the browser (v3.4.1) Preparing app 367ms -Nuxt 4.5.2 (with Nitro 2.13.4, Vite 8.2.2 and Vue 3.5.41)Local: http://localhost:3000/Network: use --host to expose ➜ DevTools: press Shift + Alt + D in the browser (v3.4.1) Generating types 447ms -Nuxt 4.5.2 (with Nitro 2.13.4, Vite 8.2.2 and Vue 3.5.41)Local: http://localhost:3000/Network: use --host to expose ➜ DevTools: press Shift + Alt + D in the browser (v3.4.1) Bundling app 537ms -Nuxt 4.5.2 (with Nitro 2.13.4, Vite 8.2.2 and Vue 3.5.41)Local: http://localhost:3000/Network: use --host to expose ➜ DevTools: press Shift + Alt + D in the browser (v3.4.1) Vite client built in 23ms Vite server built in 9ms Building server 638ms -Nuxt 4.5.2 (with Nitro 2.13.4, Vite 8.2.2 and Vue 3.5.41)Local: http://localhost:3000/Network: use --host to expose ➜ DevTools: press Shift + Alt + D in the browser (v3.4.1) Vite client built in 23ms Vite server built in 9ms Building server 719ms -Nuxt 4.5.2 (with Nitro 2.13.4, Vite 8.2.2 and Vue 3.5.41)Local: http://localhost:3000/Network: use --host to expose ➜ DevTools: press Shift + Alt + D in the browser (v3.4.1) Vite client built in 23ms Vite server built in 9ms Building server 804ms -Nuxt 4.5.2 (with Nitro 2.13.4, Vite 8.2.2 and Vue 3.5.41)Local: http://localhost:3000/Network: use --host to expose ➜ DevTools: press Shift + Alt + D in the browser (v3.4.1) Vite client built in 23ms Vite server built in 9ms Building server 888ms -Nuxt 4.5.2 (with Nitro 2.13.4, Vite 8.2.2 and Vue 3.5.41)Local: http://localhost:3000/Network: use --host to expose ➜ DevTools: press Shift + Alt + D in the browser (v3.4.1) Vite client built in 23ms Vite server built in 9ms Building server 969ms -Nuxt 4.5.2 (with Nitro 2.13.4, Vite 8.2.2 and Vue 3.5.41)Local: http://localhost:3000/Network: use --host to expose ➜ DevTools: press Shift + Alt + D in the browser (v3.4.1) Vite client built in 23ms Vite server built in 9ms Building server 1.05s -Nuxt 4.5.2 (with Nitro 2.13.4, Vite 8.2.2 and Vue 3.5.41)Local: http://localhost:3000/Network: use --host to expose ➜ DevTools: press Shift + Alt + D in the browser (v3.4.1) Vite client built in 23ms Vite server built in 9ms Nuxt Nitro server built in 539ms nitro Ready in 1.23sconfig 92ms · modules 95ms · app 204ms · types 145ms · bundle 102ms · server 591mspressh + enterto see available shortcuts Vite server warmed up in 5ms Vite client warmed up in 14ms -Nuxt 4.5.2 (with Nitro 2.13.4, Vite 8.2.2 and Vue 3.5.41)Local: http://localhost:3000/Network: use --host to expose ➜ DevTools: press Shift + Alt + D in the browser (v3.4.1) Vite client built in 23ms Vite server built in 9ms Nuxt Nitro server built in 539ms nitro Ready in 1.23sconfig 92ms · modules 95ms · app 204ms · types 145ms · bundle 102ms · server 591mspressh + enterto see available shortcuts Vite server warmed up in 5ms Vite client warmed up in 14ms nuxt.config.ts changed. Reloading Nuxt... -Nuxt 4.5.2 (with Nitro 2.13.4, Vite 8.2.2 and Vue 3.5.41)Local: http://localhost:3000/Network: use --host to expose ➜ DevTools: press Shift + Alt + D in the browser (v3.4.1) Vite client built in 23ms Vite server built in 9ms Nuxt Nitro server built in 539ms nitro Ready in 1.23sconfig 92ms · modules 95ms · app 204ms · types 145ms · bundle 102ms · server 591mspressh + enterto see available shortcuts Vite server warmed up in 5ms Vite client warmed up in 14ms nuxt.config.ts changed. Reloading Nuxt... Re-optimizing dependencies because vite config has changed Vite client built in 16ms Vite server built in 12ms -Nuxt 4.5.2 (with Nitro 2.13.4, Vite 8.2.2 and Vue 3.5.41)Local: http://localhost:3000/Network: use --host to expose ➜ DevTools: press Shift + Alt + D in the browser (v3.4.1) Vite client built in 23ms Vite server built in 9ms Nuxt Nitro server built in 539ms nitro Ready in 1.23sconfig 92ms · modules 95ms · app 204ms · types 145ms · bundle 102ms · server 591mspressh + enterto see available shortcuts Vite server warmed up in 5ms Vite client warmed up in 14ms nuxt.config.ts changed. Reloading Nuxt... Re-optimizing dependencies because vite config has changed Vite client built in 16ms Vite server built in 12ms Nuxt Nitro server built in 408ms nitro Vite server warmed up in 2ms Vite client warmed up in 4ms -Network: use --host to expose ➜ DevTools: press Shift + Alt + D in the browser (v3.4.1) Vite client built in 23ms Vite server built in 9ms Nuxt Nitro server built in 539ms nitro Ready in 1.23sconfig 92ms · modules 95ms · app 204ms · types 145ms · bundle 102ms · server 591mspressh + enterto see available shortcuts Vite server warmed up in 5ms Vite client warmed up in 14ms nuxt.config.ts changed. Reloading Nuxt... Re-optimizing dependencies because vite config has changed Vite client built in 16ms Vite server built in 12ms Nuxt Nitro server built in 408ms nitro Vite server warmed up in 2ms Vite client warmed up in 4ms nuxt.config.ts changed. Reloading Nuxt... ➜ DevTools: press Shift + Alt + D in the browser (v3.4.1) - Vite client built in 23ms Vite server built in 9ms Nuxt Nitro server built in 539ms nitro Ready in 1.23sconfig 92ms · modules 95ms · app 204ms · types 145ms · bundle 102ms · server 591mspressh + enterto see available shortcuts Vite server warmed up in 5ms Vite client warmed up in 14ms nuxt.config.ts changed. Reloading Nuxt... Re-optimizing dependencies because vite config has changed Vite client built in 16ms Vite server built in 12ms Nuxt Nitro server built in 408ms nitro Vite server warmed up in 2ms Vite client warmed up in 4ms nuxt.config.ts changed. Reloading Nuxt... ➜ DevTools: press Shift + Alt + D in the browser (v3.4.1) Re-optimizing dependencies because vite config has changed (x2) Vite client built in 10ms Vite server built in 8ms - Nuxt Nitro server built in 539ms nitro Ready in 1.23sconfig 92ms · modules 95ms · app 204ms · types 145ms · bundle 102ms · server 591mspressh + enterto see available shortcuts Vite server warmed up in 5ms Vite client warmed up in 14ms nuxt.config.ts changed. Reloading Nuxt... Re-optimizing dependencies because vite config has changed Vite client built in 16ms Vite server built in 12ms Nuxt Nitro server built in 408ms nitro Vite server warmed up in 2ms Vite client warmed up in 4ms nuxt.config.ts changed. Reloading Nuxt... ➜ DevTools: press Shift + Alt + D in the browser (v3.4.1) Re-optimizing dependencies because vite config has changed (x2) Vite client built in 10ms Vite server built in 8ms Nuxt Nitro server built in 343ms nitro Vite server warmed up in 2ms Vite client warmed up in 2ms + + Starting Nuxt... 0ms +Nuxt 4.5.2 (with Nitro 2.13.4, Vite 8.2.2 and Vue 3.5.41)Local: http://localhost:3000/Network: use --host to expose Setting up modules 117ms +Nuxt 4.5.2 (with Nitro 2.13.4, Vite 8.2.2 and Vue 3.5.41)Local: http://localhost:3000/Network: use --host to expose ➜ DevTools: press Shift + Alt + D in the browser (v3.4.2) Preparing app 244ms +Nuxt 4.5.2 (with Nitro 2.13.4, Vite 8.2.2 and Vue 3.5.41)Local: http://localhost:3000/Network: use --host to expose ➜ DevTools: press Shift + Alt + D in the browser (v3.4.2) Preparing app 374ms +Nuxt 4.5.2 (with Nitro 2.13.4, Vite 8.2.2 and Vue 3.5.41)Local: http://localhost:3000/Network: use --host to expose ➜ DevTools: press Shift + Alt + D in the browser (v3.4.2) Generating types 500ms +Nuxt 4.5.2 (with Nitro 2.13.4, Vite 8.2.2 and Vue 3.5.41)Local: http://localhost:3000/Network: use --host to expose ➜ DevTools: press Shift + Alt + D in the browser (v3.4.2) Generating types 543ms +Nuxt 4.5.2 (with Nitro 2.13.4, Vite 8.2.2 and Vue 3.5.41)Local: http://localhost:3000/Network: use --host to expose ➜ DevTools: press Shift + Alt + D in the browser (v3.4.2) Bundling app 701ms +Nuxt 4.5.2 (with Nitro 2.13.4, Vite 8.2.2 and Vue 3.5.41)Local: http://localhost:3000/Network: use --host to expose ➜ DevTools: press Shift + Alt + D in the browser (v3.4.2) Vite client built in 31ms Vite server built in 11ms Building server 829ms +Nuxt 4.5.2 (with Nitro 2.13.4, Vite 8.2.2 and Vue 3.5.41)Local: http://localhost:3000/Network: use --host to expose ➜ DevTools: press Shift + Alt + D in the browser (v3.4.2) Vite client built in 31ms Vite server built in 11ms Building server 951ms +Nuxt 4.5.2 (with Nitro 2.13.4, Vite 8.2.2 and Vue 3.5.41)Local: http://localhost:3000/Network: use --host to expose ➜ DevTools: press Shift + Alt + D in the browser (v3.4.2) Vite client built in 31ms Vite server built in 11ms Building server 1.05s +Nuxt 4.5.2 (with Nitro 2.13.4, Vite 8.2.2 and Vue 3.5.41)Local: http://localhost:3000/Network: use --host to expose ➜ DevTools: press Shift + Alt + D in the browser (v3.4.2) Vite client built in 31ms Vite server built in 11ms Building server 1.13s +Nuxt 4.5.2 (with Nitro 2.13.4, Vite 8.2.2 and Vue 3.5.41)Local: http://localhost:3000/Network: use --host to expose ➜ DevTools: press Shift + Alt + D in the browser (v3.4.2) Vite client built in 31ms Vite server built in 11ms Building server 1.22s +Nuxt 4.5.2 (with Nitro 2.13.4, Vite 8.2.2 and Vue 3.5.41)Local: http://localhost:3000/Network: use --host to expose ➜ DevTools: press Shift + Alt + D in the browser (v3.4.2) Vite client built in 31ms Vite server built in 11ms Building server 1.3s +Nuxt 4.5.2 (with Nitro 2.13.4, Vite 8.2.2 and Vue 3.5.41)Local: http://localhost:3000/Network: use --host to expose ➜ DevTools: press Shift + Alt + D in the browser (v3.4.2) Vite client built in 31ms Vite server built in 11ms Building server 1.38s +Nuxt 4.5.2 (with Nitro 2.13.4, Vite 8.2.2 and Vue 3.5.41)Local: http://localhost:3000/Network: use --host to expose ➜ DevTools: press Shift + Alt + D in the browser (v3.4.2) Vite client built in 31ms Vite server built in 11ms Nuxt Nitro server built in 736ms nitro Ready in 1.65sconfig 117ms · modules 126ms · app 257ms · types 199ms · bundle 130ms · server 820mspressh + enterto see available shortcuts Vite server warmed up in 20ms Vite client warmed up in 36ms +Nuxt 4.5.2 (with Nitro 2.13.4, Vite 8.2.2 and Vue 3.5.41)Local: http://localhost:3000/Network: use --host to expose ➜ DevTools: press Shift + Alt + D in the browser (v3.4.2) Vite client built in 31ms Vite server built in 11ms Nuxt Nitro server built in 736ms nitro Ready in 1.65sconfig 117ms · modules 126ms · app 257ms · types 199ms · bundle 130ms · server 820mspressh + enterto see available shortcuts Vite server warmed up in 20ms Vite client warmed up in 36ms nuxt.config.ts changed. Reloading Nuxt... +Nuxt 4.5.2 (with Nitro 2.13.4, Vite 8.2.2 and Vue 3.5.41)Local: http://localhost:3000/Network: use --host to expose ➜ DevTools: press Shift + Alt + D in the browser (v3.4.2) Vite client built in 31ms Vite server built in 11ms Nuxt Nitro server built in 736ms nitro Ready in 1.65sconfig 117ms · modules 126ms · app 257ms · types 199ms · bundle 130ms · server 820mspressh + enterto see available shortcuts Vite server warmed up in 20ms Vite client warmed up in 36ms nuxt.config.ts changed. Reloading Nuxt... Re-optimizing dependencies because vite config has changed Vite client built in 14ms Vite server built in 14ms +Nuxt 4.5.2 (with Nitro 2.13.4, Vite 8.2.2 and Vue 3.5.41)Local: http://localhost:3000/Network: use --host to expose ➜ DevTools: press Shift + Alt + D in the browser (v3.4.2) Vite client built in 31ms Vite server built in 11ms Nuxt Nitro server built in 736ms nitro Ready in 1.65sconfig 117ms · modules 126ms · app 257ms · types 199ms · bundle 130ms · server 820mspressh + enterto see available shortcuts Vite server warmed up in 20ms Vite client warmed up in 36ms nuxt.config.ts changed. Reloading Nuxt... Re-optimizing dependencies because vite config has changed Vite client built in 14ms Vite server built in 14ms Nuxt Nitro server built in 534ms nitro Vite server warmed up in 2ms Vite client warmed up in 6ms +Network: use --host to expose ➜ DevTools: press Shift + Alt + D in the browser (v3.4.2) Vite client built in 31ms Vite server built in 11ms Nuxt Nitro server built in 736ms nitro Ready in 1.65sconfig 117ms · modules 126ms · app 257ms · types 199ms · bundle 130ms · server 820mspressh + enterto see available shortcuts Vite server warmed up in 20ms Vite client warmed up in 36ms nuxt.config.ts changed. Reloading Nuxt... Re-optimizing dependencies because vite config has changed Vite client built in 14ms Vite server built in 14ms Nuxt Nitro server built in 534ms nitro Vite server warmed up in 2ms Vite client warmed up in 6ms nuxt.config.ts changed. Reloading Nuxt... ➜ DevTools: press Shift + Alt + D in the browser (v3.4.2) + Vite client built in 31ms Vite server built in 11ms Nuxt Nitro server built in 736ms nitro Ready in 1.65sconfig 117ms · modules 126ms · app 257ms · types 199ms · bundle 130ms · server 820mspressh + enterto see available shortcuts Vite server warmed up in 20ms Vite client warmed up in 36ms nuxt.config.ts changed. Reloading Nuxt... Re-optimizing dependencies because vite config has changed Vite client built in 14ms Vite server built in 14ms Nuxt Nitro server built in 534ms nitro Vite server warmed up in 2ms Vite client warmed up in 6ms nuxt.config.ts changed. Reloading Nuxt... ➜ DevTools: press Shift + Alt + D in the browser (v3.4.2) Re-optimizing dependencies because vite config has changed (x2) Vite client built in 13ms Vite server built in 10ms + Nuxt Nitro server built in 736ms nitro Ready in 1.65sconfig 117ms · modules 126ms · app 257ms · types 199ms · bundle 130ms · server 820mspressh + enterto see available shortcuts Vite server warmed up in 20ms Vite client warmed up in 36ms nuxt.config.ts changed. Reloading Nuxt... Re-optimizing dependencies because vite config has changed Vite client built in 14ms Vite server built in 14ms Nuxt Nitro server built in 534ms nitro Vite server warmed up in 2ms Vite client warmed up in 6ms nuxt.config.ts changed. Reloading Nuxt... ➜ DevTools: press Shift + Alt + D in the browser (v3.4.2) Re-optimizing dependencies because vite config has changed (x2) Vite client built in 13ms Vite server built in 10ms Nuxt Nitro server built in 466ms nitro Vite server warmed up in 2ms Vite client warmed up in 3ms diff --git a/packages/nuxt-cli/src/commands/build.ts b/packages/nuxt-cli/src/commands/build.ts index c3b9dfcc9..f0ea04a16 100644 --- a/packages/nuxt-cli/src/commands/build.ts +++ b/packages/nuxt-cli/src/commands/build.ts @@ -6,8 +6,9 @@ import { defineCommand } from 'citty' import { relative } from 'pathe' import { resolveDotenvFileNames } from '../utils/args' import { showBanner } from '../utils/banner' - +import { BuildProgress } from '../utils/build-progress' import { overrideEnv } from '../utils/env' + import { ActionableError } from '../utils/errors' import { formatDuration } from '../utils/formatting' import { clearBuildDir } from '../utils/fs' @@ -15,9 +16,13 @@ import { loadKit } from '../utils/kit' import { acquireLock, acquireOutputLock, formatLockError } from '../utils/lockfile' import { intro, logger, outro } from '../utils/logger' import { resolveRootDir } from '../utils/paths' +import { createPhaseReporter, formatPhaseBreakdown } from '../utils/phase-reporter' import { startCpuProfile, stopCpuProfile } from '../utils/profile' import { dotEnvArgs, envNameArgs, extendsArgs, logLevelArgs, profileArgs, rootDirArgs } from './_shared' +/** How often a phase repeats itself where there is no animated line. */ +const HEARTBEAT_INTERVAL = 5000 + export default defineCommand({ meta: { name: 'build', @@ -55,9 +60,23 @@ export default defineCommand({ } const releaseLocks: Array<() => void> = [] + const progress = new BuildProgress() + let stopReporting = () => {} try { intro(styleText('cyan', 'Building Nuxt for production...')) + // The phase line owns a row of the terminal, which a silent build has no + // business drawing on. Subscribed after the intro so the first phase is + // reported below it rather than above. + if (ctx.args.logLevel !== 'silent') { + const reporter = createPhaseReporter({ heartbeat: HEARTBEAT_INTERVAL }) + const unsubscribe = progress.onUpdate(reporter.update) + stopReporting = () => { + unsubscribe() + reporter.stop() + } + } + const kit = await loadKit(cwd) const nuxt = await kit.loadNuxt({ cwd, @@ -85,6 +104,8 @@ export default defineCommand({ }, }) + progress.attachNuxt(nuxt) + showBanner(nuxt) await nuxt.ready() @@ -115,6 +136,14 @@ export default defineCommand({ await kit.buildNuxt(nuxt) + stopReporting() + progress.finish() + + const breakdown = formatPhaseBreakdown(progress.timings) + if (breakdown) { + logger.message(styleText('dim', breakdown)) + } + if (ctx.args.prerender) { if (!nuxt.options.ssr) { logger.warn(`HTML content not prerendered because ${styleText('cyan', 'ssr: false')} was set.`) @@ -129,6 +158,7 @@ export default defineCommand({ } } finally { + stopReporting() for (const release of releaseLocks.reverse()) { release() } diff --git a/packages/nuxt-cli/src/dev/index.ts b/packages/nuxt-cli/src/dev/index.ts index d95660cce..d40de4e73 100644 --- a/packages/nuxt-cli/src/dev/index.ts +++ b/packages/nuxt-cli/src/dev/index.ts @@ -1,7 +1,7 @@ /* eslint-disable perfectionist/sort-imports -- `./force-tty` must be evaluated before anything that loads `std-env` or `consola` */ import type { NuxtConfig } from '@nuxt/schema' import type { DevListenOverrides, Listener, ListenURL } from './listen' -import type { DevProgressSnapshot } from './progress' +import type { ProgressSnapshot } from '../utils/progress-snapshot' import type { DevRestartReason } from './reason' import type { ServerLogEvent } from './log-channel' import type { DevRequestEvent, DevRoutes, NuxtDevContext, NuxtDevIPCMessage, NuxtParentIPCMessage } from './utils' @@ -21,7 +21,7 @@ import { debug } from '../utils/logger' import { startCpuProfile, stopCpuProfile } from '../utils/profile.ts' import { openInspector } from './inspect' import { currentRequest, isServingRequest } from './serving-state' -import { createStartupReporter } from './startup-log' +import { createPhaseReporter } from '../utils/phase-reporter' import { NuxtDevServer } from './utils' const start = Date.now() @@ -93,7 +93,7 @@ interface InitializeOptions { * Called with every startup progress snapshot, from before the first load * begins, so a UI can narrate startup as it happens rather than after. */ - onProgress?: (snapshot: DevProgressSnapshot) => void + onProgress?: (snapshot: ProgressSnapshot) => void /** * Called as soon as a socket is bound, milliseconds into startup, and again * with `confirmed` once the resolved config has agreed with the address. @@ -357,7 +357,7 @@ export async function initialize(devContext: NuxtDevContext, ctx: InitializeOpti // itself, so the transient reporter line would only fight it for the screen. const reporter = devContext.args.logLevel === 'silent' || ipc.enabled || ctx.captureUIEvents ? undefined - : createStartupReporter() + : createPhaseReporter() const unsubscribeProgress = reporter && devServer.progress.onUpdate(reporter.update) const stopReporting = () => { unsubscribeProgress?.() diff --git a/packages/nuxt-cli/src/dev/loading-client.ts b/packages/nuxt-cli/src/dev/loading-client.ts index f207cdc15..157d80e23 100644 --- a/packages/nuxt-cli/src/dev/loading-client.ts +++ b/packages/nuxt-cli/src/dev/loading-client.ts @@ -1,4 +1,4 @@ -import type { DevProgressSnapshot } from './progress' +import type { ProgressSnapshot } from '../utils/progress-snapshot' /** * The scripts inlined into the dev server's loading and error pages. @@ -36,7 +36,7 @@ export function progressClient(options: ProgressClientOptions): void { caption.textContent = label ? `${label} \u00B7 ${seconds}` : seconds } - function apply(snapshot: DevProgressSnapshot): void { + function apply(snapshot: ProgressSnapshot): void { start = Date.now() - snapshot.elapsed // The message, not the phase id: it carries whatever detail the server has, // such as the module currently being set up, and this page is what the user @@ -53,7 +53,7 @@ export function progressClient(options: ProgressClientOptions): void { paint() } - function read(event: Event): DevProgressSnapshot | undefined { + function read(event: Event): ProgressSnapshot | undefined { try { return JSON.parse((event as MessageEvent).data) } diff --git a/packages/nuxt-cli/src/dev/loading-page.ts b/packages/nuxt-cli/src/dev/loading-page.ts index 2c57d49d0..5baa0118f 100644 --- a/packages/nuxt-cli/src/dev/loading-page.ts +++ b/packages/nuxt-cli/src/dev/loading-page.ts @@ -1,4 +1,4 @@ -import type { DevProgressSnapshot } from './progress' +import type { ProgressSnapshot } from '../utils/progress-snapshot' import { inlineScript, progressClient, recoveryClient } from './loading-client' import { PROGRESS_PATH } from './progress' @@ -16,7 +16,7 @@ const MAX_POLL_INTERVAL_MS = 1000 const STYLES = `.nuxt-loader-bar{right:auto!important;width:var(${PROGRESS_PROPERTY},4%);transition:width .3s ease} #${CAPTION_ID}{position:fixed;left:0;right:0;bottom:14px;text-align:center;font:12px/1.5 ui-sans-serif,system-ui,-apple-system,sans-serif;opacity:.55;font-variant-numeric:tabular-nums}` -function progressTags(snapshot: DevProgressSnapshot): string { +function progressTags(snapshot: ProgressSnapshot): string { return `${inlineScript(progressClient, { progressPath: PROGRESS_PATH, captionId: CAPTION_ID, @@ -35,7 +35,7 @@ function progressTags(snapshot: DevProgressSnapshot): string { * adds is a determinate bar, a phase caption, the build error inline, and a * reload driven by the server rather than by a poll interval. */ -export function withProgress(html: string, snapshot: DevProgressSnapshot): string { +export function withProgress(html: string, snapshot: ProgressSnapshot): string { const tags = progressTags(snapshot) const index = html.lastIndexOf('') return index === -1 ? html + tags : html.slice(0, index) + tags + html.slice(index) diff --git a/packages/nuxt-cli/src/dev/progress.ts b/packages/nuxt-cli/src/dev/progress.ts index c162fa53f..e8b571e44 100644 --- a/packages/nuxt-cli/src/dev/progress.ts +++ b/packages/nuxt-cli/src/dev/progress.ts @@ -1,4 +1,5 @@ import type { IncomingMessage, ServerResponse } from 'node:http' +import type { PhaseTiming, ProgressSnapshot, ProgressStatus } from '../utils/progress-snapshot' /** Path prefix reserved for the CLI's own dev-time endpoints. */ export const DEV_INTERNAL_PREFIX: string = '/__nuxt_dev__/' @@ -107,33 +108,6 @@ function moduleName(module: unknown): string | undefined { return typeof name === 'string' && name ? name : undefined } -export type DevProgressStatus = 'loading' | 'ready' | 'error' - -interface DevPhaseTiming { - phase: string - message: string - duration: number -} - -export interface DevProgressSnapshot { - status: DevProgressStatus - phase: string - message: string - index: number - total: number - progress: number - elapsed: number - reload: boolean - /** - * Whether a request has actually been answered. `status` is `ready` from the - * moment the server is listening, so this is what tells a UI whether the app - * can be used yet. - */ - serving: boolean - timings: DevPhaseTiming[] - error?: { name: string, message: string } -} - interface HookableLike { beforeEach?: (fn: (event: { name: string, args?: unknown[] }) => void) => void afterEach?: (fn: (event: { name: string }) => void) => void @@ -150,16 +124,16 @@ interface ActiveHook { */ export class DevProgress { #clients = new Set() - #listeners = new Set<(snapshot: DevProgressSnapshot) => void>() + #listeners = new Set<(snapshot: ProgressSnapshot) => void>() #heartbeat?: NodeJS.Timeout #index = 0 #message = DEV_PHASES[0]!.message - #status: DevProgressStatus = 'loading' + #status: ProgressStatus = 'loading' #error?: Error #startedAt = Date.now() #phaseStartedAt = Date.now() - #timings: DevPhaseTiming[] = [] + #timings: PhaseTiming[] = [] #reload = false #baseMessage = DEV_PHASES[0]!.message #module?: ActiveHook @@ -170,7 +144,7 @@ export class DevProgress { #observed = new WeakSet() #ticker?: NodeJS.Timeout - get snapshot(): DevProgressSnapshot { + get snapshot(): ProgressSnapshot { const phase = DEV_PHASES[this.#index]! return { status: this.#status, @@ -189,11 +163,11 @@ export class DevProgress { } } - get timings(): DevPhaseTiming[] { + get timings(): PhaseTiming[] { return this.#timings } - onUpdate(listener: (snapshot: DevProgressSnapshot) => void): () => void { + onUpdate(listener: (snapshot: ProgressSnapshot) => void): () => void { this.#listeners.add(listener) return () => this.#listeners.delete(listener) } @@ -512,7 +486,7 @@ export class DevProgress { } } - #send(res: ServerResponse, snapshot: DevProgressSnapshot): void { + #send(res: ServerResponse, snapshot: ProgressSnapshot): void { if (res.writableEnded) { return } diff --git a/packages/nuxt-cli/src/dev/tui/session.ts b/packages/nuxt-cli/src/dev/tui/session.ts index 476cf160e..b71b2762e 100644 --- a/packages/nuxt-cli/src/dev/tui/session.ts +++ b/packages/nuxt-cli/src/dev/tui/session.ts @@ -1,5 +1,5 @@ +import type { ProgressSnapshot } from '../../utils/progress-snapshot' import type { ListenURL } from '../listen' -import type { DevProgressSnapshot } from '../progress' import type { DevLogEvent } from './events' import type { PanelState } from './panel' import type { DevUISupportOptions } from './support' @@ -85,7 +85,7 @@ export interface DevUISession { /** Stop the session's own startup animation, once the controller drives it. */ stopStartupTicker: () => void /** Narrate the current startup phase while the server is loading. */ - reportProgress: (snapshot: DevProgressSnapshot) => void + reportProgress: (snapshot: ProgressSnapshot) => void /** * Show the bound address the moment the socket answers, spinning until the * resolved config confirms it. The full URL block replaces it on ready. @@ -175,7 +175,7 @@ export function beginDevUI(options: DevUISupportOptions & { version?: string, cw surface.render(renderPanel(state, process.stdout.columns || 80, process.stdout.rows || 24)) } - function reportProgress(snapshot: DevProgressSnapshot): void { + function reportProgress(snapshot: ProgressSnapshot): void { if (snapshot.status === 'ready') { // Between the server accepting requests and answering one there is // nothing to watch but a badge, so it says which of the two has happened. diff --git a/packages/nuxt-cli/src/utils/build-progress.ts b/packages/nuxt-cli/src/utils/build-progress.ts new file mode 100644 index 000000000..1669c1f0a --- /dev/null +++ b/packages/nuxt-cli/src/utils/build-progress.ts @@ -0,0 +1,197 @@ +import type { PhaseTiming, ProgressSnapshot } from './progress-snapshot' + +interface BuildPhase { + id: string + message: string +} + +/** + * 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[] = [ + { id: 'config', message: 'Loading Nuxt config' }, + { id: 'modules', message: 'Setting up modules' }, + { id: 'app', message: 'Preparing app' }, + { id: 'types', message: 'Generating types' }, + { id: 'client', message: 'Bundling client' }, + { id: 'server', message: 'Bundling server' }, + { id: 'nitro', message: 'Building Nitro server' }, +] + +const HOOK_PHASES: Record = { + 'modules:before': 'modules', + 'builder:generateApp': 'app', + 'prepare:types': 'types', + 'build:before': 'client', + 'nitro:build:before': 'nitro', +} + +/** Nitro builds on its own hooks, which Nuxt's never see. */ +const NITRO_INIT_HOOK = 'nitro:init' + +/** + * Nitro's own hooks, relabelled. Without these the Nitro phase says nothing + * between the bundle starting and the output being written, which on a large + * project is the longest silence of the build. + */ +const NITRO_MESSAGES: Record = { + 'rollup:before': 'Bundling Nitro server', + 'prerender:routes': 'Prerendering routes', +} + +const PRERENDER_HOOK = 'prerender:generate' + +interface HookableLike { + beforeEach?: (fn: (event: { name: string, args?: unknown[] }) => void) => void +} + +interface NuxtLike { + options?: { experimental?: { viteEnvironmentApi?: boolean } } + hooks?: HookableLike +} + +/** + * Tracks how far a production build has got, so the same transient phase line + * `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 + #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 + * the client and server configs resolve up front and cannot mark the boundary + * between them. + */ + #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, + reload: false, + serving: true, + timings: this.#timings, + } + } + + get timings(): PhaseTiming[] { + return this.#timings + } + + onUpdate(listener: (snapshot: ProgressSnapshot) => void): () => void { + this.#listeners.add(listener) + listener(this.snapshot) + return () => this.#listeners.delete(listener) + } + + setPhase(id: string, message?: string): void { + const index = BUILD_PHASES.findIndex(phase => phase.id === id) + if (this.#finished || index === -1 || index < this.#index) { + 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) + } + + setMessage(message: string): void { + if (this.#finished || message === this.#message) { + return + } + this.#message = message + this.#emit() + } + + /** + * Close the phase in flight and stop accepting updates, so its duration makes + * it into the breakdown. Nothing is emitted: whoever reports the build owns + * what is said about it finishing. + */ + finish(): void { + if (this.#finished) { + return + } + const phase = BUILD_PHASES[this.#index]! + this.#timings.push({ + phase: phase.id, + message: phase.message, + duration: Date.now() - this.#phaseStartedAt, + }) + this.#finished = true + } + + /** + * Derive phases from the hooks Nuxt and Nitro call, so the timeline follows + * the project's own build rather than a schedule guessed by the CLI. + */ + attachNuxt(nuxt: NuxtLike): void { + this.#splitBundles = !nuxt.options?.experimental?.viteEnvironmentApi + nuxt.hooks?.beforeEach?.(({ name, args }) => { + const phase = HOOK_PHASES[name] + if (phase) { + this.setPhase(phase, phase === 'client' && !this.#splitBundles ? 'Bundling app' : undefined) + return + } + if (name === 'vite:configResolved' && this.#splitBundles) { + const ctx = args?.[1] as { isServer?: boolean } | undefined + this.setPhase(ctx?.isServer ? 'server' : 'client') + } + else if (name === 'webpack:compile') { + const ctx = args?.[0] as { name?: string } | undefined + this.setPhase(ctx?.name === 'server' ? 'server' : 'client') + } + else if (name === NITRO_INIT_HOOK) { + this.#attachNitro((args?.[0] as { hooks?: HookableLike } | undefined)?.hooks) + } + }) + } + + #attachNitro(hooks: HookableLike | undefined): void { + 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') { + return + } + const message = NITRO_MESSAGES[name] + if (message) { + this.setMessage(message) + } + else if (name === PRERENDER_HOOK) { + const route = (args?.[0] as { route?: string } | undefined)?.route + if (route) { + this.setMessage(`Prerendering ${route}`) + } + } + }) + } + + #emit(): void { + const snapshot = this.snapshot + for (const listener of this.#listeners) { + listener(snapshot) + } + } +} diff --git a/packages/nuxt-cli/src/dev/startup-log.ts b/packages/nuxt-cli/src/utils/phase-reporter.ts similarity index 57% rename from packages/nuxt-cli/src/dev/startup-log.ts rename to packages/nuxt-cli/src/utils/phase-reporter.ts index 2fd7571ab..8a77dfb62 100644 --- a/packages/nuxt-cli/src/dev/startup-log.ts +++ b/packages/nuxt-cli/src/utils/phase-reporter.ts @@ -1,13 +1,13 @@ -import type { DevProgressSnapshot } from './progress' +import type { PhaseTiming, ProgressSnapshot } from './progress-snapshot' import process from 'node:process' import { styleText } from 'node:util' import { isCI } from 'std-env' -import { formatDuration } from '../utils/formatting' -import { logger } from '../utils/logger' -import { tapOutput } from '../utils/stdout' +import { formatDuration } from './formatting' +import { logger } from './logger' +import { tapOutput } from './stdout' const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] const FRAME_INTERVAL = 80 @@ -15,48 +15,67 @@ const CLEAR_LINE = '\r\u001B[2K' const HIDE_CURSOR = '\u001B[?25l' const SHOW_CURSOR = '\u001B[?25h' -export interface StartupReporter { - update: (snapshot: DevProgressSnapshot) => void +export interface PhaseReporter { + update: (snapshot: ProgressSnapshot) => void stop: () => void } -interface StartupReporterOptions { +interface PhaseReporterOptions { stream?: NodeJS.WriteStream /** Force the animated line on or off; detected from the environment otherwise. */ animated?: boolean + /** + * How often a phase that is still running repeats itself, in milliseconds, + * when there is no animated line to show that time is passing. Off by + * default; only worth it where a single phase can run for minutes. + */ + heartbeat?: number } export function isAnimationSupported(stream: NodeJS.WriteStream = process.stdout): boolean { return !!stream.isTTY && !isCI && !process.env.NO_COLOR } -export function formatSummary(snapshot: DevProgressSnapshot): string { - const breakdown = snapshot.timings +/** `config 40ms · modules 1.2s · …`, or nothing if every phase was trivial. */ +export function formatPhaseBreakdown(timings: PhaseTiming[]): string { + return timings .filter(timing => timing.duration >= 10) .map(timing => `${timing.phase} ${formatDuration(timing.duration)}`) .join(' · ') +} + +/** + * 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. + */ +export function formatSummary(snapshot: ProgressSnapshot): string { + const breakdown = formatPhaseBreakdown(snapshot.timings) const headline = `${snapshot.reload ? 'Reloaded' : 'Ready'} in ${formatDuration(snapshot.elapsed)}${ snapshot.serving ? '' : styleText('dim', ' \u00B7 compiling the first request')}` return breakdown ? `${headline}\n${styleText('dim', breakdown)}` : headline } /** - * Report startup progress on a single line that updates in place, collapsing to - * one summary line with a phase breakdown once the server is ready. Falls back - * to sequential logs when the output is not an interactive terminal. + * Report progress through a command's phases on a single line that updates in + * place, collapsing to one summary line with a phase breakdown once the command + * reports itself ready. Falls back to sequential logs when the output is not an + * interactive terminal. */ -export function createStartupReporter(options: StartupReporterOptions = {}): StartupReporter { +export function createPhaseReporter(options: PhaseReporterOptions = {}): PhaseReporter { const stream = options.stream ?? process.stdout const animated = options.animated ?? isAnimationSupported(stream) - let snapshot: DevProgressSnapshot | undefined + let snapshot: ProgressSnapshot | undefined let served = false let lastPhase: string | undefined + let lastMessage: string | undefined + let lastLoggedAt = 0 let frame = 0 let dirty = false let timer: NodeJS.Timeout | undefined let stopped = false let receivedAt = Date.now() + let pulse: NodeJS.Timeout | undefined // Foreign output would otherwise be written on top of the transient line. The // tap sits below `consola.wrapAll()`, which `nuxt dev` installs, for two @@ -68,6 +87,12 @@ export function createStartupReporter(options: StartupReporterOptions = {}): Sta const tap = animated ? tapOutput(stream, () => clear()) : undefined const write = tap?.write ?? stream.write.bind(stream) + function log(line: string, message: string) { + lastMessage = message + lastLoggedAt = Date.now() + logger.info(line) + } + function clear() { if (dirty) { write(CLEAR_LINE) @@ -91,7 +116,27 @@ export function createStartupReporter(options: StartupReporterOptions = {}): Sta timer.unref?.() } + /** + * Repeat the phase in flight, with the time it has taken so far, so a long + * silent stretch in a piped log still shows the build is alive. + */ + function schedulePulse() { + clearInterval(pulse) + pulse = undefined + if (!options.heartbeat || animated) { + return + } + pulse = setInterval(() => { + if (snapshot && !stopped) { + log(`${snapshot.message} ${styleText('dim', `(${formatDuration(snapshot.elapsed + (Date.now() - receivedAt))})`)}`, snapshot.message) + } + }, options.heartbeat) + pulse.unref?.() + } + function restore() { + clearInterval(pulse) + pulse = undefined if (timer) { clearInterval(timer) timer = undefined @@ -144,7 +189,16 @@ export function createStartupReporter(options: StartupReporterOptions = {}): Sta if (next.phase !== lastPhase) { lastPhase = next.phase - logger.info(next.message) + log(next.message, next.message) + schedulePulse() + return + } + + // Within a phase the message is narration rather than progress, so it is + // only worth a line where the phase is long enough to have a heartbeat, + // and never more often than one. + if (options.heartbeat && next.message !== lastMessage && Date.now() - lastLoggedAt >= options.heartbeat) { + log(next.message, next.message) } }, stop() { diff --git a/packages/nuxt-cli/src/utils/progress-snapshot.ts b/packages/nuxt-cli/src/utils/progress-snapshot.ts new file mode 100644 index 000000000..e1466bd24 --- /dev/null +++ b/packages/nuxt-cli/src/utils/progress-snapshot.ts @@ -0,0 +1,32 @@ +/** + * What a command reports about its own progress. Shared by `nuxt dev`, whose + * loading page and TUI panel render it, and `nuxt build`, which only shows the + * phase line, so a snapshot carries fields a given command never sets. + */ + +export type ProgressStatus = 'loading' | 'ready' | 'error' + +export interface PhaseTiming { + phase: string + message: string + duration: number +} + +export interface ProgressSnapshot { + status: ProgressStatus + phase: string + message: string + index: number + total: number + progress: number + elapsed: number + reload: boolean + /** + * Whether a request has actually been answered. `status` is `ready` from the + * moment the server is listening, so this is what tells a UI whether the app + * can be used yet. Always true for a command that only builds. + */ + serving: boolean + timings: PhaseTiming[] + error?: { name: string, message: string } +} diff --git a/packages/nuxt-cli/test/unit/build-progress.spec.ts b/packages/nuxt-cli/test/unit/build-progress.spec.ts new file mode 100644 index 000000000..8aa216973 --- /dev/null +++ b/packages/nuxt-cli/test/unit/build-progress.spec.ts @@ -0,0 +1,106 @@ +import type { ProgressSnapshot } from '../../src/utils/progress-snapshot' + +import { describe, expect, it } from 'vitest' + +import { BuildProgress } from '../../src/utils/build-progress' + +type HookHandler = (event: { name: string, args?: unknown[] }) => void + +function nuxtStub(options: { viteEnvironmentApi?: boolean } = {}) { + const handlers: HookHandler[] = [] + const nitroHandlers: HookHandler[] = [] + return { + nuxt: { + options: { experimental: { viteEnvironmentApi: options.viteEnvironmentApi } }, + hooks: { beforeEach: (fn: HookHandler) => void handlers.push(fn) }, + }, + call(name: string, ...args: unknown[]) { + for (const handler of handlers) { + handler({ name, args }) + } + }, + nitro: { hooks: { beforeEach: (fn: HookHandler) => void nitroHandlers.push(fn) } }, + callNitro(name: string, ...args: unknown[]) { + for (const handler of nitroHandlers) { + handler({ name, args }) + } + }, + } +} + +function messages(progress: BuildProgress): string[] { + const seen: string[] = [] + progress.onUpdate((snapshot: ProgressSnapshot) => seen.push(snapshot.message)) + return seen +} + +describe('build progress', () => { + it('should follow the phases of a production build', () => { + const progress = new BuildProgress() + const stub = nuxtStub() + progress.attachNuxt(stub.nuxt) + const seen = messages(progress) + + stub.call('modules:before') + stub.call('builder:generateApp') + stub.call('prepare:types') + stub.call('build:before') + stub.call('vite:configResolved', {}, { isClient: true, isServer: false }) + stub.call('vite:configResolved', {}, { isClient: false, isServer: true }) + stub.call('nitro:build:before', {}) + progress.finish() + + expect(seen).toEqual([ + 'Loading Nuxt config', + 'Setting up modules', + 'Preparing app', + 'Generating types', + 'Bundling client', + 'Bundling server', + 'Building Nitro server', + ]) + expect(progress.timings.map(timing => timing.phase)).toEqual(['config', 'modules', 'app', 'types', 'client', 'server', 'nitro']) + }) + + it('should not split the bundle phase when a single build covers both environments', () => { + const progress = new BuildProgress() + const stub = nuxtStub({ viteEnvironmentApi: true }) + progress.attachNuxt(stub.nuxt) + const seen = messages(progress) + + stub.call('build:before') + stub.call('vite:configResolved', {}, { isClient: true, isServer: false }) + stub.call('vite:configResolved', {}, { isClient: false, isServer: true }) + + expect(seen).toEqual(['Loading Nuxt config', 'Bundling app']) + }) + + it('should name what the Nitro phase is doing', () => { + const progress = new BuildProgress() + const stub = nuxtStub() + progress.attachNuxt(stub.nuxt) + stub.call('nitro:init', stub.nitro) + const seen = messages(progress) + + stub.callNitro('rollup:before') + stub.call('nitro:build:before', {}) + stub.callNitro('rollup:before') + stub.callNitro('prerender:generate', { route: '/about' }) + + expect(seen).toEqual(['Loading Nuxt config', 'Building Nitro server', 'Bundling Nitro server', 'Prerendering /about']) + }) + + it('should keep phase durations after the build is finished', () => { + const progress = new BuildProgress() + const stub = nuxtStub() + progress.attachNuxt(stub.nuxt) + + stub.call('modules:before') + progress.finish() + progress.finish() + stub.call('nitro:build:before', {}) + + expect(progress.timings.map(timing => timing.phase)).toEqual(['config', 'modules']) + expect(progress.snapshot.phase).toBe('modules') + }) +}) diff --git a/packages/nuxt-cli/test/unit/loading-client.spec.ts b/packages/nuxt-cli/test/unit/loading-client.spec.ts index e8e697832..d31811e18 100644 --- a/packages/nuxt-cli/test/unit/loading-client.spec.ts +++ b/packages/nuxt-cli/test/unit/loading-client.spec.ts @@ -1,5 +1,5 @@ import type { ProgressClientOptions } from '../../src/dev/loading-client' -import type { DevProgressSnapshot } from '../../src/dev/progress' +import type { ProgressSnapshot } from '../../src/utils/progress-snapshot' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -14,7 +14,7 @@ const OPTIONS: ProgressClientOptions = { maxPollInterval: 1000, } -function snapshot(overrides: Partial = {}): DevProgressSnapshot { +function snapshot(overrides: Partial = {}): ProgressSnapshot { return { status: 'loading', phase: 'bundle', diff --git a/packages/nuxt-cli/test/unit/loading-page.spec.ts b/packages/nuxt-cli/test/unit/loading-page.spec.ts index 665c7a558..f41a365b8 100644 --- a/packages/nuxt-cli/test/unit/loading-page.spec.ts +++ b/packages/nuxt-cli/test/unit/loading-page.spec.ts @@ -1,8 +1,8 @@ -import type { DevProgressSnapshot } from '../../src/dev/progress' +import type { ProgressSnapshot } from '../../src/utils/progress-snapshot' import { describe, expect, it } from 'vitest' import { withProgress } from '../../src/dev/loading-page' -function snapshot(overrides: Partial = {}): DevProgressSnapshot { +function snapshot(overrides: Partial = {}): ProgressSnapshot { return { status: 'loading', phase: 'config', diff --git a/packages/nuxt-cli/test/unit/dev-startup-log.spec.ts b/packages/nuxt-cli/test/unit/phase-reporter.spec.ts similarity index 80% rename from packages/nuxt-cli/test/unit/dev-startup-log.spec.ts rename to packages/nuxt-cli/test/unit/phase-reporter.spec.ts index e3def7d67..c1493233d 100644 --- a/packages/nuxt-cli/test/unit/dev-startup-log.spec.ts +++ b/packages/nuxt-cli/test/unit/phase-reporter.spec.ts @@ -1,5 +1,5 @@ -import type { DevProgressSnapshot } from '../../src/dev/progress' -import type { StartupReporter } from '../../src/dev/startup-log' +import type { PhaseReporter } from '../../src/utils/phase-reporter' +import type { ProgressSnapshot } from '../../src/utils/progress-snapshot' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -13,9 +13,9 @@ vi.mock('std-env', async importOriginal => ({ process.env.FORCE_COLOR = '3' -const { createStartupReporter, formatSummary, isAnimationSupported } = await import('../../src/dev/startup-log') +const { createPhaseReporter, formatSummary, isAnimationSupported } = await import('../../src/utils/phase-reporter') -function snapshot(overrides: Partial = {}): DevProgressSnapshot { +function snapshot(overrides: Partial = {}): ProgressSnapshot { return { status: 'loading', phase: 'config', @@ -31,8 +31,8 @@ function snapshot(overrides: Partial = {}): DevProgressSnap } } -describe('startup reporter', () => { - const reporters: StartupReporter[] = [] +describe('phase reporter', () => { + const reporters: PhaseReporter[] = [] afterEach(() => { reporters.splice(0).forEach(reporter => reporter.stop()) @@ -45,8 +45,8 @@ describe('startup reporter', () => { vi.spyOn(Date, 'now').mockReturnValue(now) } - function reporter(animated: boolean): StartupReporter { - const instance = createStartupReporter({ animated }) + function reporter(animated: boolean): PhaseReporter { + const instance = createPhaseReporter({ animated }) reporters.push(instance) return instance } @@ -104,6 +104,31 @@ describe('startup reporter', () => { `) }) + it('should repeat a long phase when there is no animated line', async () => { + const renderer = await render(async ({ waitForOutput }) => { + const startup = createPhaseReporter({ animated: false, heartbeat: 20 }) + reporters.push(startup) + startup.update(snapshot({ phase: 'server', message: 'Building Nitro server', index: 5, elapsed: 4000 })) + await waitForOutput(/Building Nitro server[\s\S]*Building Nitro server/) + }) + + expect(screen(renderer)).toContain('Building Nitro server') + }) + + it('should name what a long phase is doing, no more often than its heartbeat', async () => { + const renderer = await render(() => { + const startup = createPhaseReporter({ animated: false, heartbeat: 60_000 }) + reporters.push(startup) + startup.update(snapshot({ phase: 'server', message: 'Building Nitro server', index: 5 })) + startup.update(snapshot({ phase: 'server', message: 'Bundling Nitro server', index: 5 })) + }) + + expect(screen(renderer)).toMatchInlineSnapshot(` + "│ + ● Building Nitro server" + `) + }) + it('should leave foreign output intact', async () => { const renderer = await render(() => { const startup = reporter(true) From f0d680226b6a484dddec2f6f2b17a67f7e43030c Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 24 Aug 2026 23:33:36 +0000 Subject: [PATCH 2/4] fix(build): keep the terminal recoverable if the phase line's first write fails --- packages/nuxt-cli/src/commands/build.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/nuxt-cli/src/commands/build.ts b/packages/nuxt-cli/src/commands/build.ts index f0ea04a16..e23c96b29 100644 --- a/packages/nuxt-cli/src/commands/build.ts +++ b/packages/nuxt-cli/src/commands/build.ts @@ -70,11 +70,15 @@ export default defineCommand({ // reported below it rather than above. if (ctx.args.logLevel !== 'silent') { const reporter = createPhaseReporter({ heartbeat: HEARTBEAT_INTERVAL }) - const unsubscribe = progress.onUpdate(reporter.update) + let unsubscribe: (() => void) | undefined + // Assigned before subscribing, because subscribing reports the phase in + // flight straight away: a first write that fails, on a pipe that has + // already been closed, must still leave the terminal recoverable. stopReporting = () => { - unsubscribe() + unsubscribe?.() reporter.stop() } + unsubscribe = progress.onUpdate(reporter.update) } const kit = await loadKit(cwd) From 71af08006e2438942bacb8e3646210041a9630f1 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:58:11 +0000 Subject: [PATCH 3/4] [autofix.ci] apply automated fixes --- capture/output/nuxt-init.svg | 17 +++++++---------- capture/output/nuxt-init.txt | 3 +-- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/capture/output/nuxt-init.svg b/capture/output/nuxt-init.svg index dbdbe37c9..f85fad5b1 100644 --- a/capture/output/nuxt-init.svg +++ b/capture/output/nuxt-init.svg @@ -16,15 +16,12 @@ svg{--bg:#ffffff;--fg:#24292f;--chrome:#f6f8fa;--dot:#d0d7de} npm create nuxt - .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P` - .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! - .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Loading available templates - .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Templates loaded Which template would you like to use?content · Starter for a content-driven website.minimal · Minimal starter with a single app.vue. (recommended)module · Starter to create your first Nuxt module.ui · Starter with Nuxt UI.v5-nightly · Minimal setup for Nuxt 5 Nightly↑/↓ to navigate • Enter: confirm - .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Templates loaded Which template would you like to use?minimal · Minimal starter with a single app.vue. Creating project in my-app - .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Templates loaded Which template would you like to use?minimal · Minimal starter with a single app.vue. Creating project in my-app Downloading minimal template - .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Templates loaded Which template would you like to use?minimal · Minimal starter with a single app.vue. Creating project in my-app - .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Templates loaded Which template would you like to use?minimal · Minimal starter with a single app.vue. Creating project in my-app Downloading minimal template - .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Templates loaded Which template would you like to use?minimal · Minimal starter with a single app.vue. Creating project in my-app Downloaded minimal template Skipping install dependencies step. Would you like to browse and install modules?Yes/ No - Creating project in my-app Downloaded minimal template Skipping install dependencies step. Would you like to browse and install modules?No Created your project from the minimal template to scaffold this project again without prompts:npm create nuxt@latest my-app -- --template=minimal --packageManager=npm --no-gitInit \ --no-modules --no-install Next steps:cd my-appnpm installnpm run dev ✨ Happy building! + .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P` + .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! + .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Loading available templates + .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Templates loaded Which template would you like to use?content · Starter for a content-driven website.minimal · Minimal starter with a single app.vue. (recommended)module · Starter to create your first Nuxt module.ui · Starter with Nuxt UI.v5-nightly · Minimal setup for Nuxt 5 Nightly↑/↓ to navigate • Enter: confirm + .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Templates loaded Which template would you like to use?minimal · Minimal starter with a single app.vue. Creating project in my-app Downloaded minimal template + .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Templates loaded Which template would you like to use?minimal · Minimal starter with a single app.vue. Creating project in my-app Downloaded minimal template Skipping install dependencies step. Would you like to browse and install modules?Yes/ No + Creating project in my-app Downloaded minimal template Skipping install dependencies step. Would you like to browse and install modules?No Created your project from the minimal template to scaffold this project again without prompts:npm create nuxt@latest my-app -- --template=minimal --packageManager=npm --no-gitInit \ --no-modules --no-install Next steps:cd my-appnpm installnpm run dev ✨ Happy building! diff --git a/capture/output/nuxt-init.txt b/capture/output/nuxt-init.txt index e8aceb800..941d33f97 100644 --- a/capture/output/nuxt-init.txt +++ b/capture/output/nuxt-init.txt @@ -1,4 +1,4 @@ -styles: 5e2e229d1874e510 +styles: 9bd2f07916a1acbf .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. @@ -36,5 +36,4 @@ styles: 5e2e229d1874e510 ◇ Would you like to browse and install modules? ● Skipping install dependencies step. ● to scaffold this project again without prompts: -◐ Downloading minimal template ◐ Loading available templates From 053353e799420e769a0181fd605a2c9eeeae1003 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:00:25 +0000 Subject: [PATCH 4/4] [autofix.ci] apply automated fixes (attempt 2/3) --- capture/output/nuxt-init.svg | 16 +++++++++------- capture/output/nuxt-init.txt | 3 ++- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/capture/output/nuxt-init.svg b/capture/output/nuxt-init.svg index f85fad5b1..8e85c132b 100644 --- a/capture/output/nuxt-init.svg +++ b/capture/output/nuxt-init.svg @@ -16,12 +16,14 @@ svg{--bg:#ffffff;--fg:#24292f;--chrome:#f6f8fa;--dot:#d0d7de} npm create nuxt - .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P` - .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! - .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Loading available templates - .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Templates loaded Which template would you like to use?content · Starter for a content-driven website.minimal · Minimal starter with a single app.vue. (recommended)module · Starter to create your first Nuxt module.ui · Starter with Nuxt UI.v5-nightly · Minimal setup for Nuxt 5 Nightly↑/↓ to navigate • Enter: confirm - .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Templates loaded Which template would you like to use?minimal · Minimal starter with a single app.vue. Creating project in my-app Downloaded minimal template - .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Templates loaded Which template would you like to use?minimal · Minimal starter with a single app.vue. Creating project in my-app Downloaded minimal template Skipping install dependencies step. Would you like to browse and install modules?Yes/ No - Creating project in my-app Downloaded minimal template Skipping install dependencies step. Would you like to browse and install modules?No Created your project from the minimal template to scaffold this project again without prompts:npm create nuxt@latest my-app -- --template=minimal --packageManager=npm --no-gitInit \ --no-modules --no-install Next steps:cd my-appnpm installnpm run dev ✨ Happy building! + .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P` + .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! + .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! + .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Loading available templates + .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Templates loaded Which template would you like to use?content · Starter for a content-driven website.minimal · Minimal starter with a single app.vue. (recommended)module · Starter to create your first Nuxt module.ui · Starter with Nuxt UI.v5-nightly · Minimal setup for Nuxt 5 Nightly↑/↓ to navigate • Enter: confirm + .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Templates loaded Which template would you like to use?minimal · Minimal starter with a single app.vue. Creating project in my-app + .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Templates loaded Which template would you like to use?minimal · Minimal starter with a single app.vue. Creating project in my-app Downloading minimal template + .$$F` `$$L.$$A$$. j$$' `4$$:` `$$. j$$' .4$: `$$. j$$` .$$: `4$L :$$:____.d$$: _____.:$$: `4$$$$$$$$P` .i$$$$$$$$P`Welcome to Nuxt! Templates loaded Which template would you like to use?minimal · Minimal starter with a single app.vue. Creating project in my-app Downloaded minimal template Skipping install dependencies step. Would you like to browse and install modules?Yes/ No + Creating project in my-app Downloaded minimal template Skipping install dependencies step. Would you like to browse and install modules?No Created your project from the minimal template to scaffold this project again without prompts:npm create nuxt@latest my-app -- --template=minimal --packageManager=npm --no-gitInit \ --no-modules --no-install Next steps:cd my-appnpm installnpm run dev ✨ Happy building! diff --git a/capture/output/nuxt-init.txt b/capture/output/nuxt-init.txt index 941d33f97..e8aceb800 100644 --- a/capture/output/nuxt-init.txt +++ b/capture/output/nuxt-init.txt @@ -1,4 +1,4 @@ -styles: 9bd2f07916a1acbf +styles: 5e2e229d1874e510 .d$b. i$$A$$L .d$b .$$F` `$$L.$$A$$. @@ -36,4 +36,5 @@ styles: 9bd2f07916a1acbf ◇ Would you like to browse and install modules? ● Skipping install dependencies step. ● to scaffold this project again without prompts: +◐ Downloading minimal template ◐ Loading available templates