Skip to content

Commit 182ca80

Browse files
committed
after production compile
1 parent c640c57 commit 182ca80

File tree

11 files changed

+230
-0
lines changed

11 files changed

+230
-0
lines changed
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import type { NextConfigComplete } from '../server/config-shared'
2+
import type { Span } from '../trace'
3+
4+
import * as Log from './output/log'
5+
import createSpinner from './spinner'
6+
import isError from '../lib/is-error'
7+
import type { Telemetry } from '../telemetry/storage'
8+
import { EVENT_BUILD_FEATURE_USAGE } from '../telemetry/events/build'
9+
10+
// TODO: refactor this to account for more compiler lifecycle events
11+
// such as beforeProductionBuild, but for now this is the only one that is needed
12+
export async function runAfterProductionCompile({
13+
config,
14+
buildSpan,
15+
telemetry,
16+
metadata,
17+
}: {
18+
config: NextConfigComplete
19+
buildSpan: Span
20+
telemetry: Telemetry
21+
metadata: {
22+
projectDir: string
23+
distDir: string
24+
}
25+
}): Promise<void> {
26+
const run = config.compiler.runAfterProductionCompile
27+
if (!run) {
28+
return
29+
}
30+
telemetry.record([
31+
{
32+
eventName: EVENT_BUILD_FEATURE_USAGE,
33+
payload: {
34+
featureName: 'runAfterProductionCompile',
35+
invocationCount: 1,
36+
},
37+
},
38+
])
39+
const afterBuildSpinner = createSpinner('Running runAfterProductionCompile')
40+
41+
try {
42+
const startTime = performance.now()
43+
await buildSpan
44+
.traceChild('after-production-compile')
45+
.traceAsyncFn(async () => {
46+
await run(metadata)
47+
})
48+
const duration = performance.now() - startTime
49+
const formattedDuration = `${Math.round(duration)}ms`
50+
Log.event(`Completed runAfterProductionCompile in ${formattedDuration}`)
51+
} catch (err) {
52+
// Handle specific known errors differently if needed
53+
if (isError(err)) {
54+
Log.error(`Failed to run runAfterProductionCompile: ${err.message}`)
55+
}
56+
57+
throw err
58+
} finally {
59+
afterBuildSpinner?.stop()
60+
}
61+
}

packages/next/src/build/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ import { isPersistentCachingEnabled } from '../shared/lib/turbopack/utils'
210210
import { inlineStaticEnv } from '../lib/inline-static-env'
211211
import { populateStaticEnv } from '../lib/static-env'
212212
import { durationToString } from './duration-to-string'
213+
import { runAfterProductionCompile } from './after-production-compile'
213214

214215
type Fallback = null | boolean | string
215216

@@ -1579,6 +1580,15 @@ export default async function build(
15791580
)
15801581
}
15811582
}
1583+
await runAfterProductionCompile({
1584+
config,
1585+
buildSpan: nextBuildSpan,
1586+
telemetry,
1587+
metadata: {
1588+
projectDir: dir,
1589+
distDir,
1590+
},
1591+
})
15821592
}
15831593

15841594
// For app directory, we run type checking after build.

packages/next/src/server/config-schema.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,10 @@ export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>
220220
}),
221221
]),
222222
define: z.record(z.string(), z.string()).optional(),
223+
runAfterProductionCompile: z
224+
.function()
225+
.returns(z.promise(z.void()))
226+
.optional(),
223227
})
224228
.optional(),
225229
compress: z.boolean().optional(),

packages/next/src/server/config-shared.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -998,6 +998,21 @@ export interface NextConfig extends Record<string, any> {
998998
* replaced with the respective values.
999999
*/
10001000
define?: Record<string, string>
1001+
1002+
/**
1003+
* Hook that runs after the production build completes but before the server exits.
1004+
* Only executes when compilation is successful.
1005+
*/
1006+
runAfterProductionCompile?: (metadata: {
1007+
/**
1008+
* The root directory of the project
1009+
*/
1010+
projectDir: string
1011+
/**
1012+
* The build output directory (defaults to `.next`)
1013+
*/
1014+
distDir: string
1015+
}) => Promise<void>
10011016
}
10021017

