Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/spotty-loops-send.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/next": patch
---

Add instrumentation.ts entrypoint to next build
47 changes: 33 additions & 14 deletions packages/core/e2e/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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');
}

Expand All @@ -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');
}

Expand Down Expand Up @@ -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');
}

Expand All @@ -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');
}

Expand Down Expand Up @@ -2350,25 +2358,36 @@ 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);
expect(hook.runId).toBe(run.runId);
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);
}
}
);

Expand Down Expand Up @@ -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);
Expand Down
34 changes: 34 additions & 0 deletions packages/core/e2e/manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
33 changes: 31 additions & 2 deletions packages/next/src/builder-eager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]> {
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
Expand Down Expand Up @@ -389,8 +413,7 @@ export async function getNextBuilderEager() {
}

protected async getInputFiles(): Promise<string[]> {
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 (
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export async function instrumentationOnlyWorkflow() {
'use workflow';

return 'instrumentation-only';
}
1 change: 1 addition & 0 deletions workbench/nextjs-turbopack/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { registerOTel } from '@vercel/otel';
import './instrumentation-workflows/101_instrumentation_only';

registerOTel({ serviceName: 'example-nextjs-workflow' });

Expand Down
1 change: 1 addition & 0 deletions workbench/nextjs-webpack/instrumentation-workflows
Loading