diff --git a/.changeset/next-diagnostics-dist.md b/.changeset/next-diagnostics-dist.md new file mode 100644 index 0000000000..4c362ed52a --- /dev/null +++ b/.changeset/next-diagnostics-dist.md @@ -0,0 +1,6 @@ +--- +"@workflow/builders": patch +"@workflow/next": patch +--- + +Write Next.js workflow diagnostics manifests inside the Next.js dist directory and only use `.vercel/output/diagnostics` for the Vercel Build Output API builder. diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index b3678f1adf..5e28dc09b5 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'; import { mkdir, readFile, realpath, rename, writeFile } from 'node:fs/promises'; import { basename, dirname, join, relative, resolve } from 'node:path'; import { promisify } from 'node:util'; -import { pluralize, usesVercelWorld } from '@workflow/utils'; +import { pluralize } from '@workflow/utils'; import chalk from 'chalk'; import enhancedResolveOriginal from 'enhanced-resolve'; import * as esbuild from 'esbuild'; @@ -1320,13 +1320,23 @@ export const OPTIONS = handler;`; } /** - * Whether diagnostics artifacts should be emitted to Vercel output. - * This is enabled when the resolved world target is Vercel. + * Resolves the workflow manifest diagnostics path, when this builder should + * emit one. */ - protected get shouldEmitVercelDiagnostics(): boolean { - return ( - usesVercelWorld() || this.config.buildTarget === 'vercel-build-output-api' - ); + protected getDiagnosticsManifestPath(): string | undefined { + if (this.config.diagnosticsDir) { + return resolve( + this.config.workingDir, + this.config.diagnosticsDir, + 'workflows-manifest.json' + ); + } + + if (this.config.buildTarget === 'vercel-build-output-api') { + return this.resolvePath( + '.vercel/output/diagnostics/workflows-manifest.json' + ); + } } /** @@ -1362,10 +1372,9 @@ export const OPTIONS = handler;`; await mkdir(manifestDir, { recursive: true }); await writeFile(join(manifestDir, 'manifest.json'), manifestJson); - if (this.shouldEmitVercelDiagnostics) { - const diagnosticsManifestPath = this.resolvePath( - '.vercel/output/diagnostics/workflows-manifest.json' - ); + + const diagnosticsManifestPath = this.getDiagnosticsManifestPath(); + if (diagnosticsManifestPath) { await this.ensureDirectory(diagnosticsManifestPath); await writeFile(diagnosticsManifestPath, manifestJson); } diff --git a/packages/builders/src/get-input-files.test.ts b/packages/builders/src/get-input-files.test.ts index c8eeca7b77..92df5b9798 100644 --- a/packages/builders/src/get-input-files.test.ts +++ b/packages/builders/src/get-input-files.test.ts @@ -9,7 +9,7 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { BaseBuilder } from './base-builder.js'; -import type { StandaloneConfig } from './types.js'; +import type { StandaloneConfig, VercelBuildOutputConfig } from './types.js'; /** * Minimal subclass to expose the protected `getInputFiles()` for testing. @@ -23,6 +23,10 @@ class TestBuilder extends BaseBuilder { public getInputFiles(): Promise { return super.getInputFiles(); } + + public getDiagnosticsManifestPath(): string | undefined { + return super.getDiagnosticsManifestPath(); + } } // Resolve symlinks in tmpdir to avoid macOS /var -> /private/var issues @@ -44,7 +48,11 @@ function writeFile(dir: string, relativePath: string, content = ''): string { return fullPath; } -function createBuilder(workingDir: string, dirs: string[]): TestBuilder { +function createBuilder( + workingDir: string, + dirs: string[], + configOverrides: Partial = {} +): TestBuilder { const config: StandaloneConfig = { buildTarget: 'standalone', workingDir, @@ -52,6 +60,7 @@ function createBuilder(workingDir: string, dirs: string[]): TestBuilder { stepsBundlePath: join(workingDir, 'steps.js'), workflowsBundlePath: join(workingDir, 'workflows.js'), webhookBundlePath: join(workingDir, 'webhook.js'), + ...configOverrides, }; return new TestBuilder(config); } @@ -172,3 +181,57 @@ describe('getInputFiles', () => { expect(files).toContain(normalize(join(srcDir, '.api/config.cjs'))); }); }); + +describe('getDiagnosticsManifestPath', () => { + let testRoot: string; + + beforeEach(() => { + testRoot = mkdtempSync(join(realTmpdir, 'diagnostics-path-')); + }); + + afterEach(() => { + rmSync(testRoot, { recursive: true, force: true }); + }); + + it('uses an explicit diagnosticsDir when configured', () => { + const builder = createBuilder(testRoot, ['src'], { + diagnosticsDir: '.next/diagnostics', + }); + + expect(builder.getDiagnosticsManifestPath()).toBe( + join(testRoot, '.next/diagnostics/workflows-manifest.json') + ); + }); + + it('does not emit Vercel diagnostics for non-Vercel builder targets', () => { + const previousTargetWorld = process.env.WORKFLOW_TARGET_WORLD; + process.env.WORKFLOW_TARGET_WORLD = 'vercel'; + try { + const builder = createBuilder(testRoot, ['src']); + + expect(builder.getDiagnosticsManifestPath()).toBeUndefined(); + } finally { + if (previousTargetWorld === undefined) { + delete process.env.WORKFLOW_TARGET_WORLD; + } else { + process.env.WORKFLOW_TARGET_WORLD = previousTargetWorld; + } + } + }); + + it('falls back to Vercel output diagnostics for the Vercel builder', () => { + const config: VercelBuildOutputConfig = { + buildTarget: 'vercel-build-output-api', + workingDir: testRoot, + dirs: ['src'], + stepsBundlePath: join(testRoot, 'steps.js'), + workflowsBundlePath: join(testRoot, 'workflows.js'), + webhookBundlePath: join(testRoot, 'webhook.js'), + }; + const builder = new TestBuilder(config); + + expect(builder.getDiagnosticsManifestPath()).toBe( + join(testRoot, '.vercel/output/diagnostics/workflows-manifest.json') + ); + }); +}); diff --git a/packages/builders/src/types.ts b/packages/builders/src/types.ts index 688b14826e..99ffc7d685 100644 --- a/packages/builders/src/types.ts +++ b/packages/builders/src/types.ts @@ -33,6 +33,14 @@ interface BaseWorkflowConfig { // Optional prefix for debug files (e.g., "_" for Astro to ignore them) debugFilePrefix?: string; + // Optional directory where diagnostics artifacts should be written. + // The workflow manifest is written to workflows-manifest.json inside this dir. + diagnosticsDir?: string; + + // Optional framework output directory, used by builders that mirror framework + // artifact locations. + distDir?: string; + // Suppress informational logs emitted by createWorkflowsBundle() // (e.g. intermediate/final workflow bundle timing logs). suppressCreateWorkflowsBundleLogs?: boolean; diff --git a/packages/core/e2e/local-build.test.ts b/packages/core/e2e/local-build.test.ts index a59e839a7d..46d24bf343 100644 --- a/packages/core/e2e/local-build.test.ts +++ b/packages/core/e2e/local-build.test.ts @@ -2,7 +2,6 @@ import { spawn } from 'node:child_process'; import fs from 'node:fs/promises'; import path from 'node:path'; import { describe, expect, test } from 'vitest'; -import { usesVercelWorld } from '../../utils/src/world-target'; import { getWorkbenchAppPath } from './utils'; interface CommandResult { @@ -83,6 +82,12 @@ const ESM_STEP_BUNDLE_PROJECTS: Record = { '.vercel/output/functions/.well-known/workflow/v1/step.func/index.mjs', }; +const DIAGNOSTICS_MANIFEST_PATHS: Record = { + example: '.vercel/output/diagnostics/workflows-manifest.json', + 'nextjs-webpack': '.next/diagnostics/workflows-manifest.json', + 'nextjs-turbopack': '.next/diagnostics/workflows-manifest.json', +}; + const DEFERRED_BUILD_MODE_PROJECTS = new Set([ 'nextjs-webpack', 'nextjs-turbopack', @@ -128,12 +133,13 @@ describe.each([ } } - if (usesVercelWorld()) { - const diagnosticsManifestPath = path.join( + const diagnosticsManifestPath = DIAGNOSTICS_MANIFEST_PATHS[project]; + if (diagnosticsManifestPath) { + const resolvedDiagnosticsManifestPath = path.join( getWorkbenchAppPath(project), - '.vercel/output/diagnostics/workflows-manifest.json' + diagnosticsManifestPath ); - await fs.access(diagnosticsManifestPath); + await fs.access(resolvedDiagnosticsManifestPath); } // Verify ESM step bundles use native import.meta (no CJS polyfill needed) diff --git a/packages/next/src/builder-deferred.ts b/packages/next/src/builder-deferred.ts index cb918cf0c2..b9f57d44ee 100644 --- a/packages/next/src/builder-deferred.ts +++ b/packages/next/src/builder-deferred.ts @@ -786,7 +786,7 @@ export async function getNextBuilderDeferred() { } private getDistDir(): string { - return (this.config as { distDir?: string }).distDir || '.next'; + return this.config.distDir || '.next'; } private getWorkflowsCacheFilePath(): string { diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts index 08d22b78df..692008b6bb 100644 --- a/packages/next/src/index.test.ts +++ b/packages/next/src/index.test.ts @@ -50,7 +50,7 @@ const loaderStubPath = join( ); const hadLoaderStub = existsSync(loaderStubPath); -describe('withWorkflow outputFileTracingRoot', () => { +describe('withWorkflow builder config', () => { const originalEnv = { PORT: process.env.PORT, VERCEL_DEPLOYMENT_ID: process.env.VERCEL_DEPLOYMENT_ID, @@ -109,4 +109,32 @@ describe('withWorkflow outputFileTracingRoot', () => { workingDir: process.cwd(), }); }); + + it('configures diagnostics inside the default Next.js dist dir', async () => { + const config = withWorkflow({}); + + await config('phase-production-build', { + defaultConfig: {}, + }); + + expect(builderConfigs[0]).toMatchObject({ + distDir: '.next', + diagnosticsDir: '.next/diagnostics', + }); + }); + + it('configures diagnostics inside a custom Next.js dist dir', async () => { + const config = withWorkflow({ + distDir: 'build-output', + }); + + await config('phase-production-build', { + defaultConfig: {}, + }); + + expect(builderConfigs[0]).toMatchObject({ + distDir: 'build-output', + diagnosticsDir: 'build-output/diagnostics', + }); + }); }); diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index f16c45967e..d6b67df095 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -115,6 +115,7 @@ export function withWorkflow( supportsTurboCondition && !useDeferredBuilder; const shouldWatch = process.env.NODE_ENV === 'development'; let workflowBuilderPromise: Promise | undefined; + const distDir = nextConfig.distDir || '.next'; const getWorkflowBuilder = async () => { if (!workflowBuilderPromise) { @@ -126,7 +127,8 @@ export function withWorkflow( dirs: ['pages', 'app', 'src/pages', 'src/app'], projectRoot: nextConfig.outputFileTracingRoot, workingDir: process.cwd(), - distDir: nextConfig.distDir || '.next', + distDir, + diagnosticsDir: `${distDir}/diagnostics`, buildTarget: 'next', workflowsBundlePath: '', // not used in base stepsBundlePath: '', // not used in base