10031018
/**
@@ -1138,6 +1153,7 @@ export const defaultConfig: NextConfig = {
11381153
keepAlive: true,
11391154
},
11401155
logging: {},
1156+
compiler: {},
11411157
expireTime: process.env.NEXT_PRIVATE_CDN_CONSUMED_SWR_CACHE_CONTROL
11421158
? undefined
11431159
: 31536000, // one year

packages/next/src/telemetry/events/build.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ export type EventBuildFeatureUsage = {
180180
| 'webpackPlugins'
181181
| UseCacheTrackerKey
182182
| 'turbopackPersistentCaching'
183+
| 'runAfterProductionCompile'
183184
invocationCount: number
184185
}
185186
export function eventBuildFeatureUsage(
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import fs from 'fs/promises'
2+
3+
export async function after({
4+
distDir,
5+
projectDir,
6+
}: {
7+
distDir: string
8+
projectDir: string
9+
}) {
10+
try {
11+
console.log(`Using distDir: ${distDir}`)
12+
console.log(`Using projectDir: ${projectDir}`)
13+
14+
await new Promise((resolve) => setTimeout(resolve, 5000))
15+
16+
const files = await fs.readdir(distDir, { recursive: true })
17+
console.log(`Total files in ${distDir} folder: ${files.length}`)
18+
} catch (err) {
19+
console.error(`Error reading ${distDir} directory:`, err)
20+
}
21+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import React from 'react'
2+
3+
export default function RootLayout({
4+
children,
5+
}: {
6+
children: React.ReactNode
7+
}) {
8+
return (
9+
<html>
10+
<body>{children}</body>
11+
</html>
12+
)
13+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import React from 'react'
2+
3+
export default function Page() {
4+
return (
5+
<div>
6+
<h1>Hello, World!</h1>
7+
</div>
8+
)
9+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export async function after({
2+
distDir,
3+
projectDir,
4+
}: {
5+
distDir: string
6+
projectDir: string
7+
}) {
8+
throw new Error('error after production build')
9+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { nextTestSetup } from 'e2e-utils'
2+
import { findAllTelemetryEvents } from 'next-test-utils'
3+
4+
describe('build-lifecycle-hooks', () => {
5+
const { next } = nextTestSetup({
6+
files: __dirname,
7+
env: {
8+
NEXT_TELEMETRY_DEBUG: '1',
9+
},
10+
})
11+
12+
it('should run runAfterProductionCompile', async () => {
13+
const output = next.cliOutput
14+
15+
expect(output).toContain('')
16+
expect(output).toContain(`Using distDir: ${next.testDir}/.next`)
17+
expect(output).toContain(`Using projectDir: ${next.testDir}`)
18+
expect(output).toContain(`Total files in ${next.testDir}/.next folder:`)
19+
expect(output).toContain('Completed runAfterProductionCompile in')
20+
21+
// Ensure telemetry event is recorded
22+
const events = findAllTelemetryEvents(output, 'NEXT_BUILD_FEATURE_USAGE')
23+
expect(events).toContainEqual({
24+
featureName: 'runAfterProductionCompile',
25+
invocationCount: 1,
26+
})
27+
})
28+
29+
it('should not execute runAfterProductionCompile if compilation fails', async () => {
30+
try {
31+
await next.stop()
32+
await next.patchFile('app/layout.tsx', (content) => {
33+
return content + '{'
34+
})
35+
36+
const getCliOutput = next.getCliOutputFromHere()
37+
await next.build()
38+
expect(getCliOutput()).not.toContain('Total files in .next folder')
39+
expect(getCliOutput()).not.toContain(
40+
'Completed runAfterProductionCompile in'
41+
)
42+
} finally {
43+
await next.patchFile('app/layout.tsx', (content) => {
44+
return content.slice(0, -1)
45+
})
46+
}
47+
})
48+
49+
it('should throw an error', async () => {
50+
try {
51+
await next.stop()
52+
await next.patchFile('next.config.ts', (content) => {
53+
return content.replace(
54+
`import { after } from './after'`,
55+
`import { after } from './bad-after'`
56+
)
57+
})
58+
59+
const getCliOutput = next.getCliOutputFromHere()
60+
await next.build()
61+
expect(getCliOutput()).toContain('error after production build')
62+
expect(getCliOutput()).not.toContain(
63+
'Completed runAfterProductionCompile in'
64+
)
65+
} finally {
66+
await next.patchFile('next.config.ts', (content) => {
67+
return content.replace(
68+
`import { after } from './bad-after'`,
69+
`import { after } from './after'`
70+
)
71+
})
72+
}
73+
})
74+
})

0 commit comments

Comments
 (0)