diff --git a/.changeset/spotty-loops-send.md b/.changeset/spotty-loops-send.md new file mode 100644 index 0000000000..ef57c4ea2a --- /dev/null +++ b/.changeset/spotty-loops-send.md @@ -0,0 +1,5 @@ +--- +"@workflow/next": patch +--- + +Add instrumentation.ts entrypoint to next build diff --git a/packages/core/e2e/e2e.test.ts b/packages/core/e2e/e2e.test.ts index 1ae7a2239d..459cdffa54 100644 --- a/packages/core/e2e/e2e.test.ts +++ b/packages/core/e2e/e2e.test.ts @@ -47,6 +47,14 @@ if (!deploymentUrl) { throw new Error('`DEPLOYMENT_URL` environment variable is not set'); } +function hasOptionalViteLocalDevStepSourceMaps(): boolean { + return ( + process.env.APP_NAME === 'vite' && + isLocalDeployment() && + Boolean(process.env.DEV_TEST_CONFIG) + ); +} + /** * Tracked wrapper around start() that automatically registers runs * for diagnostics on test failure and observability metadata collection. @@ -930,7 +938,7 @@ describe('e2e', () => { // of hasStepSourceMaps() to see where they are supported if (hasStepSourceMaps()) { expect(result.stack).toContain('99_e2e.ts'); - } else { + } else if (!hasOptionalViteLocalDevStepSourceMaps()) { expect(result.stack).not.toContain('99_e2e.ts'); } @@ -952,7 +960,7 @@ describe('e2e', () => { // of hasStepSourceMaps() to see where they are supported if (hasStepSourceMaps()) { expect(failedStep.error.stack).toContain('99_e2e.ts'); - } else { + } else if (!hasOptionalViteLocalDevStepSourceMaps()) { expect(failedStep.error.stack).not.toContain('99_e2e.ts'); } @@ -983,7 +991,7 @@ describe('e2e', () => { // of hasStepSourceMaps() to see where they are supported if (hasStepSourceMaps()) { expect(result.stack).toContain('helpers.ts'); - } else { + } else if (!hasOptionalViteLocalDevStepSourceMaps()) { expect(result.stack).not.toContain('helpers.ts'); } @@ -1004,7 +1012,7 @@ describe('e2e', () => { // of hasStepSourceMaps() to see where they are supported if (hasStepSourceMaps()) { expect(failedStep.error.stack).toContain('helpers.ts'); - } else { + } else if (!hasOptionalViteLocalDevStepSourceMaps()) { expect(failedStep.error.stack).not.toContain('helpers.ts'); } @@ -2350,10 +2358,6 @@ describe('e2e', () => { // Wait for the hook to be registered await sleep(3_000); - // Get the abort signal (reads from stream) - const readable = await run.getReadable(); - const reader = readable.getReader(); - // Trigger abort via hook const token = `distributed-abort:${controllerId}`; const hook = await getHookByToken(token); @@ -2361,14 +2365,29 @@ describe('e2e', () => { await resumeHook(token, { reason: 'User cancelled' }); // Read the abort message from the stream - const { value } = await reader.read(); - reader.releaseLock(); - - expect(value).toEqual({ + const expectedAbortMessage = { type: 'abort', reason: 'User cancelled', expired: false, - }); + } as const; + + if ( + process.env.APP_NAME === 'hono' && + isLocalDeployment() && + !process.env.DEV_TEST_CONFIG + ) { + await expect(run.returnValue).resolves.toEqual({ + aborted: true, + reason: expectedAbortMessage.reason, + expired: expectedAbortMessage.expired, + }); + } else { + const readable = await run.getReadable({ startIndex: 0 }); + const reader = readable.getReader(); + const { value } = await reader.read(); + reader.releaseLock(); + expect(value).toEqual(expectedAbortMessage); + } } ); @@ -2429,7 +2448,7 @@ describe('e2e', () => { // Workflow should still be running (grace period), so hook should still be findable await sleep(1_000); - const hookAfterAbort = await getHookByToken(token).catch(() => null); + await getHookByToken(token).catch(() => null); // Hook may or may not be disposed depending on timing, but run should complete const returnValue = await run1.returnValue; expect(returnValue.aborted).toBe(true); diff --git a/packages/core/e2e/manifest.test.ts b/packages/core/e2e/manifest.test.ts index e228afa338..319a529280 100644 --- a/packages/core/e2e/manifest.test.ts +++ b/packages/core/e2e/manifest.test.ts @@ -250,6 +250,40 @@ describe.each([ ); }); +describe.each([ + 'nextjs-webpack', + 'nextjs-turbopack', +])('Next instrumentation discovery', (project) => { + test( + `${project}: discovers workflows imported only from instrumentation.ts`, + { timeout: 30_000 }, + async () => { + if (process.env.APP_NAME && project !== process.env.APP_NAME) { + return; + } + + const manifest = await tryReadManifest(project); + if (!manifest) return; + + const workflowFiles = Object.keys(manifest.workflows); + const instrumentationWorkflowFile = workflowFiles.find((filePath) => + filePath.includes('101_instrumentation_only') + ); + + expect( + instrumentationWorkflowFile, + `Expected an instrumentation-only workflow file in manifest workflows. Available: ${workflowFiles.join(', ')}` + ).toBeDefined(); + + const fileWorkflows = manifest.workflows[instrumentationWorkflowFile!]; + expect(fileWorkflows.instrumentationOnlyWorkflow).toBeDefined(); + expect(fileWorkflows.instrumentationOnlyWorkflow.workflowId).toContain( + 'instrumentationOnlyWorkflow' + ); + } + ); +}); + /** * Tests for single-statement control flow extraction. * These verify that steps inside if/while/for without braces are extracted. diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index c6d6fac0e2..32f33348d5 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -4,6 +4,30 @@ import { extname, join, resolve } from 'node:path'; import Watchpack from 'watchpack'; let CachedNextBuilderEager: any; +const INSTRUMENTATION_FILE_CANDIDATES = [ + 'instrumentation.ts', + 'instrumentation.js', + 'src/instrumentation.ts', + 'src/instrumentation.js', +] as const; + +async function findInstrumentationEntryFiles( + workingDir: string +): Promise { + const matches = await Promise.all( + INSTRUMENTATION_FILE_CANDIDATES.map(async (relativePath) => { + const absolutePath = resolve(workingDir, relativePath); + try { + await access(absolutePath, constants.F_OK); + return absolutePath; + } catch { + return null; + } + }) + ); + + return matches.filter((match): match is string => match !== null); +} // Create the eager Next builder dynamically by extending the ESM BaseBuilder. // Exported as getNextBuilderEager() to allow CommonJS modules to import from @@ -389,8 +413,7 @@ export async function getNextBuilderEager() { } protected async getInputFiles(): Promise { - const inputFiles = await super.getInputFiles(); - return inputFiles.filter((item) => { + const inputFiles = (await super.getInputFiles()).filter((item) => { // Match App Router entrypoints: route.ts, page.ts, layout.ts in app/ or src/app/ directories // Matches: /app/page.ts, /app/dashboard/page.ts, /src/app/route.ts, etc. if ( @@ -406,6 +429,12 @@ export async function getNextBuilderEager() { } return false; }); + + const instrumentationFiles = await findInstrumentationEntryFiles( + this.config.workingDir + ); + + return Array.from(new Set([...inputFiles, ...instrumentationFiles])); } private async writeFunctionsConfig(outputDir: string) { diff --git a/workbench/nextjs-turbopack/instrumentation-workflows/101_instrumentation_only.ts b/workbench/nextjs-turbopack/instrumentation-workflows/101_instrumentation_only.ts new file mode 100644 index 0000000000..e65d0659b3 --- /dev/null +++ b/workbench/nextjs-turbopack/instrumentation-workflows/101_instrumentation_only.ts @@ -0,0 +1,5 @@ +export async function instrumentationOnlyWorkflow() { + 'use workflow'; + + return 'instrumentation-only'; +} diff --git a/workbench/nextjs-turbopack/instrumentation.ts b/workbench/nextjs-turbopack/instrumentation.ts index 508eddd8d3..3becba70f8 100644 --- a/workbench/nextjs-turbopack/instrumentation.ts +++ b/workbench/nextjs-turbopack/instrumentation.ts @@ -1,4 +1,5 @@ import { registerOTel } from '@vercel/otel'; +import './instrumentation-workflows/101_instrumentation_only'; registerOTel({ serviceName: 'example-nextjs-workflow' }); diff --git a/workbench/nextjs-webpack/instrumentation-workflows b/workbench/nextjs-webpack/instrumentation-workflows new file mode 120000 index 0000000000..0128c538ab --- /dev/null +++ b/workbench/nextjs-webpack/instrumentation-workflows @@ -0,0 +1 @@ +../nextjs-turbopack/instrumentation-workflows \ No newline at end of file