diff --git a/.changeset/thick-beers-share.md b/.changeset/thick-beers-share.md new file mode 100644 index 0000000000..35b0b9b5c3 --- /dev/null +++ b/.changeset/thick-beers-share.md @@ -0,0 +1,8 @@ +--- +"@workflow/builders": patch +"@workflow/core": patch +"@workflow/next": patch +"@workflow/cli": patch +--- + +Fix deferred build mode for Next.js diff --git a/.github/actions/prepare-workbench-path/action.yml b/.github/actions/prepare-workbench-path/action.yml new file mode 100644 index 0000000000..61aa6392ff --- /dev/null +++ b/.github/actions/prepare-workbench-path/action.yml @@ -0,0 +1,33 @@ +name: 'Prepare Workbench Path' +description: 'Resolve a workbench path, staging tarball-based Next.js workbenches when needed.' + +inputs: + app-name: + description: 'Workbench app name from test matrix' + required: true + +outputs: + workbench_app_path: + description: 'Resolved absolute path to the workbench used by tests' + value: ${{ steps.prepare.outputs.workbench_app_path }} + +runs: + using: 'composite' + steps: + - id: prepare + shell: bash + run: | + if [[ "${{ inputs.app-name }}" == "nextjs-turbopack" || "${{ inputs.app-name }}" == "nextjs-webpack" ]]; then + STAGE_LOG="$(mktemp)" + node scripts/stage-workbench-with-tarballs.mjs "workbench/${{ inputs.app-name }}" | tee "$STAGE_LOG" + WORKBENCH_APP_PATH="$(sed -n 's/^Staged workbench: //p' "$STAGE_LOG" | tail -n 1)" + if [ -z "$WORKBENCH_APP_PATH" ]; then + echo "Failed to parse staged workbench path from stage-workbench-with-tarballs output" + exit 1 + fi + else + ./scripts/resolve-symlinks.sh "workbench/${{ inputs.app-name }}" + WORKBENCH_APP_PATH="$GITHUB_WORKSPACE/workbench/${{ inputs.app-name }}" + fi + + echo "workbench_app_path=$WORKBENCH_APP_PATH" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 87ad86ac66..2f7b840356 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -345,18 +345,22 @@ jobs: - name: Run Initial Build run: pnpm turbo run build --filter='!./workbench/*' - - name: Resolve symlinks - run: ./scripts/resolve-symlinks.sh workbench/${{ matrix.app.name }} + - name: Prepare workbench path + id: prepare-workbench + uses: ./.github/actions/prepare-workbench-path + with: + app-name: ${{ matrix.app.name }} - name: Run E2E Tests run: | - cd workbench/${{ matrix.app.name }} && pnpm dev & + cd "${{ steps.prepare-workbench.outputs.workbench_app_path }}" && pnpm dev & echo "starting tests in 10 seconds" && sleep 10 pnpm vitest run packages/core/e2e/dev.test.ts; sleep 10 pnpm run test:e2e --reporter=default --reporter=json --outputFile=e2e-local-dev-${{ matrix.app.name }}-${{ matrix.app.canary && 'canary' || 'stable' }}.json env: NODE_OPTIONS: "--enable-source-maps" APP_NAME: ${{ matrix.app.name }} + WORKBENCH_APP_PATH: ${{ steps.prepare-workbench.outputs.workbench_app_path }} DEPLOYMENT_URL: "http://localhost:${{ matrix.app.name == 'sveltekit' && '5173' || (matrix.app.name == 'astro' && '4321' || '3000') }}" DEV_TEST_CONFIG: ${{ toJSON(matrix.app) }} @@ -413,22 +417,27 @@ jobs: - name: Run Initial Build run: pnpm turbo run build --filter='!./workbench/*' - - name: Resolve symlinks - run: ./scripts/resolve-symlinks.sh workbench/${{ matrix.app.name }} + - name: Prepare workbench path + id: prepare-workbench + uses: ./.github/actions/prepare-workbench-path + with: + app-name: ${{ matrix.app.name }} - name: Run Build Tests run: pnpm vitest run packages/core/e2e/local-build.test.ts env: APP_NAME: ${{ matrix.app.name }} + WORKBENCH_APP_PATH: ${{ steps.prepare-workbench.outputs.workbench_app_path }} - name: Run E2E Tests run: | - cd workbench/${{ matrix.app.name }} && pnpm start & + cd "${{ steps.prepare-workbench.outputs.workbench_app_path }}" && pnpm start & echo "starting tests in 10 seconds" && sleep 10 pnpm run test:e2e --reporter=default --reporter=json --outputFile=e2e-local-prod-${{ matrix.app.name }}-${{ matrix.app.canary && 'canary' || 'stable' }}.json env: NODE_OPTIONS: "--enable-source-maps" APP_NAME: ${{ matrix.app.name }} + WORKBENCH_APP_PATH: ${{ steps.prepare-workbench.outputs.workbench_app_path }} DEPLOYMENT_URL: "http://localhost:${{ matrix.app.name == 'sveltekit' && '4173' || (matrix.app.name == 'astro' && '4321' || '3000') }}" - name: Generate E2E summary @@ -504,22 +513,27 @@ jobs: - name: Setup PostgreSQL Database run: ./packages/world-postgres/bin/setup.js - - name: Resolve symlinks - run: ./scripts/resolve-symlinks.sh workbench/${{ matrix.app.name }} + - name: Prepare workbench path + id: prepare-workbench + uses: ./.github/actions/prepare-workbench-path + with: + app-name: ${{ matrix.app.name }} - name: Run Build Tests run: pnpm vitest run packages/core/e2e/local-build.test.ts env: APP_NAME: ${{ matrix.app.name }} + WORKBENCH_APP_PATH: ${{ steps.prepare-workbench.outputs.workbench_app_path }} - name: Run E2E Tests run: | - cd workbench/${{ matrix.app.name }} && pnpm start & + cd "${{ steps.prepare-workbench.outputs.workbench_app_path }}" && pnpm start & echo "starting tests in 10 seconds" && sleep 10 pnpm run test:e2e --reporter=default --reporter=json --outputFile=e2e-local-postgres-${{ matrix.app.name }}-${{ matrix.app.canary && 'canary' || 'stable' }}.json env: NODE_OPTIONS: "--enable-source-maps" APP_NAME: ${{ matrix.app.name }} + WORKBENCH_APP_PATH: ${{ steps.prepare-workbench.outputs.workbench_app_path }} DEPLOYMENT_URL: "http://localhost:${{ matrix.app.name == 'sveltekit' && '4173' || (matrix.app.name == 'astro' && '4321' || '3000') }}" - name: Generate E2E summary diff --git a/package.json b/package.json index 9051b2f744..53ae1fa9f7 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "clean": "turbo clean", "typecheck": "turbo typecheck", "test:e2e": "vitest run packages/core/e2e/e2e.test.ts", + "test:e2e:nextjs-webpack:staged": "node scripts/test-staged-nextjs-webpack.mjs", "test:docs": "pnpm --filter @workflow/docs-typecheck test:docs", "bench": "vitest bench packages/core/e2e/bench.bench.ts", "bench:local": "DEPLOYMENT_URL=http://localhost:3000 APP_NAME=nextjs-turbopack vitest bench packages/core/e2e/bench.bench.ts", @@ -42,7 +43,8 @@ "changeset": "changeset", "ci:version": "changeset version", "ci:publish": "pnpm build && changeset publish", - "release:notes": "node scripts/generate-release-notes.mjs" + "release:notes": "node scripts/generate-release-notes.mjs", + "workbench:stage": "node scripts/stage-workbench-with-tarballs.mjs" }, "lint-staged": { "**/*": "biome format --write --no-errors-on-unmatched" diff --git a/packages/builders/src/module-specifier.test.ts b/packages/builders/src/module-specifier.test.ts index 580c6fb89b..c43b17a07d 100644 --- a/packages/builders/src/module-specifier.test.ts +++ b/packages/builders/src/module-specifier.test.ts @@ -208,4 +208,56 @@ describe('getImportPath', () => { isPackage: true, }); }); + + it('uses package subpath import for direct node_modules dependencies', () => { + const projectRoot = join(testRoot, 'apps/chat'); + const packageDir = join(testRoot, 'apps/chat/node_modules/@workflow/core'); + const filePath = join(packageDir, 'dist/serialization.js'); + + writeJson(join(projectRoot, 'package.json'), { + name: 'chat', + dependencies: { '@workflow/core': '1.0.0' }, + }); + + writeJson(join(packageDir, 'package.json'), { + name: '@workflow/core', + version: '1.0.0', + exports: { + './serialization': './dist/serialization.js', + }, + }); + + writeFile(filePath, `'use workflow';\n`); + + expect(getImportPath(filePath, projectRoot)).toEqual({ + importPath: '@workflow/core/serialization', + isPackage: true, + }); + }); + + it('falls back to relative import for transitive node_modules dependencies', () => { + const projectRoot = join(testRoot, 'apps/chat'); + const packageDir = join(testRoot, 'apps/chat/node_modules/@workflow/core'); + const filePath = join(packageDir, 'dist/serialization.js'); + + writeJson(join(projectRoot, 'package.json'), { + name: 'chat', + dependencies: { workflow: '1.0.0' }, + }); + + writeJson(join(packageDir, 'package.json'), { + name: '@workflow/core', + version: '1.0.0', + exports: { + './serialization': './dist/serialization.js', + }, + }); + + writeFile(filePath, `'use workflow';\n`); + + expect(getImportPath(filePath, projectRoot)).toEqual({ + importPath: './node_modules/@workflow/core/dist/serialization.js', + isPackage: false, + }); + }); }); diff --git a/packages/builders/src/module-specifier.ts b/packages/builders/src/module-specifier.ts index ae6b806f63..9d2728db94 100644 --- a/packages/builders/src/module-specifier.ts +++ b/packages/builders/src/module-specifier.ts @@ -511,6 +511,21 @@ export function getImportPath( // Find the package.json for this file const pkg = findPackageJson(filePath); if (pkg) { + const isDirectProjectDependency = getProjectDependencies(projectRoot).has( + pkg.name + ); + const canUsePackageSpecifier = inWorkspace || isDirectProjectDependency; + + // For transitive node_modules dependencies under strict package managers + // (for example, pnpm), importing by package name can fail. Use a direct + // file path import in those cases. + if (!canUsePackageSpecifier) { + return { + importPath: toRelativeImportPath(filePath, projectRoot), + isPackage: false, + }; + } + // Prefer a package subpath import when this file maps to an export. // This preserves the exact module being bundled while still respecting // package export conditions. diff --git a/packages/builders/src/transform-utils.test.ts b/packages/builders/src/transform-utils.test.ts index 310e4f7e1a..bc24f2b295 100644 --- a/packages/builders/src/transform-utils.test.ts +++ b/packages/builders/src/transform-utils.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { detectWorkflowPatterns, + isWorkflowSdkFile, useStepPattern, useWorkflowPattern, workflowSerdeComputedPropertyPattern, @@ -332,4 +333,44 @@ describe('transform-utils patterns', () => { expect(result.hasSerde).toBe(true); }); }); + + describe('isWorkflowSdkFile', () => { + it('matches direct @workflow package path in node_modules', () => { + expect( + isWorkflowSdkFile( + '/tmp/app/node_modules/@workflow/core/dist/serialization.js' + ) + ).toBe(true); + }); + + it('matches direct workflow package path in node_modules', () => { + expect( + isWorkflowSdkFile('/tmp/app/node_modules/workflow/dist/runtime.js') + ).toBe(true); + }); + + it('matches pnpm virtual store @workflow package path', () => { + expect( + isWorkflowSdkFile( + '/tmp/app/node_modules/.pnpm/@workflow+core@4.1.0/node_modules/@workflow/core/dist/serialization.js' + ) + ).toBe(true); + }); + + it('matches pnpm virtual store workflow package path', () => { + expect( + isWorkflowSdkFile( + '/tmp/app/node_modules/.pnpm/workflow@4.1.0/node_modules/workflow/dist/runtime.js' + ) + ).toBe(true); + }); + + it('does not match non-workflow package in pnpm store', () => { + expect( + isWorkflowSdkFile( + '/tmp/app/node_modules/.pnpm/lodash@4.17.21/node_modules/lodash/lodash.js' + ) + ).toBe(false); + }); + }); }); diff --git a/packages/builders/src/transform-utils.ts b/packages/builders/src/transform-utils.ts index 2b7d459dff..f95de805d2 100644 --- a/packages/builders/src/transform-utils.ts +++ b/packages/builders/src/transform-utils.ts @@ -29,10 +29,15 @@ export const generatedWorkflowPathPattern = // Pattern to detect @workflow SDK packages that should be excluded from transformation // These are internal SDK packages that should not be treated as user entry points. -// Matches both: node_modules/@workflow/* and monorepo packages/*/dist paths +// Matches: +// - node_modules/@workflow/* +// - node_modules/workflow/* +// - node_modules/.pnpm/.../node_modules/@workflow/* (pnpm virtual store) +// - node_modules/.pnpm/.../node_modules/workflow/* (pnpm virtual store) +// - monorepo packages/*/dist paths // User npm packages with workflows/steps/serde SHOULD still be discovered. export const workflowSdkPathPattern = - /[/\\](?:node_modules[/\\]@workflow[/\\]|packages[/\\](?:builders|core|rollup|vite|next|nitro|serde|workflow|swc-plugin-workflow)[/\\])/; + /[/\\](?:node_modules[/\\](?:@workflow[/\\]|workflow[/\\]|\.pnpm[/\\][^/\\]+[/\\]node_modules[/\\](?:@workflow[/\\]|workflow[/\\]))|packages[/\\](?:builders|core|rollup|vite|next|nitro|serde|workflow|swc-plugin-workflow)[/\\])/; /** * Detects workflow-related patterns in source code. diff --git a/packages/cli/src/base.ts b/packages/cli/src/base.ts index 6a789e5042..8bce7067df 100644 --- a/packages/cli/src/base.ts +++ b/packages/cli/src/base.ts @@ -1,6 +1,32 @@ import { Command } from '@oclif/core'; import { getWorld } from '@workflow/core/runtime'; +async function flushStream(stream: NodeJS.WriteStream): Promise { + if ( + !stream.writable || + stream.destroyed || + stream.closed || + stream.writableEnded || + stream.writableFinished + ) { + return; + } + + await new Promise((resolve) => { + const onError = () => resolve(); + stream.once('error', onError); + try { + stream.write('', () => { + stream.off('error', onError); + resolve(); + }); + } catch { + stream.off('error', onError); + resolve(); + } + }); +} + export abstract class BaseCommand extends Command { static enableJsonFlag = true; @@ -23,6 +49,10 @@ export abstract class BaseCommand extends Command { // agents, but third-party libraries (oclif update checker, postgres.js) // may leave timers or sockets that prevent the event loop from draining. // This is safe because all business logic and cleanup has completed. + await Promise.all([ + flushStream(process.stdout), + flushStream(process.stderr), + ]); process.exit(err ? 1 : 0); } diff --git a/packages/cli/src/commands/inspect.ts b/packages/cli/src/commands/inspect.ts index 9c30f59af1..586d990cb5 100644 --- a/packages/cli/src/commands/inspect.ts +++ b/packages/cli/src/commands/inspect.ts @@ -136,7 +136,8 @@ export default class Inspect extends BaseCommand { this.logError( `Unknown resource "${args.resource}": must be one of: run(s), step(s), stream(s), event(s), hook(s), sleep(s)` ); - process.exit(1); + process.exitCode = 1; + return; } const id = args.id; @@ -149,7 +150,7 @@ export default class Inspect extends BaseCommand { if (isWebMode) { const actualResource = resource === 'web' ? 'run' : resource; await launchWebUI(actualResource, id, flags, this.config.version); - process.exit(0); + return; } // For non-web commands, we need a valid world @@ -168,7 +169,7 @@ export default class Inspect extends BaseCommand { } else { await listRuns(world, options); } - process.exit(0); + return; } if (resource === 'step') { @@ -177,7 +178,7 @@ export default class Inspect extends BaseCommand { } else { await listSteps(world, options); } - process.exit(0); + return; } if (resource === 'stream') { @@ -186,7 +187,7 @@ export default class Inspect extends BaseCommand { } else { await listStreamsByRunId(world, options); } - process.exit(0); + return; } if (resource === 'event') { @@ -194,10 +195,11 @@ export default class Inspect extends BaseCommand { this.logError( 'Event-ID is not supported for events. Filter by run-id or step-id instead. Usage: `workflow inspect events --runId=`' ); - process.exit(1); + process.exitCode = 1; + return; } await listEvents(world, options); - process.exit(0); + return; } if (resource === 'hook') { @@ -206,7 +208,7 @@ export default class Inspect extends BaseCommand { } else { await listHooks(world, options); } - process.exit(0); + return; } if (resource === 'sleep') { @@ -214,22 +216,25 @@ export default class Inspect extends BaseCommand { this.logError( 'Sleep-ID is not supported for sleeps. Filter by run-id instead. Usage: `workflow inspect sleeps --runId=`' ); - process.exit(1); + process.exitCode = 1; + return; } if (!flags.runId) { this.logError( 'run-id is required for listing sleeps. Usage: `workflow inspect sleeps --runId=`' ); - process.exit(1); + process.exitCode = 1; + return; } await listSleeps(world, options); - process.exit(0); + return; } this.logError( `Unknown resource: ${resource}. Usage: ${Inspect.examples.join('\n')}` ); - process.exit(1); + process.exitCode = 1; + return; } catch (error) { // Let the catch handler deal with it, but ensure we exit throw error; diff --git a/packages/core/e2e/dev.test.ts b/packages/core/e2e/dev.test.ts index 9dd214510f..2a0cfeb2f1 100644 --- a/packages/core/e2e/dev.test.ts +++ b/packages/core/e2e/dev.test.ts @@ -1,5 +1,5 @@ -import fs from 'fs/promises'; -import path from 'path'; +import fs from 'node:fs/promises'; +import path from 'node:path'; import { afterEach, beforeAll, describe, expect, test } from 'vitest'; import { getWorkbenchAppPath } from './utils'; @@ -8,6 +8,7 @@ export interface DevTestConfig { generatedWorkflowPath: string; apiFilePath: string; apiFileImportPath: string; + canary?: boolean; /** The workflow file to modify for testing HMR. Defaults to '3_streams.ts' */ testWorkflowFile?: string; /** The workflows directory relative to appPath. Defaults to 'workflows' */ @@ -43,6 +44,11 @@ export function createDevTests(config?: DevTestConfig) { ); const testWorkflowFile = finalConfig.testWorkflowFile ?? '3_streams.ts'; const workflowsDir = finalConfig.workflowsDir ?? 'workflows'; + const supportsDeferredStepCopies = + finalConfig.canary === true && + generatedStep.includes( + path.join('.well-known', 'workflow', 'v1', 'step', 'route.js') + ); const restoreFiles: Array<{ path: string; content: string }> = []; const fetchWithTimeout = (pathname: string) => { @@ -55,6 +61,36 @@ export function createDevTests(config?: DevTestConfig) { }); }; + const triggerWorkflowRun = async ( + workflowName: string, + args: unknown[] = [] + ) => { + if (!deploymentUrl) { + return; + } + + const response = await fetch( + new URL('/api/workflows/start', deploymentUrl), + { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + workflowName, + args, + }), + signal: AbortSignal.timeout(5_000), + } + ); + + if (!response.ok) { + throw new Error( + `Failed to trigger workflow "${workflowName}": ${response.status}` + ); + } + }; + const prewarm = async () => { // Pre-warm the app with bounded requests so cleanup hooks cannot hang. await Promise.all([ @@ -63,6 +99,41 @@ export function createDevTests(config?: DevTestConfig) { ]); }; + const pollUntil = async ({ + description, + check, + timeoutMs = 25_000, + intervalMs = 1_000, + }: { + description: string; + check: () => Promise; + timeoutMs?: number; + intervalMs?: number; + }) => { + const deadline = Date.now() + timeoutMs; + let lastError: unknown = null; + + while (Date.now() < deadline) { + try { + await check(); + return; + } catch (error) { + lastError = error; + await new Promise((res) => setTimeout(res, intervalMs)); + } + } + + const lastErrorSuffix = + lastError instanceof Error + ? ` Last error: ${lastError.message}` + : lastError + ? ` Last error: ${String(lastError)}` + : ''; + throw new Error( + `Timed out after ${timeoutMs}ms waiting for ${description}.${lastErrorSuffix}` + ); + }; + beforeAll(async () => { await prewarm(); }); @@ -98,15 +169,13 @@ export async function myNewWorkflow() { ); restoreFiles.push({ path: workflowFile, content }); - while (true) { - try { + await pollUntil({ + description: 'generated workflow to include myNewWorkflow', + check: async () => { const workflowContent = await fs.readFile(generatedWorkflow, 'utf8'); expect(workflowContent).toContain('myNewWorkflow'); - break; - } catch (_) { - await new Promise((res) => setTimeout(res, 1_000)); - } - } + }, + }); }); test('should rebuild on step change', { timeout: 30_000 }, async () => { @@ -130,11 +199,12 @@ export async function myNewStep() { '__workflow_step_files__' ); - while (true) { - try { + await pollUntil({ + description: 'generated step outputs to include myNewStep', + check: async () => { const stepRouteContent = await fs.readFile(generatedStep, 'utf8'); if (stepRouteContent.includes('myNewStep')) { - break; + return; } const copiedStepFileNames = await fs.readdir(copiedStepDir); @@ -154,16 +224,72 @@ export async function myNewStep() { expect( copiedStepContents.some((content) => content.includes('myNewStep')) ).toBe(true); - break; - } catch (_) { - await new Promise((res) => setTimeout(res, 1_000)); - } - } + }, + }); }); + test.skipIf(!supportsDeferredStepCopies)( + 'should rebuild on imported step dependency change', + { timeout: 60_000 }, + async () => { + const importedStepFile = path.join( + appPath, + workflowsDir, + '_imported_step_only.ts' + ); + const content = await fs.readFile(importedStepFile, 'utf8'); + const marker = 'importedStepOnlyHotReloadMarker'; + + await fs.writeFile( + importedStepFile, + `${content} + +export async function ${marker}() { + 'use step' + return 'updated' +} +` + ); + restoreFiles.push({ path: importedStepFile, content }); + + const copiedStepDir = path.join( + path.dirname(generatedStep), + '__workflow_step_files__' + ); + + await pollUntil({ + description: + 'copied deferred step files to include imported step hot-reload marker', + timeoutMs: 50_000, + check: async () => { + await triggerWorkflowRun('importedStepOnlyWorkflow'); + const copiedStepFileNames = await fs.readdir(copiedStepDir); + const copiedStepContents = await Promise.all( + copiedStepFileNames.map(async (copiedStepFileName) => { + const copiedStepFilePath = path.join( + copiedStepDir, + copiedStepFileName + ); + const copiedStepStats = await fs.stat(copiedStepFilePath); + if (!copiedStepStats.isFile()) { + return ''; + } + return await fs.readFile(copiedStepFilePath, 'utf8'); + }) + ); + expect( + copiedStepContents.some((copiedStepContent) => + copiedStepContent.includes(marker) + ) + ).toBe(true); + }, + }); + } + ); + test( 'should rebuild on adding workflow file', - { timeout: 30_000 }, + { timeout: 60_000 }, async () => { const workflowFile = path.join( appPath, @@ -191,19 +317,101 @@ export async function myNewStep() { ${apiFileContent}` ); - while (true) { - try { + await pollUntil({ + description: 'generated workflow to include newWorkflowFile', + timeoutMs: 50_000, + check: async () => { await fetchWithTimeout('/api/chat'); const workflowContent = await fs.readFile( generatedWorkflow, 'utf8' ); expect(workflowContent).toContain('newWorkflowFile'); - break; - } catch (_) { - await new Promise((res) => setTimeout(res, 1_000)); - } - } + }, + }); + } + ); + + test.skipIf(!supportsDeferredStepCopies)( + 'should include steps discovered from workflow imports', + { timeout: 30_000 }, + async () => { + const workflowFile = path.join( + appPath, + workflowsDir, + 'discovered-via-workflow.ts' + ); + const stepFile = path.join( + appPath, + workflowsDir, + 'discovered-via-workflow-step.ts' + ); + + await fs.writeFile( + workflowFile, + `'use workflow'; +import { discoveredViaWorkflowStep } from './discovered-via-workflow-step'; + +export async function discoveredViaWorkflow() { + await discoveredViaWorkflowStep(); + return 'ok'; +} +` + ); + await fs.writeFile( + stepFile, + `'use step'; + +export async function discoveredViaWorkflowStep() { + return 'ok'; +} +` + ); + restoreFiles.push({ path: workflowFile, content: '' }); + restoreFiles.push({ path: stepFile, content: '' }); + + const apiFile = path.join(appPath, finalConfig.apiFilePath); + const apiFileContent = await fs.readFile(apiFile, 'utf8'); + restoreFiles.push({ path: apiFile, content: apiFileContent }); + + await fs.writeFile( + apiFile, + `import '${finalConfig.apiFileImportPath}/${workflowsDir}/discovered-via-workflow'; +${apiFileContent}` + ); + + const copiedStepDir = path.join( + path.dirname(generatedStep), + '__workflow_step_files__' + ); + + await pollUntil({ + description: + 'copied deferred step files to include discoveredViaWorkflowStep', + timeoutMs: 25_000, + check: async () => { + await fetchWithTimeout('/api/chat'); + const copiedStepFileNames = await fs.readdir(copiedStepDir); + const copiedStepContents = await Promise.all( + copiedStepFileNames.map(async (copiedStepFileName) => { + const copiedStepFilePath = path.join( + copiedStepDir, + copiedStepFileName + ); + const copiedStepStats = await fs.stat(copiedStepFilePath); + if (!copiedStepStats.isFile()) { + return ''; + } + return await fs.readFile(copiedStepFilePath, 'utf8'); + }) + ); + expect( + copiedStepContents.some((content) => + content.includes('discoveredViaWorkflowStep') + ) + ).toBe(true); + }, + }); } ); }); diff --git a/packages/core/e2e/e2e.test.ts b/packages/core/e2e/e2e.test.ts index 205f4bf31d..eed6dcb123 100644 --- a/packages/core/e2e/e2e.test.ts +++ b/packages/core/e2e/e2e.test.ts @@ -1,9 +1,10 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { setTimeout as sleep } from 'node:timers/promises'; import { WorkflowRunCancelledError, WorkflowRunFailedError, } from '@workflow/errors'; -import fs from 'fs'; -import path from 'path'; import { afterAll, assert, beforeAll, describe, expect, test } from 'vitest'; import type { Run } from '../src/runtime'; import { @@ -74,6 +75,10 @@ function writeE2EMetadata() { // Cached manifest fetched from the deployment let cachedManifest: WorkflowManifest | null = null; +const manifestRetryTimeoutMs = Number( + process.env.WORKFLOW_E2E_MANIFEST_RETRY_MS ?? '10000' +); +const manifestRetryIntervalMs = 250; /** * Fetches the workflow manifest from the deployment URL. @@ -81,7 +86,14 @@ let cachedManifest: WorkflowManifest | null = null; * workbench app when WORKFLOW_PUBLIC_MANIFEST=1 is set. */ async function fetchManifest(): Promise { - if (cachedManifest) return cachedManifest; + return fetchManifestWithOptions(); +} + +async function fetchManifestWithOptions(options?: { + forceRefresh?: boolean; +}): Promise { + const forceRefresh = options?.forceRefresh ?? false; + if (cachedManifest && !forceRefresh) return cachedManifest; const url = new URL('/.well-known/workflow/v1/manifest.json', deploymentUrl); const res = await fetch(url, { @@ -96,23 +108,11 @@ async function fetchManifest(): Promise { return cachedManifest; } -/** - * Looks up the workflow metadata from the manifest for a given workflow file and function name. - * Returns an object that can be passed directly to `start()`. - * - * The manifest contains the exact IDs produced by the SWC transform during the build, - * which handles symlink resolution and path normalization correctly. - */ -async function getWorkflowMetadata( +function findWorkflowMetadataInManifest( + manifest: WorkflowManifest, workflowFile: string, workflowFn: string -): Promise<{ workflowId: string }> { - const manifest = await fetchManifest(); - - // The manifest keys are relative file paths as seen by the builder. - // Due to symlinks, the key may differ from the workflowFile we pass - // (e.g., "example/workflows/99_e2e.ts" vs "workflows/99_e2e.ts"). - // Search all files for the matching function name and workflow file suffix. +): { workflowId: string } | null { for (const [manifestFile, functions] of Object.entries(manifest.workflows)) { if ( manifestFile.endsWith(workflowFile) || @@ -125,7 +125,6 @@ async function getWorkflowMetadata( } } - // If suffix matching didn't find it, try stripping the extension for matching const fileWithoutExt = workflowFile.replace(/\.tsx?$/, ''); for (const [manifestFile, functions] of Object.entries(manifest.workflows)) { const manifestFileWithoutExt = manifestFile.replace(/\.tsx?$/, ''); @@ -140,10 +139,66 @@ async function getWorkflowMetadata( } } - throw new Error( - `Workflow "${workflowFn}" not found in manifest for file "${workflowFile}". ` + - `Available files: ${Object.keys(manifest.workflows).join(', ')}` + return null; +} + +function getFallbackWorkflowId( + workflowFile: string, + workflowFn: string +): string { + const fileWithoutExt = workflowFile.replace(/\.tsx?$/, ''); + // Keep this in sync with the SWC transform ID format. This fallback is + // intentionally coupled so tests can continue running when deferred manifest + // publication lags behind discovery in staged/out-of-monorepo scenarios. + return `workflow//./${fileWithoutExt}//${workflowFn}`; +} + +/** + * Looks up the workflow metadata from the manifest for a given workflow file and function name. + * Returns an object that can be passed directly to `start()`. + * + * The manifest contains the exact IDs produced by the SWC transform during the build, + * which handles symlink resolution and path normalization correctly. + */ +async function getWorkflowMetadata( + workflowFile: string, + workflowFn: string +): Promise<{ workflowId: string }> { + let manifest = await fetchManifest(); + let metadata = findWorkflowMetadataInManifest( + manifest, + workflowFile, + workflowFn ); + if (metadata) { + return metadata; + } + + // Deferred discovery can grow the manifest during test execution, so poll + // briefly before failing to avoid races in staged/out-of-monorepo mode. + const deadline = Date.now() + manifestRetryTimeoutMs; + while (Date.now() < deadline) { + manifest = await fetchManifestWithOptions({ forceRefresh: true }); + metadata = findWorkflowMetadataInManifest( + manifest, + workflowFile, + workflowFn + ); + if (metadata) { + return metadata; + } + await sleep(manifestRetryIntervalMs); + } + + // Deferred discovery can lag behind manifest publication in staged/out-of- + // monorepo tests. Fall back to the deterministic workflow ID format used by + // the transform so tests can continue exercising runtime behavior. + const fallbackWorkflowId = getFallbackWorkflowId(workflowFile, workflowFn); + console.warn( + `Workflow "${workflowFn}" not found in manifest for "${workflowFile}" after ${manifestRetryTimeoutMs}ms; ` + + `falling back to ${fallbackWorkflowId}` + ); + return { workflowId: fallbackWorkflowId }; } /** @@ -304,6 +359,16 @@ describe('e2e', () => { expect(returnValue).toBe('B'); }); + test.skipIf(!isNext)( + 'importedStepOnlyWorkflow', + { timeout: 60_000 }, + async () => { + const run = await start(await e2e('importedStepOnlyWorkflow'), []); + const returnValue = await run.returnValue; + expect(returnValue).toBe('imported-step-only-ok'); + } + ); + // ReadableStream return values use the world's streaming infrastructure which // requires in-process access. The local world's streamer uses an in-process EventEmitter // that doesn't work cross-process (test runner ↔ workbench app). diff --git a/packages/core/e2e/utils.ts b/packages/core/e2e/utils.ts index fccc27bd78..4bf44da9c7 100644 --- a/packages/core/e2e/utils.ts +++ b/packages/core/e2e/utils.ts @@ -3,9 +3,26 @@ import path, { dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); +const defaultCliTimeoutMs = Number( + process.env.WORKFLOW_E2E_CLI_TIMEOUT_MS ?? '20000' +); + +function splitArgs(raw: string): string[] { + const value = raw.trim(); + if (!value) return []; + return value.split(/\s+/); +} export function getWorkbenchAppPath(overrideAppName?: string): string { + const explicitWorkbenchPath = process.env.WORKBENCH_APP_PATH; const appName = process.env.APP_NAME ?? overrideAppName; + if ( + explicitWorkbenchPath && + (!overrideAppName || !appName || overrideAppName === appName) + ) { + return path.resolve(explicitWorkbenchPath); + } + if (!appName) { throw new Error('`APP_NAME` environment variable is not set'); } @@ -106,7 +123,7 @@ const awaitCommand = async ( command: string, args: string[], cwd: string, - timeout = 5_000, + timeout = defaultCliTimeoutMs, envOverrides?: Record ) => { console.log(`[Debug]: Executing ${command} ${args.join(' ')}`); @@ -115,7 +132,6 @@ const awaitCommand = async ( return await new Promise<{ stdout: string; stderr: string }>( (resolve, reject) => { const child = spawn(command, args, { - shell: true, timeout, cwd, env: { @@ -172,12 +188,17 @@ export function getProtectionBypassHeaders(): HeadersInit { export const cliInspectJson = async (args: string) => { const cliAppPath = getWorkbenchAppPath(); - const cliArgs = getCliArgs(); - - const command = `node ./node_modules/workflow/bin/run.js inspect`; + const cliArgs = splitArgs(getCliArgs()); + const inspectArgs = splitArgs(args); const result = await awaitCommand( - command, - ['--json', args, cliArgs], + 'node', + [ + './node_modules/workflow/bin/run.js', + 'inspect', + '--json', + ...inspectArgs, + ...cliArgs, + ], cliAppPath ); try { @@ -198,12 +219,10 @@ export const cliInspectJson = async (args: string) => { */ export const cliCancel = async (runId: string) => { const cliAppPath = getWorkbenchAppPath(); - const cliArgs = getCliArgs(); - - const command = `node ./node_modules/workflow/bin/run.js cancel`; + const cliArgs = splitArgs(getCliArgs()); const result = await awaitCommand( - command, - [runId, cliArgs], + 'node', + ['./node_modules/workflow/bin/run.js', 'cancel', runId, ...cliArgs], cliAppPath, 10_000 ); @@ -219,10 +238,9 @@ export const cliHealthJson = async (options?: { timeout?: number; }) => { const cliAppPath = getWorkbenchAppPath(); - const cliArgs = getCliArgs(); + const cliArgs = splitArgs(getCliArgs()); - const command = `node ./node_modules/workflow/bin/run.js health`; - const args = ['--json']; + const args = ['./node_modules/workflow/bin/run.js', 'health', '--json']; if (options?.endpoint) { args.push(`--endpoint=${options.endpoint}`); @@ -230,9 +248,7 @@ export const cliHealthJson = async (options?: { if (options?.timeout) { args.push(`--timeout=${options.timeout}`); } - if (cliArgs) { - args.push(cliArgs); - } + args.push(...cliArgs); // Build environment overrides for the CLI process const envOverrides: Record = {}; @@ -244,7 +260,7 @@ export const cliHealthJson = async (options?: { } const result = await awaitCommand( - command, + 'node', args, cliAppPath, 45_000, diff --git a/packages/next/src/builder-deferred.ts b/packages/next/src/builder-deferred.ts index 39c1358638..2fb00efaaf 100644 --- a/packages/next/src/builder-deferred.ts +++ b/packages/next/src/builder-deferred.ts @@ -73,6 +73,7 @@ export async function getNextBuilderDeferred() { private deferredBuildQueue = Promise.resolve(); private cacheInitialized = false; private cacheWriteTimer: NodeJS.Timeout | null = null; + private deferredRebuildTimer: NodeJS.Timeout | null = null; private lastDeferredBuildSignature: string | null = null; async build() { @@ -584,8 +585,10 @@ export async function getNextBuilderDeferred() { return; } + process.env.WORKFLOW_SOCKET_INFO_PATH = this.getSocketInfoFilePath(); const config: SocketServerConfig = { isDevServer: Boolean(this.config.watch), + socketInfoFilePath: this.getSocketInfoFilePath(), onFileDiscovered: ( filePath: string, hasWorkflow: boolean, @@ -594,6 +597,8 @@ export async function getNextBuilderDeferred() { ) => { const normalizedFilePath = this.normalizeDiscoveredFilePath(filePath); let hasCacheTrackingChange = false; + const wasTrackedDependency = + this.trackedDependencyFiles.has(normalizedFilePath); if (hasWorkflow) { if (!this.discoveredWorkflowFiles.has(normalizedFilePath)) { @@ -618,17 +623,32 @@ export async function getNextBuilderDeferred() { } if (hasSerde) { + if (!this.discoveredSerdeFiles.has(normalizedFilePath)) { + hasCacheTrackingChange = true; + } this.discoveredSerdeFiles.add(normalizedFilePath); } else { - this.discoveredSerdeFiles.delete(normalizedFilePath); + const wasDeleted = + this.discoveredSerdeFiles.delete(normalizedFilePath); + hasCacheTrackingChange = wasDeleted || hasCacheTrackingChange; } if (hasCacheTrackingChange) { this.scheduleWorkflowsCacheWrite(); } + + if ( + hasWorkflow || + hasStep || + hasSerde || + hasCacheTrackingChange || + wasTrackedDependency + ) { + this.scheduleDeferredRebuild(); + } }, onTriggerBuild: () => { - // Deferred builder builds via onBeforeDeferredEntries callback. + this.scheduleDeferredRebuild(); }, }; @@ -657,6 +677,15 @@ export async function getNextBuilderDeferred() { ); } + private getSocketInfoFilePath(): string { + return join( + this.config.workingDir, + this.getDistDir(), + 'cache', + 'workflow-socket.json' + ); + } + private normalizeDiscoveredFilePath(filePath: string): string { return isAbsolute(filePath) ? filePath @@ -838,6 +867,26 @@ export async function getNextBuilderDeferred() { }, 50); } + private scheduleDeferredRebuild(): void { + if (!this.config.watch) { + return; + } + + if (this.deferredRebuildTimer) { + clearTimeout(this.deferredRebuildTimer); + } + + this.deferredRebuildTimer = setTimeout(() => { + this.deferredRebuildTimer = null; + void this.onBeforeDeferredEntries().catch((error) => { + console.warn( + '[workflow] Deferred rebuild after source update failed.', + error + ); + }); + }, 75); + } + private async readWorkflowsCache(): Promise<{ workflowFiles: string[]; stepFiles: string[]; @@ -1337,18 +1386,25 @@ export async function getNextBuilderDeferred() { return null; } - private async collectTransitiveStepFiles( - stepFiles: string[] - ): Promise { - const normalizedStepFiles = Array.from( + private async collectTransitiveStepFiles({ + stepFiles, + seedFiles = [], + }: { + stepFiles: string[]; + seedFiles?: string[]; + }): Promise { + const normalizedSeedFiles = Array.from( new Set( - stepFiles.map((stepFile) => + [...stepFiles, ...seedFiles].map((stepFile) => this.normalizeDiscoveredFilePath(stepFile) ) ) ).sort(); - const discoveredStepFiles = new Set(normalizedStepFiles); - const queuedFiles = [...normalizedStepFiles]; + // Intentionally re-validate step seeds against current file contents + // instead of blindly trusting callers. This prevents stale/manual seed + // paths from persisting when files no longer contain "use step". + const discoveredStepFiles = new Set(); + const queuedFiles = [...normalizedSeedFiles]; const visitedFiles = new Set(); const sourceCache = new Map(); const patternCache = new Map< @@ -1398,6 +1454,11 @@ export async function getNextBuilderDeferred() { continue; } + const currentPatterns = await getPatterns(currentFile); + if (currentPatterns?.hasUseStep) { + discoveredStepFiles.add(currentFile); + } + const relativeImportSpecifiers = this.extractRelativeImportSpecifiers(currentSource); for (const specifier of relativeImportSpecifiers) { @@ -1438,13 +1499,19 @@ export async function getNextBuilderDeferred() { ) ) ).sort(); - const discoveredSerdeFiles = new Set( - serdeFiles.map((serdeFile) => - this.normalizeDiscoveredFilePath(serdeFile) + const normalizedSerdeSeedFiles = Array.from( + new Set( + serdeFiles.map((serdeFile) => + this.normalizeDiscoveredFilePath(serdeFile) + ) ) - ); + ).sort(); + // Intentionally re-validate serde seeds against source + SDK filtering. + // This keeps previously discovered/manual seed entries from sticking when + // files no longer match serde patterns or resolve to SDK internals. + const discoveredSerdeFiles = new Set(); const queuedFiles = Array.from( - new Set([...normalizedEntryFiles, ...discoveredSerdeFiles]) + new Set([...normalizedEntryFiles, ...normalizedSerdeSeedFiles]) ); const visitedFiles = new Set(); const sourceCache = new Map(); @@ -1483,6 +1550,13 @@ export async function getNextBuilderDeferred() { return patterns; }; + for (const serdeSeedFile of normalizedSerdeSeedFiles) { + const seedPatterns = await getPatterns(serdeSeedFile); + if (seedPatterns?.hasSerde && !isWorkflowSdkFile(serdeSeedFile)) { + discoveredSerdeFiles.add(serdeSeedFile); + } + } + while (queuedFiles.length > 0) { const currentFile = queuedFiles.pop(); if (!currentFile || visitedFiles.has(currentFile)) { @@ -1694,10 +1768,13 @@ export async function getNextBuilderDeferred() { const stepsRouteDir = join(workflowGeneratedDir, 'step'); await mkdir(stepsRouteDir, { recursive: true }); const discovered = discoveredEntries; - const stepFiles = await this.collectTransitiveStepFiles( - [...discovered.discoveredSteps].sort() - ); const workflowFiles = [...discovered.discoveredWorkflows].sort(); + const stepFiles = await this.collectTransitiveStepFiles({ + stepFiles: [...discovered.discoveredSteps].sort(), + // Workflow transforms can inline step IDs and remove runtime imports, + // so seed transitive traversal with workflow files too. + seedFiles: workflowFiles, + }); const serdeFiles = [...discovered.discoveredSerdeFiles].sort(); const stepFileSet = new Set(stepFiles); const serdeOnlyFiles = serdeFiles.filter( diff --git a/packages/next/src/loader.ts b/packages/next/src/loader.ts index 55d42b4fec..a121bfd065 100644 --- a/packages/next/src/loader.ts +++ b/packages/next/src/loader.ts @@ -1,13 +1,14 @@ -import { connect, type Socket } from 'node:net'; import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { connect, type Socket } from 'node:net'; import { dirname, join, relative } from 'node:path'; import { transform } from '@swc/core'; import { type SocketMessage, serializeMessage } from './socket-server.js'; import { DEFERRED_STEP_SOURCE_METADATA_PREFIX, isDeferredStepCopyFilePath, - parseInlineSourceMapComment, parseDeferredStepSourceMetadata, + parseInlineSourceMapComment, } from './step-copy-utils.js'; type DecoratorOptionsWithConfigPath = @@ -30,6 +31,12 @@ let cachedLoaderStaticDependencies: LoaderStaticDependencies | null = null; // Cache socket connection to avoid reconnecting on every file. let socketClientPromise: Promise | null = null; let socketClient: Socket | null = null; +let socketClientKey: string | null = null; + +type SocketCredentials = { + port: number; + authToken: string; +}; function registerFileDependency( loaderContext: WorkflowLoaderContext, @@ -100,6 +107,7 @@ function resetSocketClient(cachedSocket?: Socket): void { socketClientPromise = null; socketClient = null; + socketClientKey = null; } async function writeSocketMessage( @@ -117,14 +125,69 @@ async function writeSocketMessage( }); } -function shouldUseSocketDiscovery(): boolean { - return Boolean( - process.env.WORKFLOW_SOCKET_PORT && process.env.WORKFLOW_SOCKET_AUTH +function getSocketInfoFilePath(): string { + return ( + process.env.WORKFLOW_SOCKET_INFO_PATH ?? + join(process.cwd(), '.next', 'cache', 'workflow-socket.json') + ); +} + +function getSocketCredentialsFromEnv(): SocketCredentials | null { + const socketPort = process.env.WORKFLOW_SOCKET_PORT; + const authToken = process.env.WORKFLOW_SOCKET_AUTH; + if (!socketPort || !authToken) { + return null; + } + + const port = Number.parseInt(socketPort, 10); + if (Number.isNaN(port)) { + return null; + } + + return { port, authToken }; +} + +async function getSocketCredentialsFromFile(): Promise { + const socketInfoFilePath = getSocketInfoFilePath(); + if (!existsSync(socketInfoFilePath)) { + return null; + } + + try { + const raw = await readFile(socketInfoFilePath, 'utf8'); + const parsed = JSON.parse(raw) as { + port?: unknown; + authToken?: unknown; + }; + const authToken = + typeof parsed.authToken === 'string' ? parsed.authToken : null; + const numericPort = + typeof parsed.port === 'number' + ? parsed.port + : Number.parseInt(String(parsed.port), 10); + + if (!authToken || Number.isNaN(numericPort)) { + return null; + } + + return { + port: numericPort, + authToken, + }; + } catch { + return null; + } +} + +async function getSocketCredentials(): Promise { + return ( + getSocketCredentialsFromEnv() ?? (await getSocketCredentialsFromFile()) ); } async function getSocketClient(): Promise { - if (!shouldUseSocketDiscovery()) { + const socketCredentials = await getSocketCredentials(); + if (!socketCredentials) { return null; } @@ -132,24 +195,22 @@ async function getSocketClient(): Promise { resetSocketClient(socketClient); } + const currentSocketKey = `${socketCredentials.port}:${socketCredentials.authToken}`; + if (socketClientKey && socketClientKey !== currentSocketKey) { + if (socketClient) { + resetSocketClient(socketClient); + } else { + resetSocketClient(); + } + } + if (!socketClientPromise) { socketClientPromise = (async () => { try { - const socketPort = process.env.WORKFLOW_SOCKET_PORT; - if (!socketPort) { - throw new Error( - 'Invariant: no socket port provided for workflow loader' - ); - } - - const port = Number.parseInt(socketPort, 10); - if (Number.isNaN(port)) { - throw new Error( - `Invariant: invalid socket port provided: ${socketPort}` - ); - } - - const socket = connect({ port, host: '127.0.0.1' }); + const socket = connect({ + port: socketCredentials.port, + host: '127.0.0.1', + }); // Wait for connection await new Promise((resolve, reject) => { @@ -185,6 +246,7 @@ async function getSocketClient(): Promise { }); socketClient = socket; + socketClientKey = currentSocketKey; return socket; } catch (error) { resetSocketClient(); @@ -202,7 +264,8 @@ async function notifySocketServer( hasStep: boolean, hasSerde: boolean ): Promise { - if (!shouldUseSocketDiscovery()) { + const socketCredentials = await getSocketCredentials(); + if (!socketCredentials) { return; } @@ -211,13 +274,6 @@ async function notifySocketServer( throw new Error('Invariant: missing workflow socket connection'); } - const authToken = process.env.WORKFLOW_SOCKET_AUTH; - if (!authToken) { - throw new Error( - 'Invariant: no socket auth token provided for workflow loader' - ); - } - const message: SocketMessage = { type: 'file-discovered', filePath: filename, @@ -225,7 +281,10 @@ async function notifySocketServer( hasStep, hasSerde, }; - const serializedMessage = serializeMessage(message, authToken); + const serializedMessage = serializeMessage( + message, + socketCredentials.authToken + ); try { await writeSocketMessage(socket, serializedMessage); @@ -406,24 +465,39 @@ export default function workflowLoader( sourceMap: null, }; const sourceForTransform = deferredSourceMapResult.sourceWithoutMapComment; + const discoveryFilePath = + deferredStepSourceMetadata?.absolutePath || filename; + + if (deferredStepSourceMetadata?.absolutePath) { + // Ensure edits to the original source invalidate deferred step copies. + registerFileDependency(this, deferredStepSourceMetadata.absolutePath); + } // Skip generated workflow route files to avoid re-processing them if ((await checkGeneratedFile(filename)) && !isDeferredStepCopyFile) { return { code: normalizedSource, map: sourceMap }; } - // Detect workflow patterns in the source code - const patterns = await detectPatterns(normalizedSource); + // Detect workflow patterns in the source code. + const patterns = await detectPatterns(sourceForTransform); // Always notify discovery tracking, even for `false/false`, so files that // previously had workflow/step usage are removed from the tracked sets. - if (!isDeferredStepCopyFile) { + // Deferred step copy files must report using their original source path so + // deferred rebuilds can react to source edits outside generated artifacts. + if (!isDeferredStepCopyFile || deferredStepSourceMetadata?.absolutePath) { + // For @workflow SDK packages, do not report serde-only matches for + // discovery, otherwise deferred mode can incorrectly treat SDK internals + // as app serde entrypoints. + const isSdkFile = await checkSdkFile(discoveryFilePath); await notifySocketServer( - filename, + discoveryFilePath, patterns.hasUseWorkflow, patterns.hasUseStep, - patterns.hasSerde + patterns.hasSerde && !isSdkFile ); + } + if (!isDeferredStepCopyFile) { // For @workflow SDK packages, only transform files with actual directives, // not files that just match serde patterns (which are internal SDK implementation files) const isSdkFile = await checkSdkFile(filename); diff --git a/packages/next/src/socket-server.ts b/packages/next/src/socket-server.ts index 777a2619c5..c79acf12dd 100644 --- a/packages/next/src/socket-server.ts +++ b/packages/next/src/socket-server.ts @@ -1,5 +1,7 @@ import { randomBytes } from 'node:crypto'; +import { mkdir, writeFile } from 'node:fs/promises'; import { createServer, type Server, type Socket } from 'node:net'; +import { dirname, join } from 'node:path'; /** * Magic preamble that must prefix all messages to authenticate them as workflow messages. @@ -41,6 +43,7 @@ export interface SocketServerConfig { hasSerde: boolean ) => void; onTriggerBuild: () => void; + socketInfoFilePath?: string; } /** @@ -51,6 +54,10 @@ export interface SocketIO { getAuthToken(): string; } +function getDefaultSocketInfoFilePath(): string { + return join(process.cwd(), '.next', 'cache', 'workflow-socket.json'); +} + /** * Serialize a message with authentication preamble */ @@ -170,14 +177,38 @@ export async function createSocketServer( }); // Listen on random available port (localhost only) - await new Promise((resolve) => { + await new Promise((resolve, reject) => { + server.once('error', reject); server.listen(0, '127.0.0.1', () => { const address = server.address(); if (address && typeof address === 'object') { - process.env.WORKFLOW_SOCKET_PORT = String(address.port); - process.env.WORKFLOW_SOCKET_AUTH = authToken; + const socketInfoFilePath = + config.socketInfoFilePath || getDefaultSocketInfoFilePath(); + void (async () => { + try { + await mkdir(dirname(socketInfoFilePath), { recursive: true }); + await writeFile( + socketInfoFilePath, + JSON.stringify( + { + port: address.port, + authToken, + }, + null, + 2 + ) + ); + process.env.WORKFLOW_SOCKET_INFO_PATH = socketInfoFilePath; + process.env.WORKFLOW_SOCKET_PORT = String(address.port); + process.env.WORKFLOW_SOCKET_AUTH = authToken; + resolve(); + } catch (error) { + reject(error); + } + })(); + return; } - resolve(); + reject(new Error('Failed to obtain workflow socket server address')); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3d44f38f6..43b88b7653 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1060,7 +1060,7 @@ importers: version: 4.1.0 geist: specifier: ^1.7.0 - version: 1.7.0(next@16.1.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) + version: 1.7.0(next@16.2.0-canary.65(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) jsdom: specifier: ^26 version: 26.1.0 @@ -2886,6 +2886,9 @@ packages: '@emnapi/runtime@1.5.0': resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==} + '@emnapi/runtime@1.8.1': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} + '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} @@ -3801,122 +3804,255 @@ packages: cpu: [arm64] os: [darwin] + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + '@img/sharp-darwin-x64@0.34.4': resolution: {integrity: sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [darwin] + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + '@img/sharp-libvips-darwin-arm64@1.2.3': resolution: {integrity: sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==} cpu: [arm64] os: [darwin] + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + '@img/sharp-libvips-darwin-x64@1.2.3': resolution: {integrity: sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==} cpu: [x64] os: [darwin] + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + '@img/sharp-libvips-linux-arm64@1.2.3': resolution: {integrity: sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==} cpu: [arm64] os: [linux] + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + '@img/sharp-libvips-linux-arm@1.2.3': resolution: {integrity: sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==} cpu: [arm] os: [linux] + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + '@img/sharp-libvips-linux-ppc64@1.2.3': resolution: {integrity: sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==} cpu: [ppc64] os: [linux] + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + '@img/sharp-libvips-linux-s390x@1.2.3': resolution: {integrity: sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==} cpu: [s390x] os: [linux] + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + '@img/sharp-libvips-linux-x64@1.2.3': resolution: {integrity: sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==} cpu: [x64] os: [linux] + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + '@img/sharp-libvips-linuxmusl-arm64@1.2.3': resolution: {integrity: sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==} cpu: [arm64] os: [linux] + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + '@img/sharp-libvips-linuxmusl-x64@1.2.3': resolution: {integrity: sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==} cpu: [x64] os: [linux] + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + '@img/sharp-linux-arm64@0.34.4': resolution: {integrity: sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + '@img/sharp-linux-arm@0.34.4': resolution: {integrity: sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + '@img/sharp-linux-ppc64@0.34.4': resolution: {integrity: sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + '@img/sharp-linux-s390x@0.34.4': resolution: {integrity: sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + '@img/sharp-linux-x64@0.34.4': resolution: {integrity: sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + '@img/sharp-linuxmusl-arm64@0.34.4': resolution: {integrity: sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + '@img/sharp-linuxmusl-x64@0.34.4': resolution: {integrity: sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + '@img/sharp-wasm32@0.34.4': resolution: {integrity: sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [wasm32] + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + '@img/sharp-win32-arm64@0.34.4': resolution: {integrity: sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [win32] + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + '@img/sharp-win32-ia32@0.34.4': resolution: {integrity: sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ia32] os: [win32] + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + '@img/sharp-win32-x64@0.34.4': resolution: {integrity: sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [win32] + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@inquirer/ansi@1.0.2': resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} engines: {node: '>=18'} @@ -4372,54 +4508,105 @@ packages: '@next/env@16.1.6': resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==} + '@next/env@16.2.0-canary.65': + resolution: {integrity: sha512-75gSDG2jpvqNCKyaHqGx9AbPhPERWzYU5wY8fFx0eTHrQXK0bClmW8fgn/+5BZmeaa3/TAaJM3JR+b31FdLegA==} + '@next/swc-darwin-arm64@16.1.6': resolution: {integrity: sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] + '@next/swc-darwin-arm64@16.2.0-canary.65': + resolution: {integrity: sha512-V4QrVYe8tKG5wQHYOu2JqTisCO5S6O1zTlH453uPd2kh3HzKLcCMcc1ksWbK1rh2W8WDxQygbCbZeI5UAf5Dcw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + '@next/swc-darwin-x64@16.1.6': resolution: {integrity: sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] + '@next/swc-darwin-x64@16.2.0-canary.65': + resolution: {integrity: sha512-QKVsHp0VfNRr6ePSukkzFTlY3BlCVGGyOisLqL+Yrb5Fve8mMXmBbeHT3034O/xzq2/QIcxBpvoUl9keEwEkhg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + '@next/swc-linux-arm64-gnu@16.1.6': resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + '@next/swc-linux-arm64-gnu@16.2.0-canary.65': + resolution: {integrity: sha512-3+Rw84v+bvjTKPnBQTP0bdkq1WJJISlC4h8Kdo3AYLAq32CezF/QjEIHHG3ugKOO1TCx7nNuNDtN5t9JCTa8Ow==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + '@next/swc-linux-arm64-musl@16.1.6': resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + '@next/swc-linux-arm64-musl@16.2.0-canary.65': + resolution: {integrity: sha512-4NqyWr2NvNc8vVord4tlONJkmJq4DcBp/p0g9gETgnbkCu3wrE6q+Qtn6qfLi3QhBkUWaILgtWt9vGC6FCwWgg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + '@next/swc-linux-x64-gnu@16.1.6': resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + '@next/swc-linux-x64-gnu@16.2.0-canary.65': + resolution: {integrity: sha512-lrQTUA3ijEcbgMBBDj22bGeulN3CNqyf3tNLHgAkmgAne1ZgbJwHPEIKM2rU2KRCaFsqyGRFXrUAQU8fcO12Rw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + '@next/swc-linux-x64-musl@16.1.6': resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + '@next/swc-linux-x64-musl@16.2.0-canary.65': + resolution: {integrity: sha512-8P5hM9WXWH+DES4La5bvDKIhMxhFJ7mI1J+zW62HlwB4h9UX7lhA0VnkStqvkM6RcfUGGYvBF1k9YxxVBbTYwQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + '@next/swc-win32-arm64-msvc@16.1.6': resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] + '@next/swc-win32-arm64-msvc@16.2.0-canary.65': + resolution: {integrity: sha512-+7sUTlvtbD8uCWl7Kn6OH1K2Qsg8W2vKdGLbOnNrICSRRb86Y1ZBFnrfmmyjV0Lr+gsdps+AcPEShFcTwLvs/g==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + '@next/swc-win32-x64-msvc@16.1.6': resolution: {integrity: sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==} engines: {node: '>= 10'} cpu: [x64] os: [win32] + '@next/swc-win32-x64-msvc@16.2.0-canary.65': + resolution: {integrity: sha512-miAzz8L1JMCU037c4xWHrFKq+1PNQZFlWhDMvzhkBzD3o47e8rI+3/GHXCLZXtgmV+HYoOs1P8ylOjgJ1eV+7w==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + '@node-rs/xxhash-android-arm-eabi@1.7.6': resolution: {integrity: sha512-ptmfpFZ8SgTef58Us+0HsZ9BKhyX/gZYbhLkuzPt7qUoMqMSJK85NC7LEgzDgjUiG+S5GahEEQ9/tfh9BVvKhw==} engines: {node: '>= 12'} @@ -9032,6 +9219,11 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.10.0: + resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} + engines: {node: '>=6.0.0'} + hasBin: true + baseline-browser-mapping@2.9.18: resolution: {integrity: sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==} hasBin: true @@ -12667,6 +12859,27 @@ packages: sass: optional: true + next@16.2.0-canary.65: + resolution: {integrity: sha512-vFyjZTV2n3OPwZhU+7POExhCLCSxQBfUd5cHXlFSCWvy71AcCwDPMeSX54J+7LQX+FrwLvo5ivECOqW2kZkxow==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + nf3@0.1.10: resolution: {integrity: sha512-bT6FITvXLd8Z9Qbt0NsMz90diyLNK8H4Sp2vZ9IGLrKxsF5djM+F2vQmR6GyvtlP2y47XMZjjVFpPClgMB8USQ==} @@ -14274,6 +14487,10 @@ packages: resolution: {integrity: sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -17189,6 +17406,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.8.1': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.1.0': dependencies: tslib: 2.8.1 @@ -17772,87 +17994,181 @@ snapshots: '@img/sharp-libvips-darwin-arm64': 1.2.3 optional: true + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + '@img/sharp-darwin-x64@0.34.4': optionalDependencies: '@img/sharp-libvips-darwin-x64': 1.2.3 optional: true + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + '@img/sharp-libvips-darwin-arm64@1.2.3': optional: true + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + '@img/sharp-libvips-darwin-x64@1.2.3': optional: true + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + '@img/sharp-libvips-linux-arm64@1.2.3': optional: true + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + '@img/sharp-libvips-linux-arm@1.2.3': optional: true + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + '@img/sharp-libvips-linux-ppc64@1.2.3': optional: true + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + '@img/sharp-libvips-linux-s390x@1.2.3': optional: true + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + '@img/sharp-libvips-linux-x64@1.2.3': optional: true + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.2.3': optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + '@img/sharp-libvips-linuxmusl-x64@1.2.3': optional: true + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + '@img/sharp-linux-arm64@0.34.4': optionalDependencies: '@img/sharp-libvips-linux-arm64': 1.2.3 optional: true + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + '@img/sharp-linux-arm@0.34.4': optionalDependencies: '@img/sharp-libvips-linux-arm': 1.2.3 optional: true + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + '@img/sharp-linux-ppc64@0.34.4': optionalDependencies: '@img/sharp-libvips-linux-ppc64': 1.2.3 optional: true + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + '@img/sharp-linux-s390x@0.34.4': optionalDependencies: '@img/sharp-libvips-linux-s390x': 1.2.3 optional: true + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + '@img/sharp-linux-x64@0.34.4': optionalDependencies: '@img/sharp-libvips-linux-x64': 1.2.3 optional: true + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + '@img/sharp-linuxmusl-arm64@0.34.4': optionalDependencies: '@img/sharp-libvips-linuxmusl-arm64': 1.2.3 optional: true + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + '@img/sharp-linuxmusl-x64@0.34.4': optionalDependencies: '@img/sharp-libvips-linuxmusl-x64': 1.2.3 optional: true + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + '@img/sharp-wasm32@0.34.4': dependencies: '@emnapi/runtime': 1.5.0 optional: true + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.8.1 + optional: true + '@img/sharp-win32-arm64@0.34.4': optional: true + '@img/sharp-win32-arm64@0.34.5': + optional: true + '@img/sharp-win32-ia32@0.34.4': optional: true + '@img/sharp-win32-ia32@0.34.5': + optional: true + '@img/sharp-win32-x64@0.34.4': optional: true + '@img/sharp-win32-x64@0.34.5': + optional: true + '@inquirer/ansi@1.0.2': {} '@inquirer/checkbox@4.3.2(@types/node@22.19.0)': @@ -18428,30 +18744,56 @@ snapshots: '@next/env@16.1.6': {} + '@next/env@16.2.0-canary.65': {} + '@next/swc-darwin-arm64@16.1.6': optional: true + '@next/swc-darwin-arm64@16.2.0-canary.65': + optional: true + '@next/swc-darwin-x64@16.1.6': optional: true + '@next/swc-darwin-x64@16.2.0-canary.65': + optional: true + '@next/swc-linux-arm64-gnu@16.1.6': optional: true + '@next/swc-linux-arm64-gnu@16.2.0-canary.65': + optional: true + '@next/swc-linux-arm64-musl@16.1.6': optional: true + '@next/swc-linux-arm64-musl@16.2.0-canary.65': + optional: true + '@next/swc-linux-x64-gnu@16.1.6': optional: true + '@next/swc-linux-x64-gnu@16.2.0-canary.65': + optional: true + '@next/swc-linux-x64-musl@16.1.6': optional: true + '@next/swc-linux-x64-musl@16.2.0-canary.65': + optional: true + '@next/swc-win32-arm64-msvc@16.1.6': optional: true + '@next/swc-win32-arm64-msvc@16.2.0-canary.65': + optional: true + '@next/swc-win32-x64-msvc@16.1.6': optional: true + '@next/swc-win32-x64-msvc@16.2.0-canary.65': + optional: true + '@node-rs/xxhash-android-arm-eabi@1.7.6': optional: true @@ -24416,6 +24758,8 @@ snapshots: base64-js@1.5.1: {} + baseline-browser-mapping@2.10.0: {} + baseline-browser-mapping@2.9.18: {} bcp-47-match@2.0.3: {} @@ -26648,9 +26992,9 @@ snapshots: fuse.js@7.1.0: {} - geist@1.7.0(next@16.1.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)): + geist@1.7.0(next@16.2.0-canary.65(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)): dependencies: - next: 16.1.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: 16.2.0-canary.65(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) gensync@1.0.0-beta.2: {} @@ -28634,16 +28978,16 @@ snapshots: transitivePeerDependencies: - supports-color - next@16.1.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + next@16.1.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@next/env': 16.1.6 '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.9.18 + baseline-browser-mapping: 2.10.0 caniuse-lite: 1.0.30001766 postcss: 8.4.31 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - styled-jsx: 5.1.6(@babel/core@7.28.4)(react@19.1.0) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + styled-jsx: 5.1.6(@babel/core@7.28.4)(react@19.2.3) optionalDependencies: '@next/swc-darwin-arm64': 16.1.6 '@next/swc-darwin-x64': 16.1.6 @@ -28654,21 +28998,21 @@ snapshots: '@next/swc-win32-arm64-msvc': 16.1.6 '@next/swc-win32-x64-msvc': 16.1.6 '@opentelemetry/api': 1.9.0 - sharp: 0.34.4 + sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros - next@16.1.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + next@16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.1.6 '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.9.18 + baseline-browser-mapping: 2.10.0 caniuse-lite: 1.0.30001766 postcss: 8.4.31 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - styled-jsx: 5.1.6(@babel/core@7.28.4)(react@19.2.3) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + styled-jsx: 5.1.6(react@19.2.4) optionalDependencies: '@next/swc-darwin-arm64': 16.1.6 '@next/swc-darwin-x64': 16.1.6 @@ -28679,32 +29023,32 @@ snapshots: '@next/swc-win32-arm64-msvc': 16.1.6 '@next/swc-win32-x64-msvc': 16.1.6 '@opentelemetry/api': 1.9.0 - sharp: 0.34.4 + sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros - next@16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.2.0-canary.65(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: - '@next/env': 16.1.6 + '@next/env': 16.2.0-canary.65 '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.9.18 + baseline-browser-mapping: 2.10.0 caniuse-lite: 1.0.30001766 postcss: 8.4.31 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - styled-jsx: 5.1.6(react@19.2.4) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + styled-jsx: 5.1.6(@babel/core@7.28.4)(react@19.1.0) optionalDependencies: - '@next/swc-darwin-arm64': 16.1.6 - '@next/swc-darwin-x64': 16.1.6 - '@next/swc-linux-arm64-gnu': 16.1.6 - '@next/swc-linux-arm64-musl': 16.1.6 - '@next/swc-linux-x64-gnu': 16.1.6 - '@next/swc-linux-x64-musl': 16.1.6 - '@next/swc-win32-arm64-msvc': 16.1.6 - '@next/swc-win32-x64-msvc': 16.1.6 + '@next/swc-darwin-arm64': 16.2.0-canary.65 + '@next/swc-darwin-x64': 16.2.0-canary.65 + '@next/swc-linux-arm64-gnu': 16.2.0-canary.65 + '@next/swc-linux-arm64-musl': 16.2.0-canary.65 + '@next/swc-linux-x64-gnu': 16.2.0-canary.65 + '@next/swc-linux-x64-musl': 16.2.0-canary.65 + '@next/swc-win32-arm64-msvc': 16.2.0-canary.65 + '@next/swc-win32-x64-msvc': 16.2.0-canary.65 '@opentelemetry/api': 1.9.0 - sharp: 0.34.4 + sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros @@ -31208,6 +31552,38 @@ snapshots: '@img/sharp-win32-x64': 0.34.4 optional: true + sharp@0.34.5: + dependencies: + '@img/colour': 1.0.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 diff --git a/scripts/stage-workbench-with-tarballs.mjs b/scripts/stage-workbench-with-tarballs.mjs new file mode 100644 index 0000000000..307a9ef3b5 --- /dev/null +++ b/scripts/stage-workbench-with-tarballs.mjs @@ -0,0 +1,375 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, '..'); +const workbenchRoot = path.join(repoRoot, 'workbench'); +const workbenchScriptsRoot = path.join(workbenchRoot, 'scripts'); +const repoLibRoot = path.join(repoRoot, 'lib'); +const packagesRoot = path.join(repoRoot, 'packages'); +const workspaceYamlPath = path.join(repoRoot, 'pnpm-workspace.yaml'); + +const dependencyFields = [ + 'dependencies', + 'devDependencies', + 'optionalDependencies', +]; + +const excludedPaths = new Set([ + 'node_modules', + '.next', + '.turbo', + '.vercel', + '.output', + '.nitro', + 'dist', +]); + +function run(command, args, cwd) { + console.log(`$ ${command} ${args.join(' ')}`); + execFileSync(command, args, { + cwd, + stdio: 'inherit', + env: { + ...process.env, + COREPACK_ENABLE_AUTO_PIN: process.env.COREPACK_ENABLE_AUTO_PIN ?? '0', + }, + }); +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function writeJson(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function usage() { + console.error( + 'Usage: node scripts/stage-workbench-with-tarballs.mjs ' + ); +} + +function resolveWorkbenchDir(inputArg) { + const candidates = [ + path.resolve(repoRoot, inputArg), + path.resolve(workbenchRoot, inputArg), + ]; + + for (const candidate of candidates) { + const packageJsonPath = path.join(candidate, 'package.json'); + if (fs.existsSync(candidate) && fs.existsSync(packageJsonPath)) { + return candidate; + } + } + + throw new Error( + `Could not resolve workbench "${inputArg}". Expected either workbench/ or a path containing package.json.` + ); +} + +function toTarballFilename(packageName, version) { + const normalized = packageName.replace(/^@/, '').replace(/\//g, '-'); + return `${normalized}-${version}.tgz`; +} + +function parseCatalogEntries(yamlPath) { + const catalog = {}; + const lines = fs.readFileSync(yamlPath, 'utf8').split(/\r?\n/u); + let inCatalog = false; + + for (const line of lines) { + if (!inCatalog) { + if (line.trim() === 'catalog:') { + inCatalog = true; + } + continue; + } + + if (!line.trim()) { + continue; + } + + if (!line.startsWith(' ')) { + break; + } + + const match = line.match(/^\s{2}("?[^"]+"?|[^:]+):\s*(.+)\s*$/u); + if (!match) { + continue; + } + + let key = match[1].trim(); + if (key.startsWith('"') && key.endsWith('"')) { + key = key.slice(1, -1); + } + const value = match[2].trim(); + catalog[key] = value; + } + + return catalog; +} + +function collectMonorepoPackages() { + const tarballByPackageName = new Map(); + const dirs = fs.readdirSync(packagesRoot, { withFileTypes: true }); + + for (const dirent of dirs) { + if (!dirent.isDirectory()) { + continue; + } + + const packageDir = path.join(packagesRoot, dirent.name); + const packageJsonPath = path.join(packageDir, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + continue; + } + + const packageJson = readJson(packageJsonPath); + tarballByPackageName.set( + packageJson.name, + toTarballFilename(packageJson.name, packageJson.version) + ); + } + + return tarballByPackageName; +} + +function copyWorkbenchWithResolvedSymlinks(sourceDir, destinationDir) { + fs.cpSync(sourceDir, destinationDir, { + recursive: true, + dereference: true, + filter: (sourcePath) => { + const baseName = path.basename(sourcePath); + return !excludedPaths.has(baseName); + }, + }); +} + +function copyWorkbenchScripts(destinationRoot) { + if (!fs.existsSync(workbenchScriptsRoot)) { + return false; + } + + const destinationScriptsDir = path.join(destinationRoot, 'scripts'); + fs.cpSync(workbenchScriptsRoot, destinationScriptsDir, { + recursive: true, + dereference: true, + filter: (sourcePath) => { + const baseName = path.basename(sourcePath); + return !excludedPaths.has(baseName); + }, + }); + return true; +} + +function copyRepoLib(destinationRoot) { + if (!fs.existsSync(repoLibRoot)) { + return false; + } + + const destinationLibDir = path.join(destinationRoot, 'lib'); + fs.cpSync(repoLibRoot, destinationLibDir, { + recursive: true, + dereference: true, + filter: (sourcePath) => { + const baseName = path.basename(sourcePath); + return !excludedPaths.has(baseName); + }, + }); + return true; +} + +function rewriteDependencySpecs( + packageJsonPath, + tarballPathByPackageName, + catalog +) { + const packageJson = readJson(packageJsonPath); + const replacedWithTarballs = []; + const replacedCatalogEntries = []; + const unresolvedWorkspaceSpecs = []; + const unresolvedCatalogSpecs = []; + + for (const field of dependencyFields) { + const dependencies = packageJson[field]; + if (!dependencies) { + continue; + } + + for (const [dependencyName, spec] of Object.entries(dependencies)) { + const tarballPath = tarballPathByPackageName.get(dependencyName); + if (tarballPath) { + dependencies[dependencyName] = `file:${tarballPath}`; + replacedWithTarballs.push(`${field}.${dependencyName}`); + continue; + } + + if (typeof spec === 'string' && spec.startsWith('workspace:')) { + unresolvedWorkspaceSpecs.push(`${field}.${dependencyName}`); + continue; + } + + if (spec === 'catalog:') { + const resolvedVersion = catalog[dependencyName]; + if (resolvedVersion) { + dependencies[dependencyName] = resolvedVersion; + replacedCatalogEntries.push(`${field}.${dependencyName}`); + } else { + unresolvedCatalogSpecs.push(`${field}.${dependencyName}`); + } + continue; + } + + if (typeof spec === 'string' && spec.startsWith('catalog:')) { + unresolvedCatalogSpecs.push(`${field}.${dependencyName}`); + } + } + } + + if (unresolvedWorkspaceSpecs.length > 0) { + throw new Error( + `Found unresolved workspace dependencies in staged workbench package.json: ${unresolvedWorkspaceSpecs.join(', ')}` + ); + } + + if (unresolvedCatalogSpecs.length > 0) { + throw new Error( + `Found unresolved catalog dependencies in staged workbench package.json: ${unresolvedCatalogSpecs.join(', ')}` + ); + } + + writeJson(packageJsonPath, packageJson); + return { replacedWithTarballs, replacedCatalogEntries }; +} + +function applyTarballOverrides(packageJsonPath, tarballPathByPackageName) { + const packageJson = readJson(packageJsonPath); + const pnpmConfig = + packageJson.pnpm && typeof packageJson.pnpm === 'object' + ? packageJson.pnpm + : {}; + const overrides = + pnpmConfig.overrides && typeof pnpmConfig.overrides === 'object' + ? pnpmConfig.overrides + : {}; + + let overridesApplied = 0; + for (const [packageName, tarballPath] of tarballPathByPackageName.entries()) { + overrides[packageName] = `file:${tarballPath}`; + overridesApplied += 1; + } + + packageJson.pnpm = { + ...pnpmConfig, + overrides, + }; + + writeJson(packageJsonPath, packageJson); + return overridesApplied; +} + +function main() { + const args = process.argv.slice(2).filter((arg) => arg !== '--'); + const [workbenchArg] = args; + if (!workbenchArg) { + usage(); + process.exit(1); + } + + const sourceWorkbenchDir = resolveWorkbenchDir(workbenchArg); + const workbenchName = path.basename(sourceWorkbenchDir); + + const tmpRoot = fs.mkdtempSync( + path.join(os.tmpdir(), `workflow-${workbenchName}-`) + ); + const stagedWorkbenchRoot = path.join(tmpRoot, 'workbench'); + const stagedWorkbenchDir = path.join(stagedWorkbenchRoot, workbenchName); + const tarballDir = path.join(tmpRoot, 'tarballs'); + fs.mkdirSync(stagedWorkbenchRoot, { recursive: true }); + fs.mkdirSync(tarballDir, { recursive: true }); + + console.log( + `Staging ${path.relative(repoRoot, sourceWorkbenchDir)} at ${stagedWorkbenchDir}` + ); + copyWorkbenchWithResolvedSymlinks(sourceWorkbenchDir, stagedWorkbenchDir); + const copiedScripts = copyWorkbenchScripts(stagedWorkbenchRoot); + if (copiedScripts) { + console.log( + `Copied workbench scripts to ${path.join(stagedWorkbenchRoot, 'scripts')}` + ); + } + + const copiedLib = copyRepoLib(tmpRoot); + if (copiedLib) { + console.log(`Copied repo lib to ${path.join(tmpRoot, 'lib')}`); + } + + console.log(`Packing monorepo packages to ${tarballDir}`); + run( + 'pnpm', + [ + '-r', + '--filter', + './packages/*', + 'pack', + '--pack-destination', + tarballDir, + ], + repoRoot + ); + + const tarballFileByPackageName = collectMonorepoPackages(); + const tarballPathByPackageName = new Map(); + const missingTarballs = []; + + for (const [packageName, tarballFile] of tarballFileByPackageName.entries()) { + const tarballPath = path.join(tarballDir, tarballFile); + if (!fs.existsSync(tarballPath)) { + missingTarballs.push(`${packageName} (${tarballFile})`); + continue; + } + tarballPathByPackageName.set(packageName, tarballPath); + } + + if (missingTarballs.length > 0) { + throw new Error( + `Missing tarballs after packing: ${missingTarballs.join(', ')}` + ); + } + + const catalog = parseCatalogEntries(workspaceYamlPath); + const stagedPackageJsonPath = path.join(stagedWorkbenchDir, 'package.json'); + const { replacedWithTarballs, replacedCatalogEntries } = + rewriteDependencySpecs( + stagedPackageJsonPath, + tarballPathByPackageName, + catalog + ); + const overridesApplied = applyTarballOverrides( + stagedPackageJsonPath, + tarballPathByPackageName + ); + + console.log( + `Rewrote ${replacedWithTarballs.length} monorepo dependencies to tarballs and ${replacedCatalogEntries.length} catalog dependencies to versions` + ); + console.log( + `Applied ${overridesApplied} pnpm tarball overrides for transitive monorepo packages` + ); + + console.log(`Installing dependencies in ${stagedWorkbenchDir}`); + run('pnpm', ['install', '--no-frozen-lockfile'], stagedWorkbenchDir); + + console.log(''); + console.log('Done.'); + console.log(`Staged workbench: ${stagedWorkbenchDir}`); + console.log(`Tarballs: ${tarballDir}`); +} + +main(); diff --git a/workbench/example/workflows/99_e2e.ts b/workbench/example/workflows/99_e2e.ts index 0f75f08d51..e22cf25b16 100644 --- a/workbench/example/workflows/99_e2e.ts +++ b/workbench/example/workflows/99_e2e.ts @@ -14,6 +14,7 @@ import { sleep, } from 'workflow'; import { getRun, start } from 'workflow/api'; +import { importedStepOnly } from './_imported_step_only.js'; import { callThrower, stepThatThrowsFromHelper } from './helpers.js'; ////////////////////////////////////////////////////////// @@ -86,6 +87,13 @@ export async function promiseAnyWorkflow() { ////////////////////////////////////////////////////////// +export async function importedStepOnlyWorkflow() { + 'use workflow'; + return await importedStepOnly(); +} + +////////////////////////////////////////////////////////// + // Name should not conflict with genStream in 3_streams.ts // TODO: swc transform should mangle names to avoid conflicts async function genReadableStream() { diff --git a/workbench/example/workflows/_imported_step_only.ts b/workbench/example/workflows/_imported_step_only.ts new file mode 100644 index 0000000000..5da436a95f --- /dev/null +++ b/workbench/example/workflows/_imported_step_only.ts @@ -0,0 +1,4 @@ +export async function importedStepOnly() { + 'use step'; + return 'imported-step-only-ok'; +} diff --git a/workbench/nextjs-turbopack/next.config.ts b/workbench/nextjs-turbopack/next.config.ts index 952bbe35f2..78df6b2090 100644 --- a/workbench/nextjs-turbopack/next.config.ts +++ b/workbench/nextjs-turbopack/next.config.ts @@ -1,12 +1,20 @@ import type { NextConfig } from 'next'; +import path from 'node:path'; import { withWorkflow } from 'workflow/next'; +const turbopackRoot = path.resolve(process.cwd(), '../..'); + const nextConfig: NextConfig = { /* config options here */ serverExternalPackages: ['@node-rs/xxhash'], + turbopack: { + // Keep Turbopack root aligned with repo root so @repo/* path aliases can + // resolve files outside the app directory in both monorepo and staged temp layouts. + root: turbopackRoot, + }, }; // export default nextConfig; export default withWorkflow(nextConfig, { - workflows: { lazyDiscovery: false }, + workflows: { lazyDiscovery: true }, }); diff --git a/workbench/nextjs-turbopack/workflows/_imported_step_only.ts b/workbench/nextjs-turbopack/workflows/_imported_step_only.ts new file mode 120000 index 0000000000..756b696826 --- /dev/null +++ b/workbench/nextjs-turbopack/workflows/_imported_step_only.ts @@ -0,0 +1 @@ +../../example/workflows/_imported_step_only.ts \ No newline at end of file diff --git a/workbench/nextjs-webpack/workflows/_imported_step_only.ts b/workbench/nextjs-webpack/workflows/_imported_step_only.ts new file mode 120000 index 0000000000..756b696826 --- /dev/null +++ b/workbench/nextjs-webpack/workflows/_imported_step_only.ts @@ -0,0 +1 @@ +../../example/workflows/_imported_step_only.ts \ No newline at end of file diff --git a/workbench/nitro-v3/workflows/_imported_step_only.ts b/workbench/nitro-v3/workflows/_imported_step_only.ts new file mode 120000 index 0000000000..756b696826 --- /dev/null +++ b/workbench/nitro-v3/workflows/_imported_step_only.ts @@ -0,0 +1 @@ +../../example/workflows/_imported_step_only.ts \ No newline at end of file diff --git a/workbench/sveltekit/src/workflows/_imported_step_only.ts b/workbench/sveltekit/src/workflows/_imported_step_only.ts new file mode 120000 index 0000000000..925496cd65 --- /dev/null +++ b/workbench/sveltekit/src/workflows/_imported_step_only.ts @@ -0,0 +1 @@ +../../../example/workflows/_imported_step_only.ts \ No newline at end of file