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
constcwd=resolveRootDir(ctx.args)
7474
constname=ctx.args.name||'default'
75-
constslug=name.trim().replace(NON_WORD_RE,'_')
75+
constslug=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
constanalyzeDir=nuxt.options.analyzeDir
133133
constbuildDir=nuxt.options.buildDir
134-
constoutDir
135-
=nuxt.options.nitro.output?.dir||join(nuxt.options.rootDir,'.output')
134+
constoutDir=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-
consttasklog=taskLog({
142-
title: 'Building Nuxt with analysis enabled',
143-
retainLog: false,
144-
limit: 1,
145-
})
146-
147-
tasklog.message('Clearing analyze directory...')
148-
awaitclearDir(analyzeDir)
149-
tasklog.message('Building Nuxt...')
150-
awaitbuildNuxt(nuxt)
151-
tasklog.success('Build complete')
140+
constlockInfo={command: 'analyze'asconst, cwd }
141+
constlock=acquireLock(buildDir,lockInfo)
142+
if(lock.existing){
143+
logger.error(formatLockError(lock.existing))
144+
thrownewError(`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+
constoutputLock=acquireOutputLock(nuxt.options.rootDir,outDir,lockInfo)
148+
if(outputLock.existing){
149+
lock.release()
150+
logger.error(formatLockError(outputLock.existing))
151+
thrownewError(`Another Nuxt build is already writing to ${relative(process.cwd(),outDir)} (PID ${outputLock.existing.pid}).`)
155152
}
156153

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

159-
constmeta: NuxtAnalyzeMeta={
160-
name,
161-
slug,
162-
startTime,
163-
endTime,
164-
analyzeDir,
165-
buildDir,
166-
outDir,
161+
tasklog.message('Clearing analyze directory...')
162+
awaitclearDir(analyzeDir)
163+
tasklog.message('Building Nuxt...')
164+
awaitbuildNuxt(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+
constmeta: NuxtAnalyzeMeta={
172+
name,
173+
slug,
174+
startTime,
175+
endTime: Date.now(),
176+
analyzeDir,
177+
buildDir,
178+
outDir,
179+
}
180+
181+
awaitnuxt.callHook('build:analyze:done',meta)
182+
awaitfsp.writeFile(join(analyzeDir,'meta.json'),JSON.stringify(meta,null,2),'utf-8')
183+
}
184+
finally{
185+
outputLock.release()
186+
lock.release()
167187
}
168-
169-
awaitnuxt.callHook('build:analyze:done',meta)
170-
awaitfsp.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-
constapp=newH3()
176-
177-
constopts={headers: {'content-type': 'text/html'}}
178-
constserveFile=(filePath: string)=>lazyEventHandler(async()=>{
179-
constcontents=awaitfsp.readFile(filePath,'utf-8')
180-
return()=>newResponse(contents,opts)
181-
})
192+
constheaders={'content-type': 'text/html'}
193+
constreadReport=(name: string)=>fsp.readFile(join(analyzeDir,name),'utf8').catch(()=>undefined)
194+
constreports=newMap([
195+
['/client',awaitreadReport('client.html')],
196+
['/nitro',awaitreadReport('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(()=>newResponse(indexHtml,opts))
188-
189-
awaitserve(app).serve()
201+
awaitserve({
202+
hostname: process.env.HOST||'localhost',
203+
fetch(request){
204+
constpathname=newURL(request.url).pathname.replace(/\/$/,'')
205+
if(reports.has(pathname)){
206+
constreport=reports.get(pathname)
207+
returnreport===undefined
208+
? newResponse('This report was not generated by the analyze build.',{status: 404, headers })
209+
: newResponse(report,{ headers })
210+
}
211+
returnnewResponse(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
exportinterfaceLockInfo{
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
importtype{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
importanalyzefrom'../../../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
letcwd: 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
asyncfunctionrunAnalyze({ args =[], routes =[]}: {args?: string[],routes?: string[]}={}){
4449
constnuxtHooks=createHooks()
4550
constprerenderRoutes=newSet(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=awaitmkdtemp(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+
awaitrunAnalyze({args: ['--name= ']})
123+
124+
constmeta=JSON.parse(awaitreadFile(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+
awaitrunAnalyze()
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(newError('build failed'))
146+
147+
awaitexpect(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+
awaitexpect(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
exportdefaultdefineCliConfig({
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)