Skip to content

Commit 2e29f1f

Browse files
feat(build): report build phases and where the time went (#1490)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 14c16bf commit 2e29f1f

15 files changed

Lines changed: 528 additions & 105 deletions

capture/output/nuxt-dev-plain-restart.svg

Lines changed: 22 additions & 20 deletions
Loading

capture/output/nuxt-init.svg

Lines changed: 9 additions & 10 deletions
Loading

packages/nuxt-cli/src/commands/build.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,23 @@ import { defineCommand } from 'citty'
66
import { relative } from 'pathe'
77
import { resolveDotenvFileNames } from '../utils/args'
88
import { showBanner } from '../utils/banner'
9-
9+
import { BuildProgress } from '../utils/build-progress'
1010
import { overrideEnv } from '../utils/env'
11+
1112
import { ActionableError } from '../utils/errors'
1213
import { formatDuration } from '../utils/formatting'
1314
import { clearBuildDir } from '../utils/fs'
1415
import { loadKit } from '../utils/kit'
1516
import { acquireLock, acquireOutputLock, formatLockError } from '../utils/lockfile'
1617
import { intro, logger, outro } from '../utils/logger'
1718
import { resolveRootDir } from '../utils/paths'
19+
import { createPhaseReporter, formatPhaseBreakdown } from '../utils/phase-reporter'
1820
import { startCpuProfile, stopCpuProfile } from '../utils/profile'
1921
import { dotEnvArgs, envNameArgs, extendsArgs, logLevelArgs, profileArgs, rootDirArgs } from './_shared'
2022

23+
/** How often a phase repeats itself where there is no animated line. */
24+
const HEARTBEAT_INTERVAL = 5000
25+
2126
export default defineCommand({
2227
meta: {
2328
name: 'build',
@@ -55,9 +60,27 @@ export default defineCommand({
5560
}
5661

5762
const releaseLocks: Array<() => void> = []
63+
const progress = new BuildProgress()
64+
let stopReporting = () => {}
5865
try {
5966
intro(styleText('cyan', 'Building Nuxt for production...'))
6067

68+
// The phase line owns a row of the terminal, which a silent build has no
69+
// business drawing on. Subscribed after the intro so the first phase is
70+
// reported below it rather than above.
71+
if (ctx.args.logLevel !== 'silent') {
72+
const reporter = createPhaseReporter({ heartbeat: HEARTBEAT_INTERVAL })
73+
let unsubscribe: (() => void) | undefined
74+
// Assigned before subscribing, because subscribing reports the phase in
75+
// flight straight away: a first write that fails, on a pipe that has
76+
// already been closed, must still leave the terminal recoverable.
77+
stopReporting = () => {
78+
unsubscribe?.()
79+
reporter.stop()
80+
}
81+
unsubscribe = progress.onUpdate(reporter.update)
82+
}
83+
6184
const kit = await loadKit(cwd)
6285
const nuxt = await kit.loadNuxt({
6386
cwd,
@@ -85,6 +108,8 @@ export default defineCommand({
85108
},
86109
})
87110

111+
progress.attachNuxt(nuxt)
112+
88113
showBanner(nuxt)
89114
await nuxt.ready()
90115

@@ -115,6 +140,14 @@ export default defineCommand({
115140

116141
await kit.buildNuxt(nuxt)
117142

143+
stopReporting()
144+
progress.finish()
145+
146+
const breakdown = formatPhaseBreakdown(progress.timings)
147+
if (breakdown) {
148+
logger.message(styleText('dim', breakdown))
149+
}
150+
118151
if (ctx.args.prerender) {
119152
if (!nuxt.options.ssr) {
120153
logger.warn(`HTML content not prerendered because ${styleText('cyan', 'ssr: false')} was set.`)
@@ -129,6 +162,7 @@ export default defineCommand({
129162
}
130163
}
131164
finally {
165+
stopReporting()
132166
for (const release of releaseLocks.reverse()) {
133167
release()
134168
}

packages/nuxt-cli/src/dev/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* eslint-disable perfectionist/sort-imports -- `./force-tty` must be evaluated before anything that loads `std-env` or `consola` */
22
import type { NuxtConfig } from '@nuxt/schema'
33
import type { DevListenOverrides, Listener, ListenURL } from './listen'
4-
import type { DevProgressSnapshot } from './progress'
4+
import type { ProgressSnapshot } from '../utils/progress-snapshot'
55
import type { DevRestartReason } from './reason'
66
import type { ServerLogEvent } from './log-channel'
77
import type { DevRequestEvent, DevRoutes, NuxtDevContext, NuxtDevIPCMessage, NuxtParentIPCMessage } from './utils'
@@ -21,7 +21,7 @@ import { debug } from '../utils/logger'
2121
import { startCpuProfile, stopCpuProfile } from '../utils/profile.ts'
2222
import { openInspector } from './inspect'
2323
import { currentRequest, isServingRequest } from './serving-state'
24-
import { createStartupReporter } from './startup-log'
24+
import { createPhaseReporter } from '../utils/phase-reporter'
2525
import { NuxtDevServer } from './utils'
2626

2727
const start = Date.now()
@@ -93,7 +93,7 @@ interface InitializeOptions {
9393
* Called with every startup progress snapshot, from before the first load
9494
* begins, so a UI can narrate startup as it happens rather than after.
9595
*/
96-
onProgress?: (snapshot: DevProgressSnapshot) => void
96+
onProgress?: (snapshot: ProgressSnapshot) => void
9797
/**
9898
* Called as soon as a socket is bound, milliseconds into startup, and again
9999
* with `confirmed` once the resolved config has agreed with the address.
@@ -357,7 +357,7 @@ export async function initialize(devContext: NuxtDevContext, ctx: InitializeOpti
357357
// itself, so the transient reporter line would only fight it for the screen.
358358
const reporter = devContext.args.logLevel === 'silent' || ipc.enabled || ctx.captureUIEvents
359359
? undefined
360-
: createStartupReporter()
360+
: createPhaseReporter()
361361
const unsubscribeProgress = reporter && devServer.progress.onUpdate(reporter.update)
362362
const stopReporting = () => {
363363
unsubscribeProgress?.()

packages/nuxt-cli/src/dev/loading-client.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { DevProgressSnapshot } from './progress'
1+
import type { ProgressSnapshot } from '../utils/progress-snapshot'
22

33
/**
44
* The scripts inlined into the dev server's loading and error pages.
@@ -36,7 +36,7 @@ export function progressClient(options: ProgressClientOptions): void {
3636
caption.textContent = label ? `${label} \u00B7 ${seconds}` : seconds
3737
}
3838

39-
function apply(snapshot: DevProgressSnapshot): void {
39+
function apply(snapshot: ProgressSnapshot): void {
4040
start = Date.now() - snapshot.elapsed
4141
// The message, not the phase id: it carries whatever detail the server has,
4242
// 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 {
5353
paint()
5454
}
5555

56-
function read(event: Event): DevProgressSnapshot | undefined {
56+
function read(event: Event): ProgressSnapshot | undefined {
5757
try {
5858
return JSON.parse((event as MessageEvent).data)
5959
}

packages/nuxt-cli/src/dev/loading-page.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { DevProgressSnapshot } from './progress'
1+
import type { ProgressSnapshot } from '../utils/progress-snapshot'
22

33
import { inlineScript, progressClient, recoveryClient } from './loading-client'
44
import { PROGRESS_PATH } from './progress'
@@ -16,7 +16,7 @@ const MAX_POLL_INTERVAL_MS = 1000
1616
const STYLES = `.nuxt-loader-bar{right:auto!important;width:var(${PROGRESS_PROPERTY},4%);transition:width .3s ease}
1717
#${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}`
1818

19-
function progressTags(snapshot: DevProgressSnapshot): string {
19+
function progressTags(snapshot: ProgressSnapshot): string {
2020
return `<style>${STYLES}</style>${inlineScript(progressClient, {
2121
progressPath: PROGRESS_PATH,
2222
captionId: CAPTION_ID,
@@ -35,7 +35,7 @@ function progressTags(snapshot: DevProgressSnapshot): string {
3535
* adds is a determinate bar, a phase caption, the build error inline, and a
3636
* reload driven by the server rather than by a poll interval.
3737
*/
38-
export function withProgress(html: string, snapshot: DevProgressSnapshot): string {
38+
export function withProgress(html: string, snapshot: ProgressSnapshot): string {
3939
const tags = progressTags(snapshot)
4040
const index = html.lastIndexOf('</body>')
4141
return index === -1 ? html + tags : html.slice(0, index) + tags + html.slice(index)

packages/nuxt-cli/src/dev/progress.ts

Lines changed: 8 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { IncomingMessage, ServerResponse } from 'node:http'
2+
import type { PhaseTiming, ProgressSnapshot, ProgressStatus } from '../utils/progress-snapshot'
23

34
/** Path prefix reserved for the CLI's own dev-time endpoints. */
45
export const DEV_INTERNAL_PREFIX: string = '/__nuxt_dev__/'
@@ -107,33 +108,6 @@ function moduleName(module: unknown): string | undefined {
107108
return typeof name === 'string' && name ? name : undefined
108109
}
109110

110-
export type DevProgressStatus = 'loading' | 'ready' | 'error'
111-
112-
interface DevPhaseTiming {
113-
phase: string
114-
message: string
115-
duration: number
116-
}
117-
118-
export interface DevProgressSnapshot {
119-
status: DevProgressStatus
120-
phase: string
121-
message: string
122-
index: number
123-
total: number
124-
progress: number
125-
elapsed: number
126-
reload: boolean
127-
/**
128-
* Whether a request has actually been answered. `status` is `ready` from the
129-
* moment the server is listening, so this is what tells a UI whether the app
130-
* can be used yet.
131-
*/
132-
serving: boolean
133-
timings: DevPhaseTiming[]
134-
error?: { name: string, message: string }
135-
}
136-
137111
interface HookableLike {
138112
beforeEach?: (fn: (event: { name: string, args?: unknown[] }) => void) => void
139113
afterEach?: (fn: (event: { name: string }) => void) => void
@@ -150,16 +124,16 @@ interface ActiveHook {
150124
*/
151125
export class DevProgress {
152126
#clients = new Set<ServerResponse>()
153-
#listeners = new Set<(snapshot: DevProgressSnapshot) => void>()
127+
#listeners = new Set<(snapshot: ProgressSnapshot) => void>()
154128
#heartbeat?: NodeJS.Timeout
155129

156130
#index = 0
157131
#message = DEV_PHASES[0]!.message
158-
#status: DevProgressStatus = 'loading'
132+
#status: ProgressStatus = 'loading'
159133
#error?: Error
160134
#startedAt = Date.now()
161135
#phaseStartedAt = Date.now()
162-
#timings: DevPhaseTiming[] = []
136+
#timings: PhaseTiming[] = []
163137
#reload = false
164138
#baseMessage = DEV_PHASES[0]!.message
165139
#module?: ActiveHook
@@ -170,7 +144,7 @@ export class DevProgress {
170144
#observed = new WeakSet<HookableLike>()
171145
#ticker?: NodeJS.Timeout
172146

173-
get snapshot(): DevProgressSnapshot {
147+
get snapshot(): ProgressSnapshot {
174148
const phase = DEV_PHASES[this.#index]!
175149
return {
176150
status: this.#status,
@@ -189,11 +163,11 @@ export class DevProgress {
189163
}
190164
}
191165

192-
get timings(): DevPhaseTiming[] {
166+
get timings(): PhaseTiming[] {
193167
return this.#timings
194168
}
195169

196-
onUpdate(listener: (snapshot: DevProgressSnapshot) => void): () => void {
170+
onUpdate(listener: (snapshot: ProgressSnapshot) => void): () => void {
197171
this.#listeners.add(listener)
198172
return () => this.#listeners.delete(listener)
199173
}
@@ -512,7 +486,7 @@ export class DevProgress {
512486
}
513487
}
514488

515-
#send(res: ServerResponse, snapshot: DevProgressSnapshot): void {
489+
#send(res: ServerResponse, snapshot: ProgressSnapshot): void {
516490
if (res.writableEnded) {
517491
return
518492
}

packages/nuxt-cli/src/dev/tui/session.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
import type { ProgressSnapshot } from '../../utils/progress-snapshot'
12
import type { ListenURL } from '../listen'
2-
import type { DevProgressSnapshot } from '../progress'
33
import type { DevLogEvent } from './events'
44
import type { PanelState } from './panel'
55
import type { DevUISupportOptions } from './support'
@@ -85,7 +85,7 @@ export interface DevUISession {
8585
/** Stop the session's own startup animation, once the controller drives it. */
8686
stopStartupTicker: () => void
8787
/** Narrate the current startup phase while the server is loading. */
88-
reportProgress: (snapshot: DevProgressSnapshot) => void
88+
reportProgress: (snapshot: ProgressSnapshot) => void
8989
/**
9090
* Show the bound address the moment the socket answers, spinning until the
9191
* resolved config confirms it. The full URL block replaces it on ready.
@@ -175,7 +175,7 @@ export function beginDevUI(options: DevUISupportOptions & { version?: string, cw
175175
surface.render(renderPanel(state, process.stdout.columns || 80, process.stdout.rows || 24))
176176
}
177177

178-
function reportProgress(snapshot: DevProgressSnapshot): void {
178+
function reportProgress(snapshot: ProgressSnapshot): void {
179179
if (snapshot.status === 'ready') {
180180
// Between the server accepting requests and answering one there is
181181
// nothing to watch but a badge, so it says which of the two has happened.

0 commit comments

Comments
 (0)