diff --git a/packages/next/src/build/after-production-compile.ts b/packages/next/src/build/after-production-compile.ts new file mode 100644 index 0000000000000..5fa214522bae9 --- /dev/null +++ b/packages/next/src/build/after-production-compile.ts @@ -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 { + 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() + } +} diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index 3d558d0622734..a8da6d0840f8c 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -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. diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index 95dfed2621a52..d6482fc6b06ea 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -263,6 +263,10 @@ export const configSchema: zod.ZodType = z.lazy(() => }), ]), define: z.record(z.string(), z.string()).optional(), + runAfterProductionCompile: z + .function() + .returns(z.promise(z.void())) + .optional(), }) .optional(), compress: z.boolean().optional(), diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 5338af57cbf21..0947d3776be12 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -1055,6 +1055,22 @@ export interface NextConfig extends Record { * replaced with the respective values. */ define?: Record + + /** + * 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 } /** @@ -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 diff --git a/packages/next/src/telemetry/events/build.ts b/packages/next/src/telemetry/events/build.ts index d7c3614e0cc03..5796efdff7f32 100644 --- a/packages/next/src/telemetry/events/build.ts +++ b/packages/next/src/telemetry/events/build.ts @@ -195,6 +195,7 @@ export type EventBuildFeatureUsage = { | 'webpackPlugins' | UseCacheTrackerKey | 'turbopackPersistentCaching' + | 'runAfterProductionCompile' invocationCount: number } export function eventBuildFeatureUsage( diff --git a/test/production/build-lifecycle-hooks/after.ts b/test/production/build-lifecycle-hooks/after.ts new file mode 100644 index 0000000000000..d439d65dca1e6 --- /dev/null +++ b/test/production/build-lifecycle-hooks/after.ts @@ -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) + } +} diff --git a/test/production/build-lifecycle-hooks/app/layout.tsx b/test/production/build-lifecycle-hooks/app/layout.tsx new file mode 100644 index 0000000000000..e324d469a4813 --- /dev/null +++ b/test/production/build-lifecycle-hooks/app/layout.tsx @@ -0,0 +1,13 @@ +import React from 'react' + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/test/production/build-lifecycle-hooks/app/page.tsx b/test/production/build-lifecycle-hooks/app/page.tsx new file mode 100644 index 0000000000000..172376e4f2f57 --- /dev/null +++ b/test/production/build-lifecycle-hooks/app/page.tsx @@ -0,0 +1,9 @@ +import React from 'react' + +export default function Page() { + return ( +
+

Hello, World!

+
+ ) +} diff --git a/test/production/build-lifecycle-hooks/bad-after.ts b/test/production/build-lifecycle-hooks/bad-after.ts new file mode 100644 index 0000000000000..fddd40a495d04 --- /dev/null +++ b/test/production/build-lifecycle-hooks/bad-after.ts @@ -0,0 +1,9 @@ +export async function after({ + distDir, + projectDir, +}: { + distDir: string + projectDir: string +}) { + throw new Error('error after production build') +} diff --git a/test/production/build-lifecycle-hooks/index.test.ts b/test/production/build-lifecycle-hooks/index.test.ts new file mode 100644 index 0000000000000..95887fb72c979 --- /dev/null +++ b/test/production/build-lifecycle-hooks/index.test.ts @@ -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'` + ) + }) + } + }) +}) diff --git a/test/production/build-lifecycle-hooks/next.config.ts b/test/production/build-lifecycle-hooks/next.config.ts new file mode 100644 index 0000000000000..691b32f99e27f --- /dev/null +++ b/test/production/build-lifecycle-hooks/next.config.ts @@ -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