diff --git a/.changeset/stale-bushes-listen.md b/.changeset/stale-bushes-listen.md new file mode 100644 index 0000000000..67537aef6f --- /dev/null +++ b/.changeset/stale-bushes-listen.md @@ -0,0 +1,6 @@ +--- +"@workflow/builders": patch +"@workflow/next": patch +--- + +Add lazy workflow and step discovery in Next.js diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 17b53b8199..d4bbb8a88c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,6 +8,9 @@ on: - "!*" pull_request: +env: + NEXT_TELEMETRY_DISABLED: 1 + concurrency: # Unique group for this workflow and branch group: ${{ github.workflow }}-${{ github.ref }} @@ -534,12 +537,22 @@ jobs: - name: Run E2E Tests (Next.js) run: | cd workbench/nextjs-turbopack - $job = Start-Job -ScriptBlock { Set-Location $using:PWD; pnpm dev } + $job = Start-Job -ScriptBlock { Set-Location $using:PWD; pnpm dev *>&1 | Tee-Object -FilePath "$using:PWD\dev.log" } Start-Sleep -Seconds 15 cd ../.. - pnpm vitest run packages/core/e2e/dev.test.ts - pnpm run test:e2e --reporter=default --reporter=json --outputFile=e2e-windows-nextjs-turbopack.json - Stop-Job $job + + try { + pnpm run test:e2e --reporter=default --reporter=json --outputFile=e2e-windows-nextjs-turbopack.json + pnpm vitest run packages/core/e2e/dev.test.ts + } finally { + Stop-Job $job + Write-Host "`n=== Dev Server Logs ===`n" + if (Test-Path "workbench/nextjs-turbopack/dev.log") { + Get-Content "workbench/nextjs-turbopack/dev.log" + } else { + Write-Host "No dev.log file found" + } + } shell: powershell env: APP_NAME: "nextjs-turbopack" diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index 984fa3f1cc..e91be2bc5b 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -137,6 +137,12 @@ export abstract class BaseBuilder { discoveredSteps: string[]; discoveredWorkflows: string[]; }> { + if (this.config.buildTarget === 'next') { + return { + discoveredWorkflows: inputs, + discoveredSteps: inputs, + }; + } const previousResult = this.discoveredEntries.get(inputs); if (previousResult) { @@ -252,7 +258,11 @@ export abstract class BaseBuilder { } } - if (result.warnings && result.warnings.length > 0) { + if ( + this.config.buildTarget !== 'next' && + result.warnings && + result.warnings.length > 0 + ) { console.warn(`! esbuild warnings in ${phase}:`); for (const warning of result.warnings) { console.warn(` ${warning.text}`); @@ -314,9 +324,20 @@ export abstract class BaseBuilder { ); }); + const combinedStepFiles: string[] = [ + ...stepFiles, + ...(resolvedBuiltInSteps + ? [ + resolvedBuiltInSteps, + // TODO: expose this in workflow/package.json and use resolve? + join(dirname(resolvedBuiltInSteps), '../stdlib.js'), + ] + : []), + ]; + // Create a virtual entry that imports all files. All step definitions // will get registered thanks to the swc transform. - const imports = stepFiles + const imports = combinedStepFiles .map((file) => { // Normalize both paths to forward slashes before calling relative() // This is critical on Windows where relative() can produce unexpected results with mixed path formats @@ -366,6 +387,7 @@ export abstract class BaseBuilder { keepNames: true, minify: false, jsx: 'preserve', + logLevel: 'error', resolveExtensions: [ '.ts', '.tsx', @@ -381,12 +403,7 @@ export abstract class BaseBuilder { plugins: [ createSwcPlugin({ mode: 'step', - entriesToBundle: externalizeNonSteps - ? [ - ...stepFiles, - ...(resolvedBuiltInSteps ? [resolvedBuiltInSteps] : []), - ] - : undefined, + entriesToBundle: externalizeNonSteps ? combinedStepFiles : undefined, outdir: outfile ? dirname(outfile) : undefined, tsBaseUrl, tsPaths, diff --git a/packages/builders/src/index.ts b/packages/builders/src/index.ts index ef2f077dd6..2ee8716445 100644 --- a/packages/builders/src/index.ts +++ b/packages/builders/src/index.ts @@ -1,10 +1,16 @@ export type { WorkflowManifest } from './apply-swc-transform.js'; export { applySwcTransform } from './apply-swc-transform.js'; export { BaseBuilder } from './base-builder.js'; +export { createBuildQueue } from './build-queue.js'; export { createBaseBuilderConfig } from './config-helpers.js'; export { STEP_QUEUE_TRIGGER, WORKFLOW_QUEUE_TRIGGER } from './constants.js'; -export { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js'; +export { + createDiscoverEntriesPlugin, + useStepPattern, + useWorkflowPattern, +} from './discover-entries-esbuild-plugin.js'; export { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js'; +export { NORMALIZE_REQUEST_CODE } from './request-converter.js'; export { StandaloneBuilder } from './standalone.js'; export { createSwcPlugin } from './swc-esbuild-plugin.js'; export type { @@ -18,5 +24,3 @@ export type { } from './types.js'; export { isValidBuildTarget, validBuildTargets } from './types.js'; export { VercelBuildOutputAPIBuilder } from './vercel-build-output-api.js'; -export { createBuildQueue } from './build-queue.js'; -export { NORMALIZE_REQUEST_CODE } from './request-converter.js'; diff --git a/packages/core/e2e/build-errors.test.ts b/packages/core/e2e/build-errors.test.ts index 43cc6518d7..af500d6e0c 100644 --- a/packages/core/e2e/build-errors.test.ts +++ b/packages/core/e2e/build-errors.test.ts @@ -16,6 +16,8 @@ const exec = promisify(execOriginal); */ describe('build error messages', () => { const restoreFiles: Array<{ path: string; content: string | null }> = []; + const appName = process.env.APP_NAME ?? 'nextjs-turbopack'; + const appPath = getWorkbenchAppPath(appName); afterEach(async () => { // Restore files in reverse order to handle dependencies @@ -27,6 +29,8 @@ describe('build error messages', () => { } } restoreFiles.length = 0; + // previous failures can cause successive tests to fail + await fs.rm(path.join(appPath, '.next'), { recursive: true, force: true }); }); /** @@ -89,11 +93,8 @@ describe('build error messages', () => { test( 'should show helpful error when using Node.js module in workflow', - { timeout: 120_000 }, + { timeout: 60_000 }, async () => { - const appName = process.env.APP_NAME ?? 'nextjs-turbopack'; - const appPath = getWorkbenchAppPath(appName); - // Note: filename must NOT start with _ (those are skipped by registry generator) const badWorkflowContent = ` import { readFileSync } from 'fs'; @@ -138,10 +139,8 @@ export async function nodeModuleViolationWorkflow() { process.env.APP_NAME && process.env.APP_NAME !== 'nextjs-turbopack' )( 'should show top-level package name for external dependencies that use Node.js modules', - { timeout: 120_000 }, + { timeout: 60_000 }, async () => { - const appPath = getWorkbenchAppPath('nextjs-turbopack'); - // @vercel/blob internally uses Node.js modules (via undici) // The error should show "@vercel/blob" not the internal Node.js module const badWorkflowContent = ` @@ -183,11 +182,8 @@ export async function blobViolationWorkflow() { test( 'should show helpful error when using Bun module in workflow', - { timeout: 120_000 }, + { timeout: 60_000 }, async () => { - const appName = process.env.APP_NAME ?? 'nextjs-turbopack'; - const appPath = getWorkbenchAppPath(appName); - // Bun modules should show a different error message than Node.js modules const badWorkflowContent = ` import { serve } from 'bun'; @@ -223,11 +219,8 @@ export async function bunViolationWorkflow() { test( 'should report all violations when multiple Node.js modules are used', - { timeout: 120_000 }, + { timeout: 60_000 }, async () => { - const appName = process.env.APP_NAME ?? 'nextjs-turbopack'; - const appPath = getWorkbenchAppPath(appName); - // Using multiple Node.js modules should report errors for all of them const badWorkflowContent = ` import { readFileSync } from 'fs'; diff --git a/packages/core/e2e/dev.test.ts b/packages/core/e2e/dev.test.ts index 824c807933..c059ea3a4b 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, describe, expect, test } from 'vitest'; import { getWorkbenchAppPath } from './utils'; @@ -44,6 +44,14 @@ export function createDevTests(config?: DevTestConfig) { const workflowsDir = finalConfig.workflowsDir ?? 'workflows'; const restoreFiles: Array<{ path: string; content: string }> = []; + const warmEndpoint = async () => { + // Warm up the Next.js routes to trigger lazy workflow/step discovery and compilation. + // This is only required for tests that respond to file updates (HMR tests). + // Without this, the routes won't be built yet and file change detection won't work. + await fetch(new URL('/api/trigger', process.env.DEPLOYMENT_URL)); + await fetch(new URL('/api/chat', process.env.DEPLOYMENT_URL)); + }; + afterEach(async () => { await Promise.all( restoreFiles.map(async (item) => { @@ -54,30 +62,32 @@ export function createDevTests(config?: DevTestConfig) { } }) ); + await warmEndpoint(); restoreFiles.length = 0; }); - test('should rebuild on workflow change', { timeout: 30_000 }, async () => { - const workflowFile = path.join(appPath, workflowsDir, testWorkflowFile); + test('should rebuild on step change', { timeout: 15_000 }, async () => { + const stepFile = path.join(appPath, workflowsDir, testWorkflowFile); - const content = await fs.readFile(workflowFile, 'utf8'); + const content = await fs.readFile(stepFile, 'utf8'); await fs.writeFile( - workflowFile, + stepFile, `${content} -export async function myNewWorkflow() { - 'use workflow' +export async function myNewStep() { + 'use step' return 'hello world' } ` ); - restoreFiles.push({ path: workflowFile, content }); + restoreFiles.push({ path: stepFile, content }); while (true) { try { - const workflowContent = await fs.readFile(generatedWorkflow, 'utf8'); - expect(workflowContent).toContain('myNewWorkflow'); + await warmEndpoint(); + const workflowContent = await fs.readFile(generatedStep, 'utf8'); + expect(workflowContent).toContain('myNewStep'); break; } catch (_) { await new Promise((res) => setTimeout(res, 1_000)); @@ -85,27 +95,28 @@ export async function myNewWorkflow() { } }); - test('should rebuild on step change', { timeout: 30_000 }, async () => { - const stepFile = path.join(appPath, workflowsDir, testWorkflowFile); + test('should rebuild on workflow change', { timeout: 15_000 }, async () => { + const workflowFile = path.join(appPath, workflowsDir, testWorkflowFile); - const content = await fs.readFile(stepFile, 'utf8'); + const content = await fs.readFile(workflowFile, 'utf8'); await fs.writeFile( - stepFile, + workflowFile, `${content} -export async function myNewStep() { - 'use step' +export async function myNewWorkflow() { + 'use workflow' return 'hello world' } ` ); - restoreFiles.push({ path: stepFile, content }); + restoreFiles.push({ path: workflowFile, content }); while (true) { try { - const workflowContent = await fs.readFile(generatedStep, 'utf8'); - expect(workflowContent).toContain('myNewStep'); + await warmEndpoint(); + const workflowContent = await fs.readFile(generatedWorkflow, 'utf8'); + expect(workflowContent).toContain('myNewWorkflow'); break; } catch (_) { await new Promise((res) => setTimeout(res, 1_000)); @@ -115,7 +126,7 @@ export async function myNewStep() { test( 'should rebuild on adding workflow file', - { timeout: 30_000 }, + { timeout: 15_000 }, async () => { const workflowFile = path.join( appPath, @@ -131,11 +142,11 @@ export async function myNewStep() { } ` ); - restoreFiles.push({ path: workflowFile, content: '' }); const apiFile = path.join(appPath, finalConfig.apiFilePath); - const apiFileContent = await fs.readFile(apiFile, 'utf8'); + restoreFiles.push({ path: apiFile, content: apiFileContent }); + restoreFiles.push({ path: workflowFile, content: '' }); await fs.writeFile( apiFile, @@ -145,6 +156,7 @@ ${apiFileContent}` while (true) { try { + await warmEndpoint(); const workflowContent = await fs.readFile( generatedWorkflow, 'utf8' diff --git a/packages/core/e2e/local-build.test.ts b/packages/core/e2e/local-build.test.ts index b8f01e858f..e27ba4323c 100644 --- a/packages/core/e2e/local-build.test.ts +++ b/packages/core/e2e/local-build.test.ts @@ -25,8 +25,12 @@ describe.each([ const result = await exec('pnpm build', { cwd: getWorkbenchAppPath(project), + timeout: 120_000, }); + console.log(result.stdout); + console.log(result.stderr); + expect(result.stderr).not.toContain('Error:'); }); }); diff --git a/packages/next/package.json b/packages/next/package.json index 639e09fdd2..8d8b98db61 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -18,8 +18,7 @@ }, "exports": { ".": "./dist/index.js", - "./loader": "./dist/loader.js", - "./runtime": "./dist/runtime.js" + "./loader": "./dist/loader.js" }, "scripts": { "build": "tsc", @@ -30,15 +29,11 @@ "@swc/core": "catalog:", "@workflow/builders": "workspace:*", "@workflow/core": "workspace:*", - "@workflow/swc-plugin": "workspace:*", - "semver": "7.7.3", - "watchpack": "2.4.4" + "@workflow/swc-plugin": "workspace:*" }, "devDependencies": { "@workflow/tsconfig": "workspace:*", "@types/node": "catalog:", - "@types/semver": "7.7.1", - "@types/watchpack": "2.4.4", "next": "16.0.10" }, "peerDependencies": { diff --git a/packages/next/src/builder.ts b/packages/next/src/builder.ts index 660c0bfc86..258c73da64 100644 --- a/packages/next/src/builder.ts +++ b/packages/next/src/builder.ts @@ -1,7 +1,13 @@ import { constants } from 'node:fs'; -import { access, mkdir, stat, writeFile } from 'node:fs/promises'; -import { extname, join, resolve } from 'node:path'; -import Watchpack from 'watchpack'; +import { access, mkdir, readFile, stat, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import { join, resolve } from 'node:path'; +import type { NextConfig } from 'next'; +import { + createSocketServer, + type SocketIO, + type SocketServerConfig, +} from './socket-server'; let CachedNextBuilder: any; @@ -17,13 +23,74 @@ export async function getNextBuilder() { BaseBuilder: BaseBuilderClass, STEP_QUEUE_TRIGGER, WORKFLOW_QUEUE_TRIGGER, - // biome-ignore lint/security/noGlobalEval: Need to use eval here to avoid TypeScript from transpiling the import statement into `require()` - } = (await eval( - 'import("@workflow/builders")' - )) as typeof import('@workflow/builders'); + } = await import('@workflow/builders'); class NextBuilder extends BaseBuilderClass { - async build() { + private socketIO?: SocketIO; + private isDevServer?: boolean; + private nextConfig?: NextConfig; + + private getDistDir(): string { + return this.nextConfig?.distDir || '.next'; + } + + private async writeWorkflowsCache( + workflowFiles: Set, + stepFiles: Set + ) { + const cwd = this.config.workingDir; + const distDir = this.getDistDir(); + const cacheDir = join(cwd, distDir, 'cache'); + const cacheFile = join(cacheDir, 'workflows.json'); + + try { + await mkdir(cacheDir, { recursive: true }); + const cacheData = { + workflowFiles: Array.from(workflowFiles), + stepFiles: Array.from(stepFiles), + timestamp: Date.now(), + }; + await writeFile(cacheFile, JSON.stringify(cacheData, null, 2)); + } catch (error) { + console.error('Failed to write workflows cache:', error); + } + } + + private async readWorkflowsCache(): Promise<{ + workflowFiles: string[]; + stepFiles: string[]; + } | null> { + const cwd = this.config.workingDir; + const distDir = this.getDistDir(); + const cacheFile = join(cwd, distDir, 'cache', 'workflows.json'); + + try { + const cacheContent = await readFile(cacheFile, 'utf-8'); + const cacheData = JSON.parse(cacheContent); + return { + workflowFiles: cacheData.workflowFiles || [], + stepFiles: cacheData.stepFiles || [], + }; + } catch { + // Cache file doesn't exist or is invalid, return null + return null; + } + } + + async init(nextConfig: NextConfig, phase: string) { + this.nextConfig = nextConfig; + this.isDevServer = phase === 'phase-development-server'; + + const outputDir = await this.findAppDirectory(); + + // Write stub files + await this.writeStubFiles(outputDir); + + // Create socket server for file path communication + await this.createSocketServer(outputDir); + } + + async build(inputFiles?: string[]) { const outputDir = await this.findAppDirectory(); const workflowGeneratedDir = join(outputDir, '.well-known/workflow/v1'); @@ -33,308 +100,25 @@ export async function getNextBuilder() { await writeFile(join(workflowGeneratedDir, '.gitignore'), '*'); - const inputFiles = await this.getInputFiles(); + // Use provided inputFiles or discover them + const files = inputFiles || (await this.getInputFiles()); const tsConfig = await this.getTsConfigOptions(); const options = { - inputFiles, + inputFiles: files, workflowGeneratedDir, tsBaseUrl: tsConfig.baseUrl, tsPaths: tsConfig.paths, }; - const stepsBuildContext = await this.buildStepsFunction(options); - const workflowsBundle = await this.buildWorkflowsFunction(options); + await this.buildStepsFunction(options); + await this.buildWorkflowsFunction(options); await this.buildWebhookRoute({ workflowGeneratedDir }); await this.writeFunctionsConfig(outputDir); - if (this.config.watch) { - if (!stepsBuildContext) { - throw new Error( - 'Invariant: expected steps build context in watch mode' - ); - } - if (!workflowsBundle) { - throw new Error('Invariant: expected workflows bundle in watch mode'); - } - - let stepsCtx = stepsBuildContext; - let workflowsCtx = workflowsBundle; - - const normalizePath = (pathname: string) => - pathname.replace(/\\/g, '/'); - const knownFiles = new Set(); - type WatchpackTimeInfoEntry = { - safeTime: number; - timestamp?: number; - }; - let previousTimeInfo = new Map(); - - const watchableExtensions = new Set([ - '.js', - '.jsx', - '.ts', - '.tsx', - '.mts', - '.cts', - '.cjs', - '.mjs', - ]); - const ignoredPathFragments = [ - '/.git/', - '/node_modules/', - '/.next/', - '/.turbo/', - '/.vercel/', - '/dist/', - '/build/', - '/out/', - '/.cache/', - '/.yarn/', - '/.pnpm-store/', - '/.parcel-cache/', - '/.well-known/workflow/', - ]; - const normalizedGeneratedDir = workflowGeneratedDir.replace(/\\/g, '/'); - ignoredPathFragments.push(normalizedGeneratedDir); - - // There is a node.js bug on MacOS which causes closing file watchers to be really slow. - // This limits the number of watchers to mitigate the issue. - // https://github.com/nodejs/node/issues/29949 - process.env.WATCHPACK_WATCHER_LIMIT = - process.platform === 'darwin' ? '20' : undefined; - - const watcher = new Watchpack({ - // Watchpack default is 200ms which adds 200ms of dead time on bootup. - aggregateTimeout: 5, - ignored: (pathname: string) => { - const normalizedPath = pathname.replace(/\\/g, '/'); - const extension = extname(normalizedPath); - if (extension && !watchableExtensions.has(extension)) { - return true; - } - if (normalizedPath.startsWith(normalizedGeneratedDir)) { - return true; - } - for (const fragment of ignoredPathFragments) { - if (normalizedPath.includes(fragment)) { - return true; - } - } - return false; - }, - }); - - const readTimeInfoEntries = () => { - const rawEntries = watcher.getTimeInfoEntries() as Map< - string, - WatchpackTimeInfoEntry - >; - const normalizedEntries = new Map(); - for (const [path, info] of rawEntries) { - normalizedEntries.set(normalizePath(path), info); - } - return normalizedEntries; - }; - - let rebuildQueue = Promise.resolve(); - - const enqueue = (task: () => Promise) => { - rebuildQueue = rebuildQueue.then(task).catch((error) => { - console.error('Failed to process file change', error); - }); - return rebuildQueue; - }; - - const fullRebuild = async () => { - const newInputFiles = await this.getInputFiles(); - options.inputFiles = newInputFiles; - - await stepsCtx.dispose(); - const newStepsCtx = await this.buildStepsFunction(options); - if (!newStepsCtx) { - throw new Error( - 'Invariant: expected steps build context after rebuild' - ); - } - stepsCtx = newStepsCtx; - - await workflowsCtx.interimBundleCtx.dispose(); - const newWorkflowsCtx = await this.buildWorkflowsFunction(options); - if (!newWorkflowsCtx) { - throw new Error( - 'Invariant: expected workflows bundle context after rebuild' - ); - } - workflowsCtx = newWorkflowsCtx; - }; - - const logBuildMessages = ( - result: { - errors?: import('esbuild').Message[]; - warnings?: import('esbuild').Message[]; - }, - label: string - ) => { - const logByType = ( - messages: import('esbuild').Message[] | undefined, - method: 'error' | 'warn' - ) => { - if (!messages || messages.length === 0) { - return; - } - const descriptor = method === 'error' ? 'errors' : 'warnings'; - console[method](`${descriptor} while rebuilding ${label}`); - for (const message of messages) { - console[method](message); - } - }; - - logByType(result.errors, 'error'); - logByType(result.warnings, 'warn'); - }; - - const rebuildExistingFiles = async () => { - const rebuiltStepStart = Date.now(); - const stepsResult = await stepsCtx.rebuild(); - logBuildMessages(stepsResult, 'steps bundle'); - console.log( - 'Rebuilt steps bundle', - `${Date.now() - rebuiltStepStart}ms` - ); - - const rebuiltWorkflowStart = Date.now(); - const workflowResult = await workflowsCtx.interimBundleCtx.rebuild(); - logBuildMessages(workflowResult, 'workflows bundle'); - - if ( - !workflowResult.outputFiles || - workflowResult.outputFiles.length === 0 - ) { - console.error( - 'No output generated while rebuilding workflows bundle' - ); - return; - } - await workflowsCtx.bundleFinal(workflowResult.outputFiles[0].text); - console.log( - 'Rebuilt workflow bundle', - `${Date.now() - rebuiltWorkflowStart}ms` - ); - }; - - const isWatchableFile = (path: string) => - watchableExtensions.has(extname(path)); - - const getComparableTimestamp = (entry: WatchpackTimeInfoEntry) => - entry.timestamp ?? entry.safeTime; - - const findRemovedFiles = ( - currentEntries: Map, - previousEntries: Map - ) => { - const removed: string[] = []; - for (const path of previousEntries.keys()) { - if (!currentEntries.has(path) && isWatchableFile(path)) { - removed.push(path); - } - } - return removed; - }; - - const findAddedAndModifiedFiles = ( - currentEntries: Map, - previousEntries: Map - ) => { - const added: string[] = []; - const modified: string[] = []; - - for (const [path, info] of currentEntries) { - if (!isWatchableFile(path)) { - continue; - } - - const previous = previousEntries.get(path); - if (!previous) { - added.push(path); - continue; - } - - if ( - getComparableTimestamp(info) !== getComparableTimestamp(previous) - ) { - modified.push(path); - } - } - - return { added, modified }; - }; - - const determineFileChanges = ( - currentEntries: Map, - previousEntries: Map - ) => { - const removedFiles = findRemovedFiles( - currentEntries, - previousEntries - ); - const { added, modified } = findAddedAndModifiedFiles( - currentEntries, - previousEntries - ); - - return { - addedFiles: added, - modifiedFiles: modified, - removedFiles, - }; - }; - - let isInitial = true; - - watcher.on('aggregated', () => { - const currentEntries = readTimeInfoEntries(); - const { addedFiles, modifiedFiles, removedFiles } = - determineFileChanges(currentEntries, previousTimeInfo); - - previousTimeInfo = currentEntries; - - if (isInitial) { - isInitial = false; - return; - } - - if ( - addedFiles.length === 0 && - modifiedFiles.length === 0 && - removedFiles.length === 0 - ) { - return; - } - - for (const removal of removedFiles) { - knownFiles.delete(removal); - } - for (const added of addedFiles) { - knownFiles.add(added); - } - - enqueue(async () => { - if (addedFiles.length > 0 || removedFiles.length > 0) { - await fullRebuild(); - return; - } - - if (modifiedFiles.length > 0) { - await rebuildExistingFiles(); - } - }); - }); - - watcher.watch({ - directories: [this.config.workingDir], - startTime: 0, - }); + // Signal build complete to connected clients + if (this.socketIO) { + this.socketIO.emit('build-complete'); } } @@ -350,7 +134,7 @@ export async function getNextBuilder() { private async writeFunctionsConfig(outputDir: string) { // we don't run this in development mode as it's not needed - if (process.env.NODE_ENV === 'development') { + if (this.isDevServer) { return; } const generatedConfig = { @@ -467,6 +251,142 @@ export async function getNextBuilder() { } } } + + private async createSocketServer(_usersAppDir: string): Promise { + if (process.env.WORKFLOW_SOCKET_PORT) { + return; + } + + const workflowFiles = new Set(); + const stepFiles = new Set(); + let debounceTimer: NodeJS.Timeout | null = null; + let buildTriggered = false; + const BUILD_DEBOUNCE_MS = this.isDevServer ? 500 : 2_000; + + // Attempt to load cached workflows/steps from previous build + const cache = await this.readWorkflowsCache(); + if (cache) { + for (const file of cache.workflowFiles) { + workflowFiles.add(file); + } + for (const file of cache.stepFiles) { + stepFiles.add(file); + } + } + + // Debounced build trigger + const triggerBuild = () => { + if (debounceTimer) { + clearTimeout(debounceTimer); + } + + debounceTimer = setTimeout(async () => { + if (buildTriggered && !this.isDevServer) { + // can't run another build after one has already been done + // in production mode as it won't have any affect since after + // the first is done we resolve the loaders for the stub entries + // and they can't be refreshed/rebuilt after that in production + return; + } + + // Combine workflow and step files into single array + const allFiles = new Set([...workflowFiles, ...stepFiles]); + const inputFiles = Array.from(allFiles); + + try { + buildTriggered = true; + await this.build(inputFiles); + // Write cache after successful build + await this.writeWorkflowsCache(workflowFiles, stepFiles); + } catch (error) { + if (!this.isDevServer) { + throw error; + } + console.error('Workflows build failed:', error); + } + }, BUILD_DEBOUNCE_MS); + }; + + // Configure and create socket server + const config: SocketServerConfig = { + isDevServer: this.isDevServer || false, + onFileDiscovered: ( + filePath: string, + hasWorkflow: boolean, + hasStep: boolean + ) => { + const knownFile = + workflowFiles.has(filePath) || stepFiles.has(filePath); + + if (hasWorkflow) { + workflowFiles.add(filePath); + } else { + workflowFiles.delete(filePath); + } + + if (hasStep) { + stepFiles.add(filePath); + } else { + stepFiles.delete(filePath); + } + + // Trigger debounced build if the file was previously seen + // or has workflows/steps currently + if ( + // in non-dev we always update debounce on activity + !this.isDevServer || + hasWorkflow || + hasStep || + knownFile + ) { + triggerBuild(); + } + }, + onTriggerBuild: triggerBuild, + }; + + this.socketIO = await createSocketServer(config); + } + + private async writeStubFiles(usersAppDir: string): Promise { + // NOTE: there is a limitation with turbopack that we can only + // have number of virtual entries with pending promise less than + // CPU count as that's the number of workers it uses so currently + // we're fine with > 3 vCPU but <= 3 vCPUs and we won't be able to + // discover workflows/steps + const parallelismCount = os.availableParallelism(); + if (process.env.TURBOPACK && parallelismCount < 4) { + console.warn( + `Available parallelism of ${parallelismCount} is less than needed 4. This can cause workflows/steps to fail to discover properly in turbopack` + ); + } + + const routeStubContent = "export * from './inner'"; + // this needs to change on each build so can refresh workflows + const innerStubContent = `WORKFLOW_INNER_STUB_FILE_${Date.now()}`; + const workflowDir = join(usersAppDir, '.well-known/workflow/v1'); + + // Ensure directories exist + await mkdir(join(workflowDir, 'flow'), { recursive: true }); + await mkdir(join(workflowDir, 'step'), { recursive: true }); + await mkdir(join(workflowDir, 'webhook/[token]'), { recursive: true }); + + // Write route.ts stub files (re-export from inner) + await writeFile(join(workflowDir, 'flow/route.js'), routeStubContent); + await writeFile(join(workflowDir, 'step/route.js'), routeStubContent); + await writeFile( + join(workflowDir, 'webhook/[token]/route.js'), + routeStubContent + ); + + // Write inner.js stub files (actual stub marker) + await writeFile(join(workflowDir, 'flow/inner.js'), innerStubContent); + await writeFile(join(workflowDir, 'step/inner.js'), innerStubContent); + await writeFile( + join(workflowDir, 'webhook/[token]/inner.js'), + innerStubContent + ); + } } CachedNextBuilder = NextBuilder; diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index cd24924d20..a9fa593452 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -1,5 +1,4 @@ import type { NextConfig } from 'next'; -import semver from 'semver'; import { getNextBuilder } from './builder.js'; export function withWorkflow( @@ -59,8 +58,6 @@ export function withWorkflow( nextConfig.turbopack.rules = {}; } const existingRules = nextConfig.turbopack.rules as any; - const nextVersion = require('next/package.json').version; - const supportsTurboCondition = semver.gte(nextVersion, 'v16.0.0'); for (const key of [ '*.tsx', @@ -73,23 +70,17 @@ export function withWorkflow( '*.cts', ]) { nextConfig.turbopack.rules[key] = { - ...(supportsTurboCondition - ? { - condition: { - ...existingRules[key]?.condition, - any: [ - ...(existingRules[key]?.condition.any || []), - { - content: /(use workflow|use step)/, - }, - ], - }, - } - : {}), loaders: [...(existingRules[key]?.loaders || []), loaderPath], }; } + if (process.env.TURBOPACK) { + if (!nextConfig.experimental) { + nextConfig.experimental = {}; + } + nextConfig.experimental.turbopackScopeHoisting = false; + } + // configure the loader for webpack const existingWebpackModify = nextConfig.webpack; nextConfig.webpack = (...args) => { @@ -117,10 +108,8 @@ export function withWorkflow( !process.env.WORKFLOW_NEXT_PRIVATE_BUILT && phase !== 'phase-production-server' ) { - const shouldWatch = process.env.NODE_ENV === 'development'; const NextBuilder = await getNextBuilder(); const workflowBuilder = new NextBuilder({ - watch: shouldWatch, // discover workflows from pages/app entries dirs: ['pages', 'app', 'src/pages', 'src/app'], workingDir: process.cwd(), @@ -131,7 +120,7 @@ export function withWorkflow( externalPackages: [...(nextConfig.serverExternalPackages || [])], }); - await workflowBuilder.build(); + await workflowBuilder.init(nextConfig, phase); process.env.WORKFLOW_NEXT_PRIVATE_BUILT = '1'; } diff --git a/packages/next/src/loader.ts b/packages/next/src/loader.ts index e6aca8cb4c..e4927ad981 100644 --- a/packages/next/src/loader.ts +++ b/packages/next/src/loader.ts @@ -1,5 +1,187 @@ +import { readFile } from 'node:fs/promises'; +import { connect, type Socket } from 'node:net'; import { relative } from 'node:path'; import { transform } from '@swc/core'; +import { useStepPattern, useWorkflowPattern } from '@workflow/builders'; +import { + parseMessage, + type SocketMessage, + serializeMessage, +} from './socket-server'; + +// Stub content written by builder to inner.js files +const STUB_CONTENT = 'WORKFLOW_INNER_STUB_FILE'; + +// Cache for socket connection to avoid reconnecting on every file +let socketClientPromise: Promise | null = null; + +async function getSocketClient() { + if (!socketClientPromise) { + socketClientPromise = (async () => { + 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' }); + + // Wait for connection + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + socket.destroy(); + reject(new Error('Socket connection timeout')); + }, 1000); + + socket.on('connect', () => { + socket.setNoDelay(true); + clearTimeout(timeout); + resolve(); + }); + + socket.on('error', (err: Error) => { + clearTimeout(timeout); + reject(err); + }); + }); + + return socket; + })(); + } + + return socketClientPromise; +} + +async function notifySocketServer( + filename: string, + hasWorkflow: boolean, + hasStep: boolean +) { + const socket = await getSocketClient(); + if (!socket) { + 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` + ); + } + + // Send authenticated message with workflow and step information + const message: SocketMessage = { + type: 'file-discovered', + filePath: filename, + hasWorkflow, + hasStep, + }; + socket.write(serializeMessage(message, authToken)); +} + +async function waitForBuildComplete(): Promise { + const socket = await getSocketClient(); + + return new Promise((resolve, reject) => { + if (!socket) { + reject(new Error('Socket not available')); + return; + } + + let buffer = ''; + let timeout: NodeJS.Timeout | null = null; + let settled = false; + + const cleanup = () => { + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + socket.off('data', onData); + socket.off('error', onError); + socket.off('end', onEnd); + socket.off('close', onClose); + }; + + const settle = (callback: () => void) => { + if (!settled) { + settled = true; + cleanup(); + callback(); + } + }; + + const onData = (data: Buffer) => { + buffer += data.toString(); + + const authToken = process.env.WORKFLOW_SOCKET_AUTH; + if (!authToken) { + settle(() => reject(new Error('No socket auth token available'))); + return; + } + + let newlineIndex = buffer.indexOf('\n'); + while (newlineIndex !== -1) { + const line = buffer.slice(0, newlineIndex); + buffer = buffer.slice(newlineIndex + 1); + newlineIndex = buffer.indexOf('\n'); + + const message = parseMessage(line, authToken); + if (message && message.type === 'build-complete') { + settle(() => resolve()); + } + } + }; + + const onError = (err: Error) => { + settle(() => reject(new Error(`Socket error: ${err.message}`))); + }; + + const onEnd = () => { + settle(() => + reject( + new Error( + 'Socket ended unexpectedly before build-complete message received' + ) + ) + ); + }; + + const onClose = () => { + settle(() => + reject( + new Error( + 'Socket closed unexpectedly before build-complete message received' + ) + ) + ); + }; + + // Set timeout to prevent indefinite hanging + timeout = setTimeout(() => { + settle(() => + reject( + new Error( + 'Timeout waiting for build-complete message (60 seconds elapsed)' + ) + ) + ); + }, 60000); // 60 second timeout + + socket.on('data', onData); + socket.on('error', onError); + socket.on('end', onEnd); + socket.on('close', onClose); + }); +} // This loader applies the "use workflow"/"use step" // client transformation @@ -13,8 +195,40 @@ export default async function workflowLoader( const filename = this.resourcePath; const normalizedSource = source.toString(); + // Normalize path separators for cross-platform compatibility + const normalizedFilename = filename.replace(/\\/g, '/'); + + // Check if this is a .well-known workflow inner.js file with stub content + const isWellKnownInnerFile = + normalizedFilename.includes('.well-known/workflow/v1/') && + (normalizedFilename.includes('/flow/inner.js') || + normalizedFilename.includes('/step/inner.js') || + normalizedFilename.includes('/webhook/[token]/inner.js')); + + if ( + isWellKnownInnerFile && + normalizedSource.trim().startsWith(STUB_CONTENT) + ) { + // Wait for build to complete + await waitForBuildComplete(); + + // Read the actual generated file content + const actualContent = await readFile( + filename.replace(/inner\.js$/, 'route.js'), + 'utf-8' + ); + return actualContent; + } + + // Check for workflow and step directives + const hasWorkflow = useWorkflowPattern.test(normalizedSource); + const hasStep = useStepPattern.test(normalizedSource); + + // Send message to socket server if workflow or step detected + await notifySocketServer(filename, hasWorkflow, hasStep); + // only apply the transform if file needs it - if (!normalizedSource.match(/(use step|use workflow)/)) { + if (!hasWorkflow && !hasStep) { return normalizedSource; } @@ -37,7 +251,7 @@ export default async function workflowLoader( const lowerPath = normalizedFilepath.toLowerCase(); let relativeFilename: string; - if (lowerPath.startsWith(lowerWd + '/')) { + if (lowerPath.startsWith(`${lowerWd}/`)) { // File is under working directory - manually calculate relative path relativeFilename = normalizedFilepath.substring( normalizedWorkingDir.length + 1 diff --git a/packages/next/src/runtime.ts b/packages/next/src/runtime.ts deleted file mode 100644 index 13e4a77f59..0000000000 --- a/packages/next/src/runtime.ts +++ /dev/null @@ -1,4 +0,0 @@ -// re-export runtime as stub for resolving to not -// require @workflow/core be a dependency as well as -// @workflow/next -export * from '@workflow/core/dist/runtime'; diff --git a/packages/next/src/socket-server.ts b/packages/next/src/socket-server.ts new file mode 100644 index 0000000000..bb6fbc92f4 --- /dev/null +++ b/packages/next/src/socket-server.ts @@ -0,0 +1,191 @@ +import { randomBytes } from 'node:crypto'; +import { createServer, type Server, type Socket } from 'node:net'; + +/** + * Magic preamble that must prefix all messages to authenticate them as workflow messages. + * This prevents accidental processing of messages from port scanners or other local processes. + */ +const MESSAGE_PREAMBLE = 'WF:'; + +/** + * Generate a random authentication token for this server session. + * Clients must include this token in all messages. + */ +function generateAuthToken(): string { + return randomBytes(16).toString('hex'); +} + +/** + * Message types that can be sent between loader and builder + */ +export type SocketMessage = + | { + type: 'file-discovered'; + filePath: string; + hasWorkflow: boolean; + hasStep: boolean; + } + | { type: 'trigger-build' } + | { type: 'build-complete' }; + +/** + * Configuration for the socket server + */ +export interface SocketServerConfig { + isDevServer: boolean; + onFileDiscovered: ( + filePath: string, + hasWorkflow: boolean, + hasStep: boolean + ) => void; + onTriggerBuild: () => void; +} + +/** + * Interface for the socket IO instance returned by createSocketServer + */ +export interface SocketIO { + emit(event: 'build-complete'): void; + getAuthToken(): string; +} + +/** + * Serialize a message with authentication preamble + */ +export function serializeMessage( + message: SocketMessage, + authToken: string +): string { + return `${MESSAGE_PREAMBLE}${authToken}:${JSON.stringify(message)}\n`; +} + +/** + * Parse and authenticate a message from the socket + * Returns the parsed message if valid, null otherwise + */ +export function parseMessage( + line: string, + authToken: string +): SocketMessage | null { + const trimmed = line.trim(); + if (!trimmed) { + return null; + } + + // Check for preamble + if (!trimmed.startsWith(MESSAGE_PREAMBLE)) { + console.warn('Received message without valid preamble, ignoring'); + return null; + } + + // Extract auth token and payload + const withoutPreamble = trimmed.slice(MESSAGE_PREAMBLE.length); + const colonIndex = withoutPreamble.indexOf(':'); + if (colonIndex === -1) { + console.warn('Received message without auth token separator, ignoring'); + return null; + } + + const messageToken = withoutPreamble.slice(0, colonIndex); + const payload = withoutPreamble.slice(colonIndex + 1); + + // Verify auth token + if (messageToken !== authToken) { + console.warn('Received message with invalid auth token, ignoring'); + return null; + } + + // Parse JSON payload + try { + return JSON.parse(payload) as SocketMessage; + } catch (error) { + console.error('Failed to parse socket message JSON:', error); + return null; + } +} + +/** + * Create a TCP socket server for loader<->builder communication. + * Returns a SocketIO interface for broadcasting messages and the auth token. + * + * SECURITY: Server listens on 127.0.0.1 (localhost only) and uses + * message authentication to prevent processing of unauthorized messages. + */ +export async function createSocketServer( + config: SocketServerConfig +): Promise { + const authToken = generateAuthToken(); + const clients = new Set(); + let buildTriggered = false; + + const server: Server = createServer((socket: Socket) => { + socket.setNoDelay(true); + clients.add(socket); + + // Send build-complete if build already finished (production mode) + if (buildTriggered && !config.isDevServer) { + socket.write(serializeMessage({ type: 'build-complete' }, authToken)); + } + + let buffer = ''; + + socket.on('data', (data: Buffer) => { + buffer += data.toString(); + + // Process complete messages (newline-delimited) + let newlineIndex = buffer.indexOf('\n'); + while (newlineIndex !== -1) { + const line = buffer.slice(0, newlineIndex); + buffer = buffer.slice(newlineIndex + 1); + newlineIndex = buffer.indexOf('\n'); + + const message = parseMessage(line, authToken); + if (!message) { + continue; + } + + if (message.type === 'file-discovered') { + config.onFileDiscovered( + message.filePath, + message.hasWorkflow, + message.hasStep + ); + } else if (message.type === 'trigger-build') { + config.onTriggerBuild(); + } + } + }); + + socket.on('end', () => { + clients.delete(socket); + }); + + socket.on('error', (err: Error) => { + console.error('Socket error:', err); + clients.delete(socket); + }); + }); + + // Listen on random available port (localhost only) + await new Promise((resolve) => { + 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; + } + resolve(); + }); + }); + + return { + emit: (_event: 'build-complete') => { + buildTriggered = true; + const message = serializeMessage({ type: 'build-complete' }, authToken); + for (const client of clients) { + client.write(message); + } + }, + getAuthToken: () => authToken, + }; +} diff --git a/packages/next/tsconfig.json b/packages/next/tsconfig.json index 1397247ef5..615e7d950a 100644 --- a/packages/next/tsconfig.json +++ b/packages/next/tsconfig.json @@ -3,8 +3,8 @@ "compilerOptions": { "outDir": "dist", "target": "es2022", - "module": "commonjs", - "moduleResolution": "node" + "module": "nodenext", + "moduleResolution": "nodenext" }, "include": ["src"], "exclude": ["node_modules", "**/*.test.ts"] diff --git a/packages/nitro/src/builders.ts b/packages/nitro/src/builders.ts index 8b36d24d14..d33e802b87 100644 --- a/packages/nitro/src/builders.ts +++ b/packages/nitro/src/builders.ts @@ -40,7 +40,7 @@ export class LocalBuilder extends BaseBuilder { watch: nitro.options.dev, dirs: ['.'], // Different apps that use nitro have different directories }), - buildTarget: 'next', // Placeholder, not actually used + buildTarget: 'nitro' as any, // Placeholder, not actually used }); this.#outDir = outDir; } diff --git a/packages/workflow/src/internal/builtins.ts b/packages/workflow/src/internal/builtins.ts index 5510c1fad0..06248c28b8 100644 --- a/packages/workflow/src/internal/builtins.ts +++ b/packages/workflow/src/internal/builtins.ts @@ -3,6 +3,9 @@ * similar to "stdlib" except that are not meant to be imported by users, but are instead "just available" * alongside user defined steps. They are used internally by the runtime */ +// Re-export stdlib for discovery - needed because lazy discovery doesn't pick it up via eager scanning. +// The workflow builder injects this as a builtin module available in the workflow runtime sandbox. +export * from '../stdlib.js'; export async function __builtin_response_array_buffer(res: Response) { 'use step'; diff --git a/packages/workflow/tsconfig.json b/packages/workflow/tsconfig.json index a78dbf413c..0f4008973e 100644 --- a/packages/workflow/tsconfig.json +++ b/packages/workflow/tsconfig.json @@ -1,7 +1,10 @@ { "extends": "@workflow/tsconfig/base.json", "compilerOptions": { - "outDir": "dist" + "outDir": "dist", + "sourceMap": false, + // See https://github.com/vercel/workflow/pull/352 + "inlineSourceMap": true }, "include": ["src"], "exclude": ["node_modules", "**/*.test.ts"] diff --git a/packages/world-testing/src/idempotency.mts b/packages/world-testing/src/idempotency.mts index b4bcbe4cdf..212a27ffbe 100644 --- a/packages/world-testing/src/idempotency.mts +++ b/packages/world-testing/src/idempotency.mts @@ -3,7 +3,7 @@ import { hydrateWorkflowReturnValue } from 'workflow/internal/serialization'; import { createFetcher, startServer } from './util.mjs'; export function idempotency(world: string) { - test('idempotency', { timeout: 60_000 }, async () => { + test('idempotency', { timeout: 120_000 }, async () => { const server = await startServer({ world }).then(createFetcher); const result = await server.invoke('workflows/noop.ts', 'brokenWf', [1, 2]); expect(result.runId).toMatch(/^wrun_.+/); @@ -17,7 +17,7 @@ export function idempotency(world: string) { }, { interval: 200, - timeout: 59_000, + timeout: 120_000, } ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c01f41c397..a79fc4d310 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -616,22 +616,10 @@ importers: '@workflow/swc-plugin': specifier: workspace:* version: link:../swc-plugin-workflow - semver: - specifier: 7.7.3 - version: 7.7.3 - watchpack: - specifier: 2.4.4 - version: 2.4.4 devDependencies: '@types/node': specifier: 'catalog:' version: 22.19.0 - '@types/semver': - specifier: 7.7.1 - version: 7.7.1 - '@types/watchpack': - specifier: 2.4.4 - version: 2.4.4 '@workflow/tsconfig': specifier: workspace:* version: link:../tsconfig @@ -6425,9 +6413,6 @@ packages: '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} - '@types/graceful-fs@4.1.9': - resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} - '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -6509,9 +6494,6 @@ packages: '@types/seedrandom@3.0.8': resolution: {integrity: sha512-TY1eezMU2zH2ozQoAFAQFOPpvP15g+ZgSfTZt31AUUH/Rxtnz3H+A/Sv1Snw2/amp//omibc+AEkTaA8KUeOLQ==} - '@types/semver@7.7.1': - resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} - '@types/send@0.17.6': resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} @@ -6545,9 +6527,6 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - '@types/watchpack@2.4.4': - resolution: {integrity: sha512-SbuSavsPxfOPZwVHBgQUVuzYBe6+8KL7dwiJLXaj5rmv3DxktOMwX5WP1J6UontwUbewjVoc7pCgZvqy6rPn+A==} - '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} @@ -8966,9 +8945,6 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob-to-regexp@0.4.1: - resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} - glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} hasBin: true @@ -13157,10 +13133,6 @@ packages: typescript: optional: true - watchpack@2.4.4: - resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==} - engines: {node: '>=10.13.0'} - wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} @@ -19035,10 +19007,6 @@ snapshots: '@types/geojson@7946.0.16': {} - '@types/graceful-fs@4.1.9': - dependencies: - '@types/node': 24.6.2 - '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -19116,8 +19084,6 @@ snapshots: '@types/seedrandom@3.0.8': {} - '@types/semver@7.7.1': {} - '@types/send@0.17.6': dependencies: '@types/mime': 1.3.5 @@ -19157,11 +19123,6 @@ snapshots: '@types/unist@3.0.3': {} - '@types/watchpack@2.4.4': - dependencies: - '@types/graceful-fs': 4.1.9 - '@types/node': 24.6.2 - '@types/yauzl@2.10.3': dependencies: '@types/node': 24.6.2 @@ -22082,8 +22043,6 @@ snapshots: is-glob: 4.0.3 optional: true - glob-to-regexp@0.4.1: {} - glob@10.4.5: dependencies: foreground-child: 3.3.1 @@ -27628,11 +27587,6 @@ snapshots: optionalDependencies: typescript: 5.9.3 - watchpack@2.4.4: - dependencies: - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - wcwidth@1.0.1: dependencies: defaults: 1.0.4 diff --git a/workbench/nextjs-turbopack/next.config.ts b/workbench/nextjs-turbopack/next.config.ts index 0428ff83f7..493481c8cc 100644 --- a/workbench/nextjs-turbopack/next.config.ts +++ b/workbench/nextjs-turbopack/next.config.ts @@ -4,6 +4,9 @@ import { withWorkflow } from 'workflow/next'; const nextConfig: NextConfig = { /* config options here */ serverExternalPackages: ['@node-rs/xxhash'], + + // For debugging/testing: Makes Turbopack module IDs human-readable instead of hashed + experimental: { turbopackModuleIds: 'named' }, }; // export default nextConfig;