feat: build lifecycle hooks - afterProductionCompile (#77345)

Add a `runAfterProductionCompile` hook in next.config.js that executes
after production build compilation finishes, but before running
post-compilation tasks like type checking and static page generation.
This hook provides access to project metadata including the project
directory and build output directory, making it useful for third-party
to collect build outputs like sourcemaps.
This commit is contained in:
Jude Gao
2025-05-02 08:44:45 -04:00
committed by GitHub
parent 37c97d699f
commit ec6c710845
11 changed files with 213 additions and 0 deletions
@@ -0,0 +1,63 @@
import type { NextConfigComplete } from '../server/config-shared'
import type { Span } from '../trace'
import * as Log from './output/log'
import createSpinner from './spinner'
import isError from '../lib/is-error'
import type { Telemetry } from '../telemetry/storage'
import { EVENT_BUILD_FEATURE_USAGE } from '../telemetry/events/build'
// TODO: refactor this to account for more compiler lifecycle events
// such as beforeProductionBuild, but for now this is the only one that is needed
export async function runAfterProductionCompile({
config,
buildSpan,
telemetry,
metadata,
}: {
config: NextConfigComplete
buildSpan: Span
telemetry: Telemetry
metadata: {
projectDir: string
distDir: string
}
}): Promise<void> {
const run = config.compiler.runAfterProductionCompile
if (!run) {
return
}
telemetry.record([
{
eventName: EVENT_BUILD_FEATURE_USAGE,
payload: {
featureName: 'runAfterProductionCompile',
invocationCount: 1,
},
},
])
const afterBuildSpinner = createSpinner(
'Running next.config.js provided runAfterProductionCompile'
)
try {
const startTime = performance.now()
await buildSpan
.traceChild('after-production-compile')
.traceAsyncFn(async () => {
await run(metadata)
})
const duration = performance.now() - startTime
const formattedDuration = `${Math.round(duration)}ms`
Log.event(`Completed runAfterProductionCompile in ${formattedDuration}`)
} catch (err) {
// Handle specific known errors differently if needed
if (isError(err)) {
Log.error(`Failed to run runAfterProductionCompile: ${err.message}`)
}
throw err
} finally {
afterBuildSpinner?.stop()
}
}
+10
View File
@@ -214,6 +214,7 @@ import { populateStaticEnv } from '../lib/static-env'
import { durationToString } from './duration-to-string'
import { traceGlobals } from '../trace/shared'
import { extractNextErrorCode } from '../lib/error-telemetry-utils'
import { runAfterProductionCompile } from './after-production-compile'
type Fallback = null | boolean | string
@@ -1583,6 +1584,15 @@ export default async function build(
)
}
}
await runAfterProductionCompile({
config,
buildSpan: nextBuildSpan,
telemetry,
metadata: {
projectDir: dir,
distDir,
},
})
}
// For app directory, we run type checking after build.
@@ -263,6 +263,10 @@ export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>
}),
]),
define: z.record(z.string(), z.string()).optional(),
runAfterProductionCompile: z
.function()
.returns(z.promise(z.void()))
.optional(),
})
.optional(),
compress: z.boolean().optional(),
+17
View File
@@ -1055,6 +1055,22 @@ export interface NextConfig extends Record<string, any> {
* replaced with the respective values.
*/
define?: Record<string, string>
/**
* A hook function that executes after production build compilation finishes,
* but before running post-compilation tasks such as type checking and
* static page generation.
*/
runAfterProductionCompile?: (metadata: {
/**
* The root directory of the project
*/
projectDir: string
/**
* The build output directory (defaults to `.next`)
*/
distDir: string
}) => Promise<void>
}
/**
@@ -1200,6 +1216,7 @@ export const defaultConfig: NextConfig = {
keepAlive: true,
},
logging: {},
compiler: {},
expireTime: process.env.NEXT_PRIVATE_CDN_CONSUMED_SWR_CACHE_CONTROL
? undefined
: 31536000, // one year
@@ -195,6 +195,7 @@ export type EventBuildFeatureUsage = {
| 'webpackPlugins'
| UseCacheTrackerKey
| 'turbopackPersistentCaching'
| 'runAfterProductionCompile'
invocationCount: number
}
export function eventBuildFeatureUsage(
@@ -0,0 +1,21 @@
import fs from 'fs/promises'
export async function after({
distDir,
projectDir,
}: {
distDir: string
projectDir: string
}) {
try {
console.log(`Using distDir: ${distDir}`)
console.log(`Using projectDir: ${projectDir}`)
await new Promise((resolve) => setTimeout(resolve, 5000))
const files = await fs.readdir(distDir, { recursive: true })
console.log(`Total files in ${distDir} folder: ${files.length}`)
} catch (err) {
console.error(`Error reading ${distDir} directory:`, err)
}
}
@@ -0,0 +1,13 @@
import React from 'react'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html>
<body>{children}</body>
</html>
)
}
@@ -0,0 +1,9 @@
import React from 'react'
export default function Page() {
return (
<div>
<h1>Hello, World!</h1>
</div>
)
}
@@ -0,0 +1,9 @@
export async function after({
distDir,
projectDir,
}: {
distDir: string
projectDir: string
}) {
throw new Error('error after production build')
}
@@ -0,0 +1,54 @@
import { nextTestSetup } from 'e2e-utils'
import { findAllTelemetryEvents } from 'next-test-utils'
describe('build-lifecycle-hooks', () => {
const { next } = nextTestSetup({
files: __dirname,
env: {
NEXT_TELEMETRY_DEBUG: '1',
},
})
it('should run runAfterProductionCompile', async () => {
const output = next.cliOutput
expect(output).toContain('')
expect(output).toContain(`Using distDir: ${next.testDir}/.next`)
expect(output).toContain(`Using projectDir: ${next.testDir}`)
expect(output).toContain(`Total files in ${next.testDir}/.next folder:`)
expect(output).toContain('Completed runAfterProductionCompile in')
// Ensure telemetry event is recorded
const events = findAllTelemetryEvents(output, 'NEXT_BUILD_FEATURE_USAGE')
expect(events).toContainEqual({
featureName: 'runAfterProductionCompile',
invocationCount: 1,
})
})
it('should allow throwing error in runAfterProductionCompile', async () => {
try {
await next.stop()
await next.patchFile('next.config.ts', (content) => {
return content.replace(
`import { after } from './after'`,
`import { after } from './bad-after'`
)
})
const getCliOutput = next.getCliOutputFromHere()
await next.build()
expect(getCliOutput()).toContain('error after production build')
expect(getCliOutput()).not.toContain(
'Completed runAfterProductionCompile in'
)
} finally {
await next.patchFile('next.config.ts', (content) => {
return content.replace(
`import { after } from './bad-after'`,
`import { after } from './after'`
)
})
}
})
})
@@ -0,0 +1,12 @@
import { NextConfig } from 'next'
import { after } from './after'
const nextConfig: NextConfig = {
compiler: {
runAfterProductionCompile: async ({ distDir, projectDir }) => {
await after({ distDir, projectDir })
},
},
}
export default nextConfig