Skip to content

Commit 23a5a6c

Browse files
committed
fix(analyze): guard builds and simplify serving
1 parent 50e71e5 commit 23a5a6c

6 files changed

Lines changed: 127 additions & 150 deletions

File tree

packages/nuxt-cli/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,6 @@
9292
"@types/node": "^24.13.3",
9393
"giget": "^3.3.1",
9494
"h3": "^1.15.11",
95-
"h3-next": "npm:h3@^2.0.1-rc.26",
9695
"jiti": "^2.7.0",
9796
"nitro": "^3.0.1-alpha.2",
9897
"nitropack": "^2.13.4",

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

Lines changed: 66 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@ import { styleText } from 'node:util'
77
import { intro, note, outro, taskLog } from '@clack/prompts'
88
import { defineCommand } from 'citty'
99
import { defu } from 'defu'
10-
import { H3, lazyEventHandler } from 'h3-next'
11-
import { join } from 'pathe'
10+
import { join, relative, resolve } from 'pathe'
1211
import { serve } from 'srvx'
1312

1413
import { overrideEnv } from '../utils/env'
1514
import { clearDir } from '../utils/fs'
1615
import { loadKit } from '../utils/kit'
16+
import { acquireLock, acquireOutputLock, formatLockError } from '../utils/lockfile'
1717
import { logger } from '../utils/logger'
1818
import { relativeToProcess, resolveRootDir } from '../utils/paths'
1919
import { dotEnvArgs, extendsArgs, logLevelArgs, rootDirArgs } from './_shared'
@@ -72,7 +72,7 @@ export default defineCommand({
7272

7373
const cwd = resolveRootDir(ctx.args)
7474
const name = ctx.args.name || 'default'
75-
const slug = name.trim().replace(NON_WORD_RE, '_')
75+
const slug = name.trim().replace(NON_WORD_RE, '_') || 'default'
7676

7777
intro(styleText('cyan', 'Analyzing bundle size...'))
7878

@@ -131,62 +131,86 @@ export default defineCommand({
131131

132132
const analyzeDir = nuxt.options.analyzeDir
133133
const buildDir = nuxt.options.buildDir
134-
const outDir
135-
= nuxt.options.nitro.output?.dir || join(nuxt.options.rootDir, '.output')
134+
const outDir = resolve(nuxt.options.rootDir, nuxt.options.nitro.output?.dir || '.output')
136135

137136
nuxt.options.build.analyze = defu(nuxt.options.build.analyze, {
138137
filename: join(analyzeDir, 'client.html'),
139138
})
140139

141-
const tasklog = taskLog({
142-
title: 'Building Nuxt with analysis enabled',
143-
retainLog: false,
144-
limit: 1,
145-
})
146-
147-
tasklog.message('Clearing analyze directory...')
148-
await clearDir(analyzeDir)
149-
tasklog.message('Building Nuxt...')
150-
await buildNuxt(nuxt)
151-
tasklog.success('Build complete')
140+
const lockInfo = { command: 'analyze' as const, cwd }
141+
const lock = acquireLock(buildDir, lockInfo)
142+
if (lock.existing) {
143+
logger.error(formatLockError(lock.existing))
144+
throw new Error(`Another Nuxt ${lock.existing.command} is already running (PID ${lock.existing.pid}).`)
145+
}
152146

153-
if (skippedPrerenderRoutes > 0) {
154-
logger.info(`Skipped prerendering ${skippedPrerenderRoutes} route${skippedPrerenderRoutes === 1 ? '' : 's'}. Pass ${styleText('cyan', '--prerender')} to include assets emitted while prerendering.`)
147+
const outputLock = acquireOutputLock(nuxt.options.rootDir, outDir, lockInfo)
148+
if (outputLock.existing) {
149+
lock.release()
150+
logger.error(formatLockError(outputLock.existing))
151+
throw new Error(`Another Nuxt build is already writing to ${relative(process.cwd(), outDir)} (PID ${outputLock.existing.pid}).`)
155152
}
156153

157-
const endTime = Date.now()
154+
try {
155+
const tasklog = taskLog({
156+
title: 'Building Nuxt with analysis enabled',
157+
retainLog: false,
158+
limit: 1,
159+
})
158160

159-
const meta: NuxtAnalyzeMeta = {
160-
name,
161-
slug,
162-
startTime,
163-
endTime,
164-
analyzeDir,
165-
buildDir,
166-
outDir,
161+
tasklog.message('Clearing analyze directory...')
162+
await clearDir(analyzeDir)
163+
tasklog.message('Building Nuxt...')
164+
await buildNuxt(nuxt)
165+
tasklog.success('Build complete')
166+
167+
if (skippedPrerenderRoutes > 0) {
168+
logger.info(`Skipped prerendering ${skippedPrerenderRoutes} route${skippedPrerenderRoutes === 1 ? '' : 's'}. Pass ${styleText('cyan', '--prerender')} to include assets emitted while prerendering.`)
169+
}
170+
171+
const meta: NuxtAnalyzeMeta = {
172+
name,
173+
slug,
174+
startTime,
175+
endTime: Date.now(),
176+
analyzeDir,
177+
buildDir,
178+
outDir,
179+
}
180+
181+
await nuxt.callHook('build:analyze:done', meta)
182+
await fsp.writeFile(join(analyzeDir, 'meta.json'), JSON.stringify(meta, null, 2), 'utf-8')
183+
}
184+
finally {
185+
outputLock.release()
186+
lock.release()
167187
}
168-
169-
await nuxt.callHook('build:analyze:done', meta)
170-
await fsp.writeFile(join(analyzeDir, 'meta.json'), JSON.stringify(meta, null, 2), 'utf-8')
171188

172189
note(`${relativeToProcess(analyzeDir)}\n\nDo not deploy analyze results! Use ${styleText('cyan', 'nuxt build')} before deploying.`, 'Build location')
173190

174191
if (ctx.args.serve !== false && !process.env.CI) {
175-
const app = new H3()
176-
177-
const opts = { headers: { 'content-type': 'text/html' } }
178-
const serveFile = (filePath: string) => lazyEventHandler(async () => {
179-
const contents = await fsp.readFile(filePath, 'utf-8')
180-
return () => new Response(contents, opts)
181-
})
192+
const headers = { 'content-type': 'text/html' }
193+
const readReport = (name: string) => fsp.readFile(join(analyzeDir, name), 'utf8').catch(() => undefined)
194+
const reports = new Map([
195+
['/client', await readReport('client.html')],
196+
['/nitro', await readReport('nitro.html')],
197+
])
182198

183199
logger.step('Starting stats server...')
184200

185-
app.use('/client', serveFile(join(analyzeDir, 'client.html')))
186-
app.use('/nitro', serveFile(join(analyzeDir, 'nitro.html')))
187-
app.use(() => new Response(indexHtml, opts))
188-
189-
await serve(app).serve()
201+
await serve({
202+
hostname: process.env.HOST || 'localhost',
203+
fetch(request) {
204+
const pathname = new URL(request.url).pathname.replace(/\/$/, '')
205+
if (reports.has(pathname)) {
206+
const report = reports.get(pathname)
207+
return report === undefined
208+
? new Response('This report was not generated by the analyze build.', { status: 404, headers })
209+
: new Response(report, { headers })
210+
}
211+
return new Response(indexHtml, { headers })
212+
},
213+
}).ready()
190214
}
191215
else {
192216
outro('✨ Analysis build complete!')

packages/nuxt-cli/src/utils/lockfile.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { isCI } from 'std-env'
88
export interface LockInfo {
99
pid: number
1010
startedAt: number
11-
command: 'dev' | 'build'
11+
command: 'dev' | 'build' | 'analyze'
1212
cwd: string
1313
/**
1414
* Whether the holder was started from a terminal a user is sitting at. Only

packages/nuxt-cli/test/unit/commands/analyze.spec.ts

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import type { Nuxt } from '@nuxt/schema'
22

3-
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
3+
import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'
44
import { tmpdir } from 'node:os'
5-
import { join } from 'node:path'
65

76
import { runCommand } from 'citty'
7+
import { join } from 'pathe'
88
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
99

1010
import analyze from '../../../src/commands/analyze'
@@ -24,22 +24,27 @@ function createHooks() {
2424
}
2525
}
2626

27-
const { loadNuxt, buildNuxt } = vi.hoisted(() => ({
28-
loadNuxt: vi.fn(),
27+
const { acquireLock, acquireOutputLock, buildNuxt, loadNuxt, releaseBuildLock, releaseOutputLock } = vi.hoisted(() => ({
28+
acquireLock: vi.fn(),
29+
acquireOutputLock: vi.fn(),
2930
buildNuxt: vi.fn(),
31+
loadNuxt: vi.fn(),
32+
releaseBuildLock: vi.fn(),
33+
releaseOutputLock: vi.fn(),
3034
}))
3135

3236
vi.mock('../../../src/utils/kit', () => ({
3337
loadKit: () => Promise.resolve({ loadNuxt, buildNuxt }),
3438
}))
3539

40+
vi.mock('../../../src/utils/lockfile', () => ({
41+
acquireLock,
42+
acquireOutputLock,
43+
formatLockError: vi.fn(() => 'locked'),
44+
}))
45+
3646
let cwd: string
3747

38-
/**
39-
* Runs the command against a stub Nuxt, driving the same sequence Nitro does:
40-
* route rules and explicit routes are gathered into a set, `prerender:routes`
41-
* gets a chance to change it, and `prerender.ignore` filters what is left.
42-
*/
4348
async function runAnalyze({ args = [], routes = [] }: { args?: string[], routes?: string[] } = {}) {
4449
const nuxtHooks = createHooks()
4550
const prerenderRoutes = new Set(routes)
@@ -80,6 +85,8 @@ async function runAnalyze({ args = [], routes = [] }: { args?: string[], routes?
8085
describe('nuxt analyze command', () => {
8186
beforeEach(async () => {
8287
vi.clearAllMocks()
88+
acquireLock.mockReturnValue({ release: releaseBuildLock })
89+
acquireOutputLock.mockReturnValue({ release: releaseOutputLock })
8390
cwd = await mkdtemp(join(tmpdir(), 'nuxt-analyze-'))
8491
})
8592

@@ -110,4 +117,45 @@ describe('nuxt analyze command', () => {
110117
expect(overrides.nitro?.prerender).toBeUndefined()
111118
expect(output).not.toContain('Skipped prerendering')
112119
})
120+
121+
it('should write metadata and fall back to a non-empty slug', async () => {
122+
await runAnalyze({ args: ['--name= '] })
123+
124+
const meta = JSON.parse(await readFile(join(cwd, 'analyze/meta.json'), 'utf8'))
125+
expect(meta).toMatchObject({
126+
name: ' ',
127+
slug: 'default',
128+
analyzeDir: join(cwd, 'analyze'),
129+
buildDir: join(cwd, '.nuxt'),
130+
outDir: join(cwd, '.output'),
131+
})
132+
expect(meta.endTime).toBeGreaterThanOrEqual(meta.startTime)
133+
})
134+
135+
it('should lock build directories and release both locks', async () => {
136+
await runAnalyze()
137+
138+
expect(acquireLock).toHaveBeenCalledWith(join(cwd, '.nuxt'), { command: 'analyze', cwd })
139+
expect(acquireOutputLock).toHaveBeenCalledWith(cwd, join(cwd, '.output'), { command: 'analyze', cwd })
140+
expect(releaseOutputLock).toHaveBeenCalledOnce()
141+
expect(releaseBuildLock).toHaveBeenCalledOnce()
142+
})
143+
144+
it('should release locks when the build fails', async () => {
145+
buildNuxt.mockRejectedValueOnce(new Error('build failed'))
146+
147+
await expect(runAnalyze()).rejects.toThrow('build failed')
148+
expect(releaseOutputLock).toHaveBeenCalledOnce()
149+
expect(releaseBuildLock).toHaveBeenCalledOnce()
150+
})
151+
152+
it('should release the build lock when the output is locked', async () => {
153+
acquireOutputLock.mockReturnValueOnce({
154+
existing: { command: 'build', pid: 42 },
155+
})
156+
157+
await expect(runAnalyze()).rejects.toThrow('Another Nuxt build is already writing')
158+
expect(buildNuxt).not.toHaveBeenCalled()
159+
expect(releaseBuildLock).toHaveBeenCalledOnce()
160+
})
113161
})

packages/nuxt-cli/tsdown.config.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ export const packaging: PackagingContract = {
1212

1313
export default defineCliConfig({
1414
entry: ['src/index.ts', 'src/dev/index.ts'],
15-
// h3 is inlined as we have two different versions (+ rou3 is a transitive dep of h3-next)
16-
deps: { onlyBundle: ['h3', 'rou3'], neverBundle: PARSER_PACKAGES },
15+
deps: { onlyBundle: ['h3'], neverBundle: PARSER_PACKAGES },
1716
...packaging,
1817
})

0 commit comments

Comments
 (0)