Skip to content

Commit 8d6bc16

Browse files
committed
after production compile
1 parent 0e1463f commit 8d6bc16

File tree

11 files changed

+231
-0
lines changed

11 files changed

+231
-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
@@ -213,6 +213,7 @@ import { populateStaticEnv } from '../lib/static-env'
213213
import { durationToString } from './duration-to-string'
214214
import { traceGlobals } from '../trace/shared'
215215
import { extractNextErrorCode } from '../lib/error-telemetry-utils'
216+
import { runAfterProductionCompile } from './after-production-compile'
216217

217218
type Fallback = null | boolean | string
218219

@@ -1586,6 +1587,15 @@ export default async function build(
15861587
)
15871588
}
15881589
}
1590+
await runAfterProductionCompile({
1591+
config,
1592+
buildSpan: nextBuildSpan,
1593+
telemetry,
1594+
metadata: {
1595+
projectDir: dir,
1596+
distDir,
1597+
},
1598+
})
15891599
}
15901600

15911601
// 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: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -998,6 +998,22 @@ export interface NextConfig extends Record<string, any> {
998998
* replaced with the respective values.
999999
*/
10001000
define?: Record<string, string>
1001+
1002+
/**
1003+
* A hook function that executes after production build compilation finishes,
1004+
* but before running post-compilation tasks such as type checking and
1005+
* static page generation.
1006+
*/
1007+
runAfterProductionCompile?: (metadata: {
1008+
/**
1009+
* The root directory of the project
1010+
*/
1011+
projectDir: string
1012+
/**
1013+
* The build output directory (defaults to `.next`)
1014+
*/
1015+
distDir: string
1016+
}) => Promise<void>
10011017
}
10021018

10031019
/**
@@ -1138,6 +1154,7 @@ export const defaultConfig: NextConfig = {
11381154
keepAlive: true,
11391155
},
11401156
logging: {},
1157+
compiler: {},
11411158
expireTime: process.env.NEXT_PRIVATE_CDN_CONSUMED_SWR_CACHE_CONTROL
11421159
? undefined
11431160
: 31536000, // one year

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ export type EventBuildFeatureUsage = {
195195
| 'webpackPlugins'
196196
| UseCacheTrackerKey
197197
| 'turbopackPersistentCaching'
198+
| 'runAfterProductionCompile'
198199
invocationCount: number
199200
}
200201
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)