From 7503b5228956e220d099f2c304c7d854c4d770b9 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:13:23 -0700 Subject: [PATCH 1/5] fix(next): discover every Next entrypoint --- .changeset/next-entry-discovery.md | 5 ++ packages/next/src/builder-eager.test.ts | 43 ++++++++++ packages/next/src/builder-eager.ts | 104 ++++++++++++++++++------ 3 files changed, 127 insertions(+), 25 deletions(-) create mode 100644 .changeset/next-entry-discovery.md create mode 100644 packages/next/src/builder-eager.test.ts diff --git a/.changeset/next-entry-discovery.md b/.changeset/next-entry-discovery.md new file mode 100644 index 0000000000..7ddfe61dae --- /dev/null +++ b/.changeset/next-entry-discovery.md @@ -0,0 +1,5 @@ +--- +'@workflow/next': patch +--- + +Discover workflows imported by every Next.js file convention entrypoint. diff --git a/packages/next/src/builder-eager.test.ts b/packages/next/src/builder-eager.test.ts new file mode 100644 index 0000000000..9e06333796 --- /dev/null +++ b/packages/next/src/builder-eager.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { isNextEntrypoint } from './builder-eager.js'; + +const pageExtensions = ['js', 'jsx', 'ts', 'tsx']; + +describe('isNextEntrypoint', () => { + it.each([ + 'pages/index.tsx', + 'src/pages/api/run.ts', + 'app/page.tsx', + 'app/dashboard/error.tsx', + 'app/@modal/default.tsx', + 'app/blog/opengraph-image12.tsx', + 'app/global-error.tsx', + 'src/app/robots.ts', + 'instrumentation.ts', + 'src/instrumentation-client.ts', + 'proxy.ts', + 'mdx-components.tsx', + 'src/mdx-components.js', + ])('includes %s', (entry) => { + expect(isNextEntrypoint(entry, pageExtensions)).toBe(true); + }); + + it.each([ + 'app/component.tsx', + 'app/error.test.tsx', + 'app/_components/error.tsx', + 'src/app/_draft/loading.tsx', + 'app/blog/global-error.tsx', + 'app/blog/robots.ts', + 'src/random.ts', + 'app/page.vue', + ])('excludes %s', (entry) => { + expect(isNextEntrypoint(entry, pageExtensions)).toBe(false); + }); + + it('supports compound page extensions', () => { + expect(isNextEntrypoint('app/error.page.tsx', ['tsx', 'page.tsx'])).toBe( + true + ); + }); +}); diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index b6b9c33daf..70ca454e22 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -23,6 +23,79 @@ const importEsm = new Function('specifier', 'return import(specifier)') as ( specifier: string ) => Promise; +const appEntrypoints = new Set([ + 'page', + 'route', + 'layout', + 'default', + 'error', + 'loading', + 'template', + 'not-found', + 'forbidden', + 'unauthorized', + 'sitemap', + 'icon', + 'apple-icon', + 'opengraph-image', + 'twitter-image', +]); +const appRootEntrypoints = new Set([ + 'global-error', + 'global-not-found', + 'robots', + 'manifest', +]); +const rootEntrypoints = new Set([ + 'instrumentation', + 'instrumentation-client', + 'middleware', + 'proxy', +]); +const numberedMetadataEntrypoint = + /^(?:icon|apple-icon|opengraph-image|twitter-image)\d+$/; +const mdxComponentsEntrypoint = /^mdx-components\.[jt]sx?$/; + +export function isNextEntrypoint( + entry: string, + pageExtensions: readonly string[] +): boolean { + const path = entry.split('/'); + const filename = path.at(-1)!; + const inSrc = path[0] === 'src'; + const rootDepth = inSrc ? 2 : 1; + if (path.length === rootDepth && mdxComponentsEntrypoint.test(filename)) { + return true; + } + + const extension = [...pageExtensions] + .sort((a, b) => b.length - a.length) + .find((extension) => entry.endsWith(`.${extension}`)); + if (!extension) return false; + + const name = filename.slice(0, -extension.length - 1); + const directory = path[inSrc ? 1 : 0]; + + if (directory === 'pages') return true; + + if (directory === 'app') { + const segments = path.slice(inSrc ? 2 : 1, -1); + if (segments.some((segment) => segment.startsWith('_'))) return false; + + if (appEntrypoints.has(name) || numberedMetadataEntrypoint.test(name)) { + return true; + } + const appRootDepth = inSrc ? 3 : 2; + return path.length === appRootDepth && appRootEntrypoints.has(name); + } + + if (path.length === rootDepth) { + return rootEntrypoints.has(name); + } + + return false; +} + // Create the eager Next builder dynamically by extending the ESM BaseBuilder. // Exported as getNextBuilderEager() to allow CommonJS modules to import from // the ESM @workflow/builders package via dynamic import at runtime. @@ -622,31 +695,12 @@ export async function getNextBuilderEager( protected async getInputFiles(): Promise { const inputFiles = await super.getInputFiles(); - return inputFiles.filter((file) => { - const entry = relative(this.config.workingDir, file).replaceAll( - '\\', - '/' - ); - - // Match App Router route, page, and layout entrypoints in app/ or src/app/. - if (/^(?:app|src\/app)\/(?:.*\/)?(?:route|page|layout)\./.test(entry)) { - return true; - } - - // Match every Pages Router entrypoint in pages/ or src/pages/. - if (/^(?:pages|src\/pages)\//.test(entry)) { - return true; - } - - // Match Next.js root entrypoints at the project root or under src/. - return ['instrumentation', 'middleware', 'proxy'].some((name) => - this.config.pageExtensions.some( - (extension) => - entry === `${name}.${extension}` || - entry === `src/${name}.${extension}` - ) - ); - }); + return inputFiles.filter((file) => + isNextEntrypoint( + relative(this.config.workingDir, file).replaceAll('\\', '/'), + this.config.pageExtensions + ) + ); } private async writeFunctionsConfig(outputDir: string) { From 34ef2c06b23117e082db0765c1d62609528122b9 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Wed, 26 Aug 2026 02:17:56 -0700 Subject: [PATCH 2/5] fix(next): exclude declaration files from entries --- packages/next/src/builder-eager.test.ts | 2 ++ packages/next/src/builder-eager.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/packages/next/src/builder-eager.test.ts b/packages/next/src/builder-eager.test.ts index 9e06333796..357d7c870f 100644 --- a/packages/next/src/builder-eager.test.ts +++ b/packages/next/src/builder-eager.test.ts @@ -29,6 +29,8 @@ describe('isNextEntrypoint', () => { 'src/app/_draft/loading.tsx', 'app/blog/global-error.tsx', 'app/blog/robots.ts', + 'pages/types.d.ts', + 'app/page.d.ts', 'src/random.ts', 'app/page.vue', ])('excludes %s', (entry) => { diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index 70ca454e22..8f5024c369 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -60,6 +60,8 @@ export function isNextEntrypoint( entry: string, pageExtensions: readonly string[] ): boolean { + if (entry.endsWith('.d.ts')) return false; + const path = entry.split('/'); const filename = path.at(-1)!; const inSrc = path[0] === 'src'; From 7e16d144e583166cd186caf20f80b344f52bca4b Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:43:32 -0700 Subject: [PATCH 3/5] fix(next): align entry discovery with Next --- .changeset/next-entry-discovery.md | 3 +- packages/builders/src/fast-discovery.test.ts | 33 +++ packages/builders/src/fast-discovery.ts | 68 +++--- packages/next/src/builder-eager.test.ts | 142 ++++++++--- packages/next/src/builder-eager.ts | 238 ++++++++++--------- 5 files changed, 306 insertions(+), 178 deletions(-) diff --git a/.changeset/next-entry-discovery.md b/.changeset/next-entry-discovery.md index 7ddfe61dae..e9dfb1dc67 100644 --- a/.changeset/next-entry-discovery.md +++ b/.changeset/next-entry-discovery.md @@ -1,5 +1,6 @@ --- +'@workflow/builders': patch '@workflow/next': patch --- -Discover workflows imported by every Next.js file convention entrypoint. +Discover workflows imported by every Next.js file convention entrypoint, including custom page extensions. diff --git a/packages/builders/src/fast-discovery.test.ts b/packages/builders/src/fast-discovery.test.ts index b58ca0c57f..68aa447447 100644 --- a/packages/builders/src/fast-discovery.test.ts +++ b/packages/builders/src/fast-discovery.test.ts @@ -103,6 +103,39 @@ describe('fast workflow discovery', () => { ); }); + it('follows imports from custom source extensions', async () => { + const entryFile = join(testRoot, 'app', 'page.mdx'); + const componentFile = join(testRoot, 'app', 'component.mdx'); + const workflowFile = join(testRoot, 'app', 'workflow.ts'); + + writeFile( + entryFile, + `import Component from './component';\n\n` + ); + writeFile( + componentFile, + `import { run } from './workflow';\n\n"use workflow"\n\n{run.name}\n` + ); + writeFile( + workflowFile, + `export async function run() { + 'use workflow'; + return 'ok'; +} +` + ); + + const discovered = await createBuilder(testRoot).discoverEntriesPublic( + [entryFile], + join(testRoot, 'out') + ); + + expect(discovered.discoveredWorkflows).toEqual( + new Set([normalize(workflowFile)]) + ); + expect(discovered.discoveredFiles).toContain(normalize(componentFile)); + }); + it('discovers relative JS imports whose basename includes .step', async () => { const entryFile = join(testRoot, 'src', 'entry.ts'); const workflowFile = join(testRoot, 'src', 'hello.step.js'); diff --git a/packages/builders/src/fast-discovery.ts b/packages/builders/src/fast-discovery.ts index 03a90ee276..c41a74492d 100644 --- a/packages/builders/src/fast-discovery.ts +++ b/packages/builders/src/fast-discovery.ts @@ -21,13 +21,6 @@ const FAST_DISCOVERY_SOURCE_EXTENSIONS = [ const FAST_DISCOVERY_SOURCE_EXTENSION_SET = new Set( FAST_DISCOVERY_SOURCE_EXTENSIONS ); -const fastDiscoveryResolve = promisify( - enhancedResolveOriginal.create({ - extensions: [...FAST_DISCOVERY_SOURCE_EXTENSIONS, '.json', '.node'], - fullySpecified: false, - conditionNames: ['node', 'import', 'require'], - }) -); const require = createRequire(import.meta.url); const FAST_DISCOVERY_READ_CONCURRENCY = 32; @@ -50,7 +43,7 @@ export interface DiscoveredEntries { discoveredWorkflows: Set; discoveredSerdeFiles: Set; /** - * All JS/TS files visited while walking the workflow import graph. + * All source files visited while walking the workflow import graph. * Watch-mode integrations use this to distinguish relevant HMR changes from * unrelated application file edits. */ @@ -649,6 +642,18 @@ export async function fastDiscoverEntries({ workingDir, discoverWorkflowsInNodeModules = true, }: FastDiscoverEntriesOptions): Promise { + const sourceExtensionSet = new Set(FAST_DISCOVERY_SOURCE_EXTENSIONS); + for (const entryPoint of entryPoints) { + sourceExtensionSet.add(extname(entryPoint)); + } + const sourceExtensions = [...sourceExtensionSet]; + const fastDiscoveryResolve = promisify( + enhancedResolveOriginal.create({ + extensions: [...sourceExtensions, '.json', '.node'], + fullySpecified: false, + conditionNames: ['node', 'import', 'require'], + }) + ); const readLimit = createLimiter(FAST_DISCOVERY_READ_CONCURRENCY); const resolveLimit = createLimiter(FAST_DISCOVERY_RESOLVE_CONCURRENCY); const resolveCache = new Map>(); @@ -669,7 +674,7 @@ export async function fastDiscoverEntries({ if ( queuedFiles.has(normalizedPath) || processedFiles.has(normalizedPath) || - !isJsTsFile(normalizedPath) || + !sourceExtensionSet.has(extname(normalizedPath)) || isGeneratedBuildArtifactPath(normalizedPath) ) { return; @@ -752,15 +757,15 @@ export async function fastDiscoverEntries({ ? strippedSpecifier : resolve(dirname(importer), strippedSpecifier); const extension = extname(basePath); - if (FAST_DISCOVERY_SOURCE_EXTENSION_SET.has(extension)) { + if (sourceExtensionSet.has(extension)) { return (await fileExists(basePath)) ? normalizePath(basePath) : null; } for (const candidate of [ - ...FAST_DISCOVERY_SOURCE_EXTENSIONS.map( + ...sourceExtensions.map( (candidateExtension) => `${basePath}${candidateExtension}` ), - ...FAST_DISCOVERY_SOURCE_EXTENSIONS.map((candidateExtension) => + ...sourceExtensions.map((candidateExtension) => join(basePath, `index${candidateExtension}`) ), ]) { @@ -928,6 +933,22 @@ export async function fastDiscoverEntries({ return packageInfo?.hasWorkflowDependency === true; }; + const recordWorkflowPatterns = (filePath: string, source: string) => { + if (!isJsTsFile(filePath)) return false; + + const patterns = detectWorkflowPatterns(source); + if (patterns.hasUseWorkflow) { + state.discoveredWorkflows.add(filePath); + } + if (patterns.hasUseStep) { + state.discoveredSteps.add(filePath); + } + if (patterns.hasSerde && hasLikelySerdeClass(source)) { + state.discoveredSerdeFiles.add(filePath); + } + return patterns.hasDirective || patterns.hasSerde; + }; + const processImportSpecifier = async ( filePath: string, specifier: string, @@ -960,12 +981,10 @@ export async function fastDiscoverEntries({ } addImportParent(filePath, resolved); - if (!isJsTsFile(resolved) || isGeneratedBuildArtifactPath(resolved)) { - return; - } - - if (specifier.startsWith('.')) { - enqueueFile(resolved); + if ( + !sourceExtensionSet.has(extname(resolved)) || + isGeneratedBuildArtifactPath(resolved) + ) { return; } @@ -983,18 +1002,7 @@ export async function fastDiscoverEntries({ return; } - const patterns = detectWorkflowPatterns(source); - if (patterns.hasUseWorkflow) { - state.discoveredWorkflows.add(filePath); - } - if (patterns.hasUseStep) { - state.discoveredSteps.add(filePath); - } - if (patterns.hasSerde && hasLikelySerdeClass(source)) { - state.discoveredSerdeFiles.add(filePath); - } - - const forceFollowImports = patterns.hasDirective || patterns.hasSerde; + const forceFollowImports = recordWorkflowPatterns(filePath, source); const shouldFollow = await shouldFollowImportsFromFile( filePath, forceFollowImports diff --git a/packages/next/src/builder-eager.test.ts b/packages/next/src/builder-eager.test.ts index 357d7c870f..4b406afd74 100644 --- a/packages/next/src/builder-eager.test.ts +++ b/packages/next/src/builder-eager.test.ts @@ -1,45 +1,117 @@ -import { describe, expect, it } from 'vitest'; -import { isNextEntrypoint } from './builder-eager.js'; +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { findNextEntrypoints } from './builder-eager.js'; const pageExtensions = ['js', 'jsx', 'ts', 'tsx']; -describe('isNextEntrypoint', () => { - it.each([ - 'pages/index.tsx', - 'src/pages/api/run.ts', - 'app/page.tsx', - 'app/dashboard/error.tsx', - 'app/@modal/default.tsx', - 'app/blog/opengraph-image12.tsx', - 'app/global-error.tsx', - 'src/app/robots.ts', - 'instrumentation.ts', - 'src/instrumentation-client.ts', - 'proxy.ts', - 'mdx-components.tsx', - 'src/mdx-components.js', - ])('includes %s', (entry) => { - expect(isNextEntrypoint(entry, pageExtensions)).toBe(true); +describe('findNextEntrypoints', () => { + let projectDir: string; + + beforeEach(async () => { + projectDir = await mkdtemp( + join(await realpath(tmpdir()), 'workflow-next-') + ); + }); + + afterEach(async () => { + await rm(projectDir, { recursive: true, force: true }); + }); + + const createFile = async (path: string) => { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, ''); + }; + + const discover = async (extensions = pageExtensions) => { + const files = await findNextEntrypoints({ + workingDir: projectDir, + pageExtensions: extensions, + }); + return files.map((file) => + relative(projectDir, file).replaceAll('\\', '/') + ); + }; + + it('uses root app and pages directories when both root and src exist', async () => { + const included = [ + 'app/page.tsx', + 'app/dashboard/error.tsx', + 'app/@modal/default.tsx', + 'app/blog/opengraph-image.tsx', + 'app/blog/opengraph-image1.tsx', + 'app/global-error.tsx', + 'app/robots.ts', + 'pages/index.tsx', + 'pages/api/run.ts', + 'instrumentation.ts', + 'proxy.ts', + 'mdx-components.tsx', + ]; + const excluded = [ + 'app/component.tsx', + 'app/error.test.tsx', + 'app/_components/error.tsx', + 'app/blog/global-error.tsx', + 'app/blog/robots.ts', + 'app/blog/opengraph-image12.tsx', + 'app/icon.png', + 'app/.well-known/workflow/v1/flow/route.ts', + 'pages/types.d.ts', + 'src/app/page.tsx', + 'src/pages/index.tsx', + 'src/instrumentation-client.ts', + 'src/mdx-components.js', + ]; + await Promise.all( + [...included, ...excluded].map((file) => + createFile(join(projectDir, file)) + ) + ); + + expect(await discover()).toEqual(included.sort()); }); - it.each([ - 'app/component.tsx', - 'app/error.test.tsx', - 'app/_components/error.tsx', - 'src/app/_draft/loading.tsx', - 'app/blog/global-error.tsx', - 'app/blog/robots.ts', - 'pages/types.d.ts', - 'app/page.d.ts', - 'src/random.ts', - 'app/page.vue', - ])('excludes %s', (entry) => { - expect(isNextEntrypoint(entry, pageExtensions)).toBe(false); + it('uses src conventions when the routers live in src', async () => { + const included = [ + 'src/app/page.tsx', + 'src/pages/index.tsx', + 'src/instrumentation-client.ts', + 'src/mdx-components.js', + ]; + const excluded = ['instrumentation.ts', 'mdx-components.tsx']; + await Promise.all( + [...included, ...excluded].map((file) => + createFile(join(projectDir, file)) + ) + ); + + expect(await discover()).toEqual(included.sort()); }); - it('supports compound page extensions', () => { - expect(isNextEntrypoint('app/error.page.tsx', ['tsx', 'page.tsx'])).toBe( - true + it('discovers configured and compound page extensions', async () => { + const included = [ + 'app/page.mdx', + 'app/page.page.tsx', + 'app/error.page.tsx', + 'app/opengraph-image1.mdx', + 'pages/docs.mdx', + 'pages/docs.page.tsx', + 'proxy.mdx', + 'mdx-components.tsx', + ]; + const excluded = [ + 'app/page.tsx', + 'app/opengraph-image12.mdx', + 'pages/docs.tsx', + ]; + await Promise.all( + [...included, ...excluded].map((file) => + createFile(join(projectDir, file)) + ) ); + + expect(await discover(['mdx', 'page.tsx'])).toEqual(included.sort()); }); }); diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index 8f5024c369..3876a19a96 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -1,12 +1,24 @@ -import { constants, type Dirent } from 'node:fs'; -import { access, mkdir, readdir, realpath, rm, stat } from 'node:fs/promises'; -import { extname, isAbsolute, join, relative, resolve } from 'node:path'; +import assert from 'node:assert'; +import type { Dirent } from 'node:fs'; +import { mkdir, readdir, realpath, rm, stat } from 'node:fs/promises'; +import { + basename, + dirname, + extname, + isAbsolute, + join, + relative, + resolve, +} from 'node:path'; import type { NextConfig as BuilderNextConfig, WorkflowManifest, } from '@workflow/builders'; import chokidar from 'chokidar'; import type { NextConfig as ProjectNextConfig } from 'next'; +import { findDir, findPagesDir } from 'next/dist/lib/find-pages-dir'; +import { recursiveReadDir } from 'next/dist/lib/recursive-readdir'; +import { createValidFileMatcher } from 'next/dist/server/lib/find-page-file'; import { createWatchIgnorePredicate } from './watch-ignore.js'; import { classifyRebuild, @@ -23,79 +35,116 @@ const importEsm = new Function('specifier', 'return import(specifier)') as ( specifier: string ) => Promise; -const appEntrypoints = new Set([ - 'page', - 'route', - 'layout', - 'default', +const appConventionNames = [ 'error', 'loading', 'template', 'not-found', 'forbidden', 'unauthorized', - 'sitemap', - 'icon', - 'apple-icon', - 'opengraph-image', - 'twitter-image', -]); -const appRootEntrypoints = new Set([ - 'global-error', - 'global-not-found', - 'robots', - 'manifest', -]); -const rootEntrypoints = new Set([ +] as const; +const appRootConventionNames = ['global-error', 'global-not-found'] as const; +const rootConventionNames = [ 'instrumentation', 'instrumentation-client', 'middleware', 'proxy', -]); -const numberedMetadataEntrypoint = - /^(?:icon|apple-icon|opengraph-image|twitter-image)\d+$/; -const mdxComponentsEntrypoint = /^mdx-components\.[jt]sx?$/; - -export function isNextEntrypoint( - entry: string, - pageExtensions: readonly string[] -): boolean { - if (entry.endsWith('.d.ts')) return false; - - const path = entry.split('/'); - const filename = path.at(-1)!; - const inSrc = path[0] === 'src'; - const rootDepth = inSrc ? 2 : 1; - if (path.length === rootDepth && mdxComponentsEntrypoint.test(filename)) { - return true; - } - - const extension = [...pageExtensions] - .sort((a, b) => b.length - a.length) - .find((extension) => entry.endsWith(`.${extension}`)); - if (!extension) return false; - - const name = filename.slice(0, -extension.length - 1); - const directory = path[inSrc ? 1 : 0]; - - if (directory === 'pages') return true; - - if (directory === 'app') { - const segments = path.slice(inSrc ? 2 : 1, -1); - if (segments.some((segment) => segment.startsWith('_'))) return false; - - if (appEntrypoints.has(name) || numberedMetadataEntrypoint.test(name)) { +] as const; +const mdxComponentsFile = /^mdx-components\.[jt]sx?$/; + +export async function findNextEntrypoints({ + workingDir, + pageExtensions, +}: { + workingDir: string; + pageExtensions: string[]; +}): Promise { + const { appDir, pagesDir } = findPagesDir(workingDir); + const routerDir = pagesDir ?? appDir; + assert(routerDir); + + const rootDir = dirname(routerDir); + const validFileMatcher = createValidFileMatcher(pageExtensions, appDir); + const extensionSuffixes = pageExtensions.map((extension) => `.${extension}`); + const conventionFiles = (names: readonly string[]) => + new Set( + names.flatMap((name) => + pageExtensions.map((extension) => `${name}.${extension}`) + ) + ); + const appConventionFiles = conventionFiles(appConventionNames); + const appRootConventionFiles = conventionFiles(appRootConventionNames); + const rootConventionFiles = conventionFiles(rootConventionNames); + + const relativeTo = (directory: string, file: string) => { + const entry = relative(directory, file).replaceAll('\\', '/'); + if (entry === '..' || entry.startsWith('../') || isAbsolute(entry)) { + return undefined; + } + return entry; + }; + + const isAppEntrypoint = (file: string, filename: string) => { + assert(appDir); + const appEntry = relativeTo(appDir, file); + if (appEntry === undefined) return false; + if (!appEntry.includes('/') && appRootConventionFiles.has(filename)) { return true; } - const appRootDepth = inSrc ? 3 : 2; - return path.length === appRootDepth && appRootEntrypoints.has(name); - } + return ( + validFileMatcher.isAppRouterPage(file) || + validFileMatcher.isAppLayoutPage(file) || + validFileMatcher.isAppDefaultPage(file) || + appConventionFiles.has(filename) + ); + }; + + const isEntrypoint = (file: string) => { + if (file.endsWith('.d.ts')) return false; + + const filename = basename(file); + if (dirname(file) === rootDir) { + return ( + mdxComponentsFile.test(filename) || rootConventionFiles.has(filename) + ); + } - if (path.length === rootDepth) { - return rootEntrypoints.has(name); - } + const hasPageExtension = extensionSuffixes.some((extension) => + file.endsWith(extension) + ); + if (!hasPageExtension) return false; + + if (pagesDir && relativeTo(pagesDir, file) !== undefined) { + return validFileMatcher.isPageFile(file); + } - return false; + return appDir ? isAppEntrypoint(file, filename) : false; + }; + + const [appFiles, pagesFiles, rootEntries] = await Promise.all([ + appDir + ? recursiveReadDir(appDir, { + pathnameFilter: isEntrypoint, + ignorePartFilter: (part) => part.startsWith('_'), + ignoreFilter: (pathname) => + pathname === join(appDir, '.well-known/workflow'), + relativePathnames: false, + }) + : [], + pagesDir + ? recursiveReadDir(pagesDir, { + pathnameFilter: isEntrypoint, + relativePathnames: false, + }) + : [], + readdir(rootDir, { withFileTypes: true }), + ]); + const rootFiles = rootEntries + .filter((entry) => !entry.isDirectory()) + .map((entry) => join(rootDir, entry.name)) + .filter(isEntrypoint); + + return [...appFiles, ...pagesFiles, ...rootFiles].sort(); } // Create the eager Next builder dynamically by extending the ESM BaseBuilder. @@ -235,6 +284,9 @@ export async function getNextBuilderEager( '.cts', '.cjs', '.mjs', + ...this.config.pageExtensions.map((extension) => + extname(`file.${extension}`) + ), ]); const normalizedGeneratedDir = workflowGeneratedDir.replace(/\\/g, '/'); const normalizedDistDir = normalizePath(this.config.distDir); @@ -696,13 +748,10 @@ export async function getNextBuilderEager( } protected async getInputFiles(): Promise { - const inputFiles = await super.getInputFiles(); - return inputFiles.filter((file) => - isNextEntrypoint( - relative(this.config.workingDir, file).replaceAll('\\', '/'), - this.config.pageExtensions - ) - ); + return findNextEntrypoints({ + workingDir: this.config.workingDir, + pageExtensions: this.config.pageExtensions, + }); } private async writeFunctionsConfig(outputDir: string) { @@ -771,49 +820,14 @@ export async function getNextBuilderEager( } private async findAppDirectory(): Promise { - const appDir = resolve(this.config.workingDir, 'app'); - const srcAppDir = resolve(this.config.workingDir, 'src/app'); - const pagesDir = resolve(this.config.workingDir, 'pages'); - const srcPagesDir = resolve(this.config.workingDir, 'src/pages'); - - // Helper to check if a path exists and is a directory - const isDirectory = async (path: string): Promise => { - try { - await access(path, constants.F_OK); - const stats = await stat(path); - if (!stats.isDirectory()) { - throw new Error(`Path exists but is not a directory: ${path}`); - } - return true; - } catch (e) { - if (e instanceof Error && e.message.includes('not a directory')) { - throw e; - } - return false; - } - }; - - // Check if app directory exists - if (await isDirectory(appDir)) { - return appDir; - } - - // Check if src/app directory exists - if (await isDirectory(srcAppDir)) { - return srcAppDir; - } - - // If no app directory exists, check for pages directory and create app next to it - if (await isDirectory(pagesDir)) { - // Create app directory next to pages directory - await mkdir(appDir, { recursive: true }); - return appDir; - } - - if (await isDirectory(srcPagesDir)) { - // Create src/app directory next to src/pages directory - await mkdir(srcAppDir, { recursive: true }); - return srcAppDir; + const appDir = findDir(this.config.workingDir, 'app'); + if (appDir) return appDir; + + const pagesDir = findDir(this.config.workingDir, 'pages'); + if (pagesDir) { + const outputDir = join(dirname(pagesDir), 'app'); + await mkdir(outputDir, { recursive: true }); + return outputDir; } throw new Error( From 4e99365333c4be9e6dea9047062a4d42a3f91a3a Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:48:22 -0700 Subject: [PATCH 4/5] Revert "fix(next): align entry discovery with Next" This reverts commit 7e16d144e583166cd186caf20f80b344f52bca4b. --- .changeset/next-entry-discovery.md | 3 +- packages/builders/src/fast-discovery.test.ts | 33 --- packages/builders/src/fast-discovery.ts | 68 +++--- packages/next/src/builder-eager.test.ts | 142 +++-------- packages/next/src/builder-eager.ts | 238 +++++++++---------- 5 files changed, 178 insertions(+), 306 deletions(-) diff --git a/.changeset/next-entry-discovery.md b/.changeset/next-entry-discovery.md index e9dfb1dc67..7ddfe61dae 100644 --- a/.changeset/next-entry-discovery.md +++ b/.changeset/next-entry-discovery.md @@ -1,6 +1,5 @@ --- -'@workflow/builders': patch '@workflow/next': patch --- -Discover workflows imported by every Next.js file convention entrypoint, including custom page extensions. +Discover workflows imported by every Next.js file convention entrypoint. diff --git a/packages/builders/src/fast-discovery.test.ts b/packages/builders/src/fast-discovery.test.ts index 68aa447447..b58ca0c57f 100644 --- a/packages/builders/src/fast-discovery.test.ts +++ b/packages/builders/src/fast-discovery.test.ts @@ -103,39 +103,6 @@ describe('fast workflow discovery', () => { ); }); - it('follows imports from custom source extensions', async () => { - const entryFile = join(testRoot, 'app', 'page.mdx'); - const componentFile = join(testRoot, 'app', 'component.mdx'); - const workflowFile = join(testRoot, 'app', 'workflow.ts'); - - writeFile( - entryFile, - `import Component from './component';\n\n` - ); - writeFile( - componentFile, - `import { run } from './workflow';\n\n"use workflow"\n\n{run.name}\n` - ); - writeFile( - workflowFile, - `export async function run() { - 'use workflow'; - return 'ok'; -} -` - ); - - const discovered = await createBuilder(testRoot).discoverEntriesPublic( - [entryFile], - join(testRoot, 'out') - ); - - expect(discovered.discoveredWorkflows).toEqual( - new Set([normalize(workflowFile)]) - ); - expect(discovered.discoveredFiles).toContain(normalize(componentFile)); - }); - it('discovers relative JS imports whose basename includes .step', async () => { const entryFile = join(testRoot, 'src', 'entry.ts'); const workflowFile = join(testRoot, 'src', 'hello.step.js'); diff --git a/packages/builders/src/fast-discovery.ts b/packages/builders/src/fast-discovery.ts index c41a74492d..03a90ee276 100644 --- a/packages/builders/src/fast-discovery.ts +++ b/packages/builders/src/fast-discovery.ts @@ -21,6 +21,13 @@ const FAST_DISCOVERY_SOURCE_EXTENSIONS = [ const FAST_DISCOVERY_SOURCE_EXTENSION_SET = new Set( FAST_DISCOVERY_SOURCE_EXTENSIONS ); +const fastDiscoveryResolve = promisify( + enhancedResolveOriginal.create({ + extensions: [...FAST_DISCOVERY_SOURCE_EXTENSIONS, '.json', '.node'], + fullySpecified: false, + conditionNames: ['node', 'import', 'require'], + }) +); const require = createRequire(import.meta.url); const FAST_DISCOVERY_READ_CONCURRENCY = 32; @@ -43,7 +50,7 @@ export interface DiscoveredEntries { discoveredWorkflows: Set; discoveredSerdeFiles: Set; /** - * All source files visited while walking the workflow import graph. + * All JS/TS files visited while walking the workflow import graph. * Watch-mode integrations use this to distinguish relevant HMR changes from * unrelated application file edits. */ @@ -642,18 +649,6 @@ export async function fastDiscoverEntries({ workingDir, discoverWorkflowsInNodeModules = true, }: FastDiscoverEntriesOptions): Promise { - const sourceExtensionSet = new Set(FAST_DISCOVERY_SOURCE_EXTENSIONS); - for (const entryPoint of entryPoints) { - sourceExtensionSet.add(extname(entryPoint)); - } - const sourceExtensions = [...sourceExtensionSet]; - const fastDiscoveryResolve = promisify( - enhancedResolveOriginal.create({ - extensions: [...sourceExtensions, '.json', '.node'], - fullySpecified: false, - conditionNames: ['node', 'import', 'require'], - }) - ); const readLimit = createLimiter(FAST_DISCOVERY_READ_CONCURRENCY); const resolveLimit = createLimiter(FAST_DISCOVERY_RESOLVE_CONCURRENCY); const resolveCache = new Map>(); @@ -674,7 +669,7 @@ export async function fastDiscoverEntries({ if ( queuedFiles.has(normalizedPath) || processedFiles.has(normalizedPath) || - !sourceExtensionSet.has(extname(normalizedPath)) || + !isJsTsFile(normalizedPath) || isGeneratedBuildArtifactPath(normalizedPath) ) { return; @@ -757,15 +752,15 @@ export async function fastDiscoverEntries({ ? strippedSpecifier : resolve(dirname(importer), strippedSpecifier); const extension = extname(basePath); - if (sourceExtensionSet.has(extension)) { + if (FAST_DISCOVERY_SOURCE_EXTENSION_SET.has(extension)) { return (await fileExists(basePath)) ? normalizePath(basePath) : null; } for (const candidate of [ - ...sourceExtensions.map( + ...FAST_DISCOVERY_SOURCE_EXTENSIONS.map( (candidateExtension) => `${basePath}${candidateExtension}` ), - ...sourceExtensions.map((candidateExtension) => + ...FAST_DISCOVERY_SOURCE_EXTENSIONS.map((candidateExtension) => join(basePath, `index${candidateExtension}`) ), ]) { @@ -933,22 +928,6 @@ export async function fastDiscoverEntries({ return packageInfo?.hasWorkflowDependency === true; }; - const recordWorkflowPatterns = (filePath: string, source: string) => { - if (!isJsTsFile(filePath)) return false; - - const patterns = detectWorkflowPatterns(source); - if (patterns.hasUseWorkflow) { - state.discoveredWorkflows.add(filePath); - } - if (patterns.hasUseStep) { - state.discoveredSteps.add(filePath); - } - if (patterns.hasSerde && hasLikelySerdeClass(source)) { - state.discoveredSerdeFiles.add(filePath); - } - return patterns.hasDirective || patterns.hasSerde; - }; - const processImportSpecifier = async ( filePath: string, specifier: string, @@ -981,10 +960,12 @@ export async function fastDiscoverEntries({ } addImportParent(filePath, resolved); - if ( - !sourceExtensionSet.has(extname(resolved)) || - isGeneratedBuildArtifactPath(resolved) - ) { + if (!isJsTsFile(resolved) || isGeneratedBuildArtifactPath(resolved)) { + return; + } + + if (specifier.startsWith('.')) { + enqueueFile(resolved); return; } @@ -1002,7 +983,18 @@ export async function fastDiscoverEntries({ return; } - const forceFollowImports = recordWorkflowPatterns(filePath, source); + const patterns = detectWorkflowPatterns(source); + if (patterns.hasUseWorkflow) { + state.discoveredWorkflows.add(filePath); + } + if (patterns.hasUseStep) { + state.discoveredSteps.add(filePath); + } + if (patterns.hasSerde && hasLikelySerdeClass(source)) { + state.discoveredSerdeFiles.add(filePath); + } + + const forceFollowImports = patterns.hasDirective || patterns.hasSerde; const shouldFollow = await shouldFollowImportsFromFile( filePath, forceFollowImports diff --git a/packages/next/src/builder-eager.test.ts b/packages/next/src/builder-eager.test.ts index 4b406afd74..357d7c870f 100644 --- a/packages/next/src/builder-eager.test.ts +++ b/packages/next/src/builder-eager.test.ts @@ -1,117 +1,45 @@ -import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { dirname, join, relative } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { findNextEntrypoints } from './builder-eager.js'; +import { describe, expect, it } from 'vitest'; +import { isNextEntrypoint } from './builder-eager.js'; const pageExtensions = ['js', 'jsx', 'ts', 'tsx']; -describe('findNextEntrypoints', () => { - let projectDir: string; - - beforeEach(async () => { - projectDir = await mkdtemp( - join(await realpath(tmpdir()), 'workflow-next-') - ); - }); - - afterEach(async () => { - await rm(projectDir, { recursive: true, force: true }); - }); - - const createFile = async (path: string) => { - await mkdir(dirname(path), { recursive: true }); - await writeFile(path, ''); - }; - - const discover = async (extensions = pageExtensions) => { - const files = await findNextEntrypoints({ - workingDir: projectDir, - pageExtensions: extensions, - }); - return files.map((file) => - relative(projectDir, file).replaceAll('\\', '/') - ); - }; - - it('uses root app and pages directories when both root and src exist', async () => { - const included = [ - 'app/page.tsx', - 'app/dashboard/error.tsx', - 'app/@modal/default.tsx', - 'app/blog/opengraph-image.tsx', - 'app/blog/opengraph-image1.tsx', - 'app/global-error.tsx', - 'app/robots.ts', - 'pages/index.tsx', - 'pages/api/run.ts', - 'instrumentation.ts', - 'proxy.ts', - 'mdx-components.tsx', - ]; - const excluded = [ - 'app/component.tsx', - 'app/error.test.tsx', - 'app/_components/error.tsx', - 'app/blog/global-error.tsx', - 'app/blog/robots.ts', - 'app/blog/opengraph-image12.tsx', - 'app/icon.png', - 'app/.well-known/workflow/v1/flow/route.ts', - 'pages/types.d.ts', - 'src/app/page.tsx', - 'src/pages/index.tsx', - 'src/instrumentation-client.ts', - 'src/mdx-components.js', - ]; - await Promise.all( - [...included, ...excluded].map((file) => - createFile(join(projectDir, file)) - ) - ); - - expect(await discover()).toEqual(included.sort()); +describe('isNextEntrypoint', () => { + it.each([ + 'pages/index.tsx', + 'src/pages/api/run.ts', + 'app/page.tsx', + 'app/dashboard/error.tsx', + 'app/@modal/default.tsx', + 'app/blog/opengraph-image12.tsx', + 'app/global-error.tsx', + 'src/app/robots.ts', + 'instrumentation.ts', + 'src/instrumentation-client.ts', + 'proxy.ts', + 'mdx-components.tsx', + 'src/mdx-components.js', + ])('includes %s', (entry) => { + expect(isNextEntrypoint(entry, pageExtensions)).toBe(true); }); - it('uses src conventions when the routers live in src', async () => { - const included = [ - 'src/app/page.tsx', - 'src/pages/index.tsx', - 'src/instrumentation-client.ts', - 'src/mdx-components.js', - ]; - const excluded = ['instrumentation.ts', 'mdx-components.tsx']; - await Promise.all( - [...included, ...excluded].map((file) => - createFile(join(projectDir, file)) - ) - ); - - expect(await discover()).toEqual(included.sort()); + it.each([ + 'app/component.tsx', + 'app/error.test.tsx', + 'app/_components/error.tsx', + 'src/app/_draft/loading.tsx', + 'app/blog/global-error.tsx', + 'app/blog/robots.ts', + 'pages/types.d.ts', + 'app/page.d.ts', + 'src/random.ts', + 'app/page.vue', + ])('excludes %s', (entry) => { + expect(isNextEntrypoint(entry, pageExtensions)).toBe(false); }); - it('discovers configured and compound page extensions', async () => { - const included = [ - 'app/page.mdx', - 'app/page.page.tsx', - 'app/error.page.tsx', - 'app/opengraph-image1.mdx', - 'pages/docs.mdx', - 'pages/docs.page.tsx', - 'proxy.mdx', - 'mdx-components.tsx', - ]; - const excluded = [ - 'app/page.tsx', - 'app/opengraph-image12.mdx', - 'pages/docs.tsx', - ]; - await Promise.all( - [...included, ...excluded].map((file) => - createFile(join(projectDir, file)) - ) + it('supports compound page extensions', () => { + expect(isNextEntrypoint('app/error.page.tsx', ['tsx', 'page.tsx'])).toBe( + true ); - - expect(await discover(['mdx', 'page.tsx'])).toEqual(included.sort()); }); }); diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index 3876a19a96..8f5024c369 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -1,24 +1,12 @@ -import assert from 'node:assert'; -import type { Dirent } from 'node:fs'; -import { mkdir, readdir, realpath, rm, stat } from 'node:fs/promises'; -import { - basename, - dirname, - extname, - isAbsolute, - join, - relative, - resolve, -} from 'node:path'; +import { constants, type Dirent } from 'node:fs'; +import { access, mkdir, readdir, realpath, rm, stat } from 'node:fs/promises'; +import { extname, isAbsolute, join, relative, resolve } from 'node:path'; import type { NextConfig as BuilderNextConfig, WorkflowManifest, } from '@workflow/builders'; import chokidar from 'chokidar'; import type { NextConfig as ProjectNextConfig } from 'next'; -import { findDir, findPagesDir } from 'next/dist/lib/find-pages-dir'; -import { recursiveReadDir } from 'next/dist/lib/recursive-readdir'; -import { createValidFileMatcher } from 'next/dist/server/lib/find-page-file'; import { createWatchIgnorePredicate } from './watch-ignore.js'; import { classifyRebuild, @@ -35,116 +23,79 @@ const importEsm = new Function('specifier', 'return import(specifier)') as ( specifier: string ) => Promise; -const appConventionNames = [ +const appEntrypoints = new Set([ + 'page', + 'route', + 'layout', + 'default', 'error', 'loading', 'template', 'not-found', 'forbidden', 'unauthorized', -] as const; -const appRootConventionNames = ['global-error', 'global-not-found'] as const; -const rootConventionNames = [ + 'sitemap', + 'icon', + 'apple-icon', + 'opengraph-image', + 'twitter-image', +]); +const appRootEntrypoints = new Set([ + 'global-error', + 'global-not-found', + 'robots', + 'manifest', +]); +const rootEntrypoints = new Set([ 'instrumentation', 'instrumentation-client', 'middleware', 'proxy', -] as const; -const mdxComponentsFile = /^mdx-components\.[jt]sx?$/; - -export async function findNextEntrypoints({ - workingDir, - pageExtensions, -}: { - workingDir: string; - pageExtensions: string[]; -}): Promise { - const { appDir, pagesDir } = findPagesDir(workingDir); - const routerDir = pagesDir ?? appDir; - assert(routerDir); - - const rootDir = dirname(routerDir); - const validFileMatcher = createValidFileMatcher(pageExtensions, appDir); - const extensionSuffixes = pageExtensions.map((extension) => `.${extension}`); - const conventionFiles = (names: readonly string[]) => - new Set( - names.flatMap((name) => - pageExtensions.map((extension) => `${name}.${extension}`) - ) - ); - const appConventionFiles = conventionFiles(appConventionNames); - const appRootConventionFiles = conventionFiles(appRootConventionNames); - const rootConventionFiles = conventionFiles(rootConventionNames); - - const relativeTo = (directory: string, file: string) => { - const entry = relative(directory, file).replaceAll('\\', '/'); - if (entry === '..' || entry.startsWith('../') || isAbsolute(entry)) { - return undefined; - } - return entry; - }; - - const isAppEntrypoint = (file: string, filename: string) => { - assert(appDir); - const appEntry = relativeTo(appDir, file); - if (appEntry === undefined) return false; - if (!appEntry.includes('/') && appRootConventionFiles.has(filename)) { - return true; - } - return ( - validFileMatcher.isAppRouterPage(file) || - validFileMatcher.isAppLayoutPage(file) || - validFileMatcher.isAppDefaultPage(file) || - appConventionFiles.has(filename) - ); - }; - - const isEntrypoint = (file: string) => { - if (file.endsWith('.d.ts')) return false; - - const filename = basename(file); - if (dirname(file) === rootDir) { - return ( - mdxComponentsFile.test(filename) || rootConventionFiles.has(filename) - ); - } +]); +const numberedMetadataEntrypoint = + /^(?:icon|apple-icon|opengraph-image|twitter-image)\d+$/; +const mdxComponentsEntrypoint = /^mdx-components\.[jt]sx?$/; + +export function isNextEntrypoint( + entry: string, + pageExtensions: readonly string[] +): boolean { + if (entry.endsWith('.d.ts')) return false; + + const path = entry.split('/'); + const filename = path.at(-1)!; + const inSrc = path[0] === 'src'; + const rootDepth = inSrc ? 2 : 1; + if (path.length === rootDepth && mdxComponentsEntrypoint.test(filename)) { + return true; + } - const hasPageExtension = extensionSuffixes.some((extension) => - file.endsWith(extension) - ); - if (!hasPageExtension) return false; + const extension = [...pageExtensions] + .sort((a, b) => b.length - a.length) + .find((extension) => entry.endsWith(`.${extension}`)); + if (!extension) return false; - if (pagesDir && relativeTo(pagesDir, file) !== undefined) { - return validFileMatcher.isPageFile(file); + const name = filename.slice(0, -extension.length - 1); + const directory = path[inSrc ? 1 : 0]; + + if (directory === 'pages') return true; + + if (directory === 'app') { + const segments = path.slice(inSrc ? 2 : 1, -1); + if (segments.some((segment) => segment.startsWith('_'))) return false; + + if (appEntrypoints.has(name) || numberedMetadataEntrypoint.test(name)) { + return true; } + const appRootDepth = inSrc ? 3 : 2; + return path.length === appRootDepth && appRootEntrypoints.has(name); + } + + if (path.length === rootDepth) { + return rootEntrypoints.has(name); + } - return appDir ? isAppEntrypoint(file, filename) : false; - }; - - const [appFiles, pagesFiles, rootEntries] = await Promise.all([ - appDir - ? recursiveReadDir(appDir, { - pathnameFilter: isEntrypoint, - ignorePartFilter: (part) => part.startsWith('_'), - ignoreFilter: (pathname) => - pathname === join(appDir, '.well-known/workflow'), - relativePathnames: false, - }) - : [], - pagesDir - ? recursiveReadDir(pagesDir, { - pathnameFilter: isEntrypoint, - relativePathnames: false, - }) - : [], - readdir(rootDir, { withFileTypes: true }), - ]); - const rootFiles = rootEntries - .filter((entry) => !entry.isDirectory()) - .map((entry) => join(rootDir, entry.name)) - .filter(isEntrypoint); - - return [...appFiles, ...pagesFiles, ...rootFiles].sort(); + return false; } // Create the eager Next builder dynamically by extending the ESM BaseBuilder. @@ -284,9 +235,6 @@ export async function getNextBuilderEager( '.cts', '.cjs', '.mjs', - ...this.config.pageExtensions.map((extension) => - extname(`file.${extension}`) - ), ]); const normalizedGeneratedDir = workflowGeneratedDir.replace(/\\/g, '/'); const normalizedDistDir = normalizePath(this.config.distDir); @@ -748,10 +696,13 @@ export async function getNextBuilderEager( } protected async getInputFiles(): Promise { - return findNextEntrypoints({ - workingDir: this.config.workingDir, - pageExtensions: this.config.pageExtensions, - }); + const inputFiles = await super.getInputFiles(); + return inputFiles.filter((file) => + isNextEntrypoint( + relative(this.config.workingDir, file).replaceAll('\\', '/'), + this.config.pageExtensions + ) + ); } private async writeFunctionsConfig(outputDir: string) { @@ -820,14 +771,49 @@ export async function getNextBuilderEager( } private async findAppDirectory(): Promise { - const appDir = findDir(this.config.workingDir, 'app'); - if (appDir) return appDir; - - const pagesDir = findDir(this.config.workingDir, 'pages'); - if (pagesDir) { - const outputDir = join(dirname(pagesDir), 'app'); - await mkdir(outputDir, { recursive: true }); - return outputDir; + const appDir = resolve(this.config.workingDir, 'app'); + const srcAppDir = resolve(this.config.workingDir, 'src/app'); + const pagesDir = resolve(this.config.workingDir, 'pages'); + const srcPagesDir = resolve(this.config.workingDir, 'src/pages'); + + // Helper to check if a path exists and is a directory + const isDirectory = async (path: string): Promise => { + try { + await access(path, constants.F_OK); + const stats = await stat(path); + if (!stats.isDirectory()) { + throw new Error(`Path exists but is not a directory: ${path}`); + } + return true; + } catch (e) { + if (e instanceof Error && e.message.includes('not a directory')) { + throw e; + } + return false; + } + }; + + // Check if app directory exists + if (await isDirectory(appDir)) { + return appDir; + } + + // Check if src/app directory exists + if (await isDirectory(srcAppDir)) { + return srcAppDir; + } + + // If no app directory exists, check for pages directory and create app next to it + if (await isDirectory(pagesDir)) { + // Create app directory next to pages directory + await mkdir(appDir, { recursive: true }); + return appDir; + } + + if (await isDirectory(srcPagesDir)) { + // Create src/app directory next to src/pages directory + await mkdir(srcAppDir, { recursive: true }); + return srcAppDir; } throw new Error( From b8f4d095f99baadf7868d666b7bdafd5dd229e58 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:04:23 -0700 Subject: [PATCH 5/5] fix(next): tighten entrypoint discovery --- packages/next/src/builder-eager.test.ts | 31 ++++-- packages/next/src/builder-eager.ts | 141 +++++++++++------------- 2 files changed, 84 insertions(+), 88 deletions(-) diff --git a/packages/next/src/builder-eager.test.ts b/packages/next/src/builder-eager.test.ts index 357d7c870f..36b8f52f21 100644 --- a/packages/next/src/builder-eager.test.ts +++ b/packages/next/src/builder-eager.test.ts @@ -1,45 +1,52 @@ import { describe, expect, it } from 'vitest'; -import { isNextEntrypoint } from './builder-eager.js'; +import { createNextEntrypointMatcher } from './builder-eager.js'; -const pageExtensions = ['js', 'jsx', 'ts', 'tsx']; +const pageExtensions = ['js', 'jsx', 'ts', 'tsx', 'mts', 'cts']; +const isNextEntrypoint = createNextEntrypointMatcher(pageExtensions); describe('isNextEntrypoint', () => { it.each([ 'pages/index.tsx', + 'pages/api/run.ts', 'src/pages/api/run.ts', 'app/page.tsx', 'app/dashboard/error.tsx', 'app/@modal/default.tsx', - 'app/blog/opengraph-image12.tsx', + 'app/blog/opengraph-image1.tsx', 'app/global-error.tsx', + 'app/robots.ts', 'src/app/robots.ts', 'instrumentation.ts', - 'src/instrumentation-client.ts', + 'instrumentation-client.ts', + 'instrumentation-client.mjs', 'proxy.ts', 'mdx-components.tsx', - 'src/mdx-components.js', + 'mdx-components.mjs', + 'src/instrumentation-client.ts', + 'src/mdx-components.tsx', ])('includes %s', (entry) => { - expect(isNextEntrypoint(entry, pageExtensions)).toBe(true); + expect(isNextEntrypoint(entry)).toBe(true); }); it.each([ 'app/component.tsx', 'app/error.test.tsx', 'app/_components/error.tsx', - 'src/app/_draft/loading.tsx', 'app/blog/global-error.tsx', 'app/blog/robots.ts', + 'app/blog/opengraph-image12.tsx', 'pages/types.d.ts', + 'pages/types.d.mts', + 'pages/types.d.cts', 'app/page.d.ts', - 'src/random.ts', 'app/page.vue', ])('excludes %s', (entry) => { - expect(isNextEntrypoint(entry, pageExtensions)).toBe(false); + expect(isNextEntrypoint(entry)).toBe(false); }); it('supports compound page extensions', () => { - expect(isNextEntrypoint('app/error.page.tsx', ['tsx', 'page.tsx'])).toBe( - true - ); + expect( + createNextEntrypointMatcher(['tsx', 'page.tsx'])('app/error.page.tsx') + ).toBe(true); }); }); diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index 8f5024c369..ca4d006583 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -23,79 +23,46 @@ const importEsm = new Function('specifier', 'return import(specifier)') as ( specifier: string ) => Promise; -const appEntrypoints = new Set([ - 'page', - 'route', - 'layout', - 'default', - 'error', - 'loading', - 'template', - 'not-found', - 'forbidden', - 'unauthorized', - 'sitemap', - 'icon', - 'apple-icon', - 'opengraph-image', - 'twitter-image', -]); -const appRootEntrypoints = new Set([ - 'global-error', - 'global-not-found', - 'robots', - 'manifest', -]); -const rootEntrypoints = new Set([ - 'instrumentation', - 'instrumentation-client', - 'middleware', - 'proxy', -]); -const numberedMetadataEntrypoint = - /^(?:icon|apple-icon|opengraph-image|twitter-image)\d+$/; -const mdxComponentsEntrypoint = /^mdx-components\.[jt]sx?$/; - -export function isNextEntrypoint( - entry: string, - pageExtensions: readonly string[] -): boolean { - if (entry.endsWith('.d.ts')) return false; - - const path = entry.split('/'); - const filename = path.at(-1)!; - const inSrc = path[0] === 'src'; - const rootDepth = inSrc ? 2 : 1; - if (path.length === rootDepth && mdxComponentsEntrypoint.test(filename)) { - return true; - } - - const extension = [...pageExtensions] - .sort((a, b) => b.length - a.length) - .find((extension) => entry.endsWith(`.${extension}`)); - if (!extension) return false; - - const name = filename.slice(0, -extension.length - 1); - const directory = path[inSrc ? 1 : 0]; - - if (directory === 'pages') return true; - - if (directory === 'app') { - const segments = path.slice(inSrc ? 2 : 1, -1); - if (segments.some((segment) => segment.startsWith('_'))) return false; - - if (appEntrypoints.has(name) || numberedMetadataEntrypoint.test(name)) { - return true; +const appEntrypoint = + /^(?:page|route|layout|default|error|loading|template|not-found|forbidden|unauthorized|sitemap|(?:icon|apple-icon|opengraph-image|twitter-image)\d?)$/; +const appRootEntrypoint = /^(?:global-error|global-not-found|robots|manifest)$/; +const rootEntrypoint = /^(?:instrumentation|middleware|proxy)$/; +const rootModuleEntrypoint = + /^(?:instrumentation-client|mdx-components)\.(?:mjs|[jt]sx?)$/; +const rootModuleNames = ['instrumentation-client', 'mdx-components']; +const rootModuleExtensions = ['js', 'mjs', 'tsx', 'ts', 'jsx']; + +export function createNextEntrypointMatcher(pageExtensions: readonly string[]) { + const extensions = [...pageExtensions].sort((a, b) => b.length - a.length); + + return (entry: string): boolean => { + if (/\.d\.(?:cts|mts|ts)$/.test(entry)) return false; + + const sourceEntry = entry.replace(/^src\//, ''); + const path = sourceEntry.split('/'); + const filename = path[path.length - 1]; + if (rootModuleEntrypoint.test(sourceEntry)) return true; + + const extension = extensions.find((extension) => + sourceEntry.endsWith(`.${extension}`) + ); + if (!extension) return false; + + const name = filename.slice(0, -extension.length - 1); + if (path[0] === 'pages') return true; + + if (path[0] === 'app') { + const segments = path.slice(1, -1); + if (segments.some((segment) => segment.startsWith('_'))) return false; + + return ( + appEntrypoint.test(name) || + (path.length === 2 && appRootEntrypoint.test(name)) + ); } - const appRootDepth = inSrc ? 3 : 2; - return path.length === appRootDepth && appRootEntrypoints.has(name); - } - if (path.length === rootDepth) { - return rootEntrypoints.has(name); - } - - return false; + return rootEntrypoint.test(sourceEntry.slice(0, -extension.length - 1)); + }; } // Create the eager Next builder dynamically by extending the ESM BaseBuilder. @@ -697,12 +664,34 @@ export async function getNextBuilderEager( protected async getInputFiles(): Promise { const inputFiles = await super.getInputFiles(); - return inputFiles.filter((file) => - isNextEntrypoint( - relative(this.config.workingDir, file).replaceAll('\\', '/'), - this.config.pageExtensions - ) + const isNextEntrypoint = createNextEntrypointMatcher( + this.config.pageExtensions ); + const inputFileSet = new Set(inputFiles); + const rootModuleFiles = new Set( + rootModuleNames.flatMap((name) => { + const file = ['src', ''] + .flatMap((directory) => + rootModuleExtensions.map((extension) => + join(this.config.workingDir, directory, `${name}.${extension}`) + ) + ) + .find((candidate) => inputFileSet.has(candidate)); + return file ? [file] : []; + }) + ); + + return inputFiles.filter((file) => { + const entry = relative(this.config.workingDir, file).replaceAll( + '\\', + '/' + ); + const rootModule = entry.startsWith('src/') ? entry.slice(4) : entry; + if (rootModuleEntrypoint.test(rootModule)) { + return rootModuleFiles.has(file); + } + return isNextEntrypoint(entry); + }); } private async writeFunctionsConfig(outputDir: string) {