[nest] Fix NestJS Vercel build output#2988
Conversation
Deployed NestJS workflows previously stayed `pending` forever: nothing emitted a queue-consumer function for Vercel Queue to discover, so runs were never dispatched. This adds a Vercel Build Output path for NestJS. `workflow-nest build --vercel` (also triggered when the VERCEL env var is set) now writes `.vercel/output`: - the combined workflow queue-consumer `flow.func` is produced by the shared `VercelBuildOutputAPIBuilder` — the same code path Nitro/Next/etc. use — so it is registered with `experimentalTriggers` and discovered by VQS; - the NestJS app is bundled (from the SWC-compiled `dist/`) as a catch-all function with the workflow world statically injected; - routing is merged into `config.json` (workflow routes + filesystem win, then fall through to the app). `WorkflowModule` now lazy-loads the build toolchain (@workflow/builders, esbuild, SWC) so importing it no longer pulls the compiler into the runtime serverless bundle. The builders remain available via the `workflow/nest/builder` and `workflow/nest/vercel-builder` subpaths. Verified end-to-end: deployed a NestJS app to Vercel and a triggered workflow ran to completion (previously stuck pending). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 6e6f9ab The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
📊 Workflow Benchmarks❌ The benchmark run for commit Backend:
📜 Previous results (1)dad0793Fri, 17 Jul 2026 20:03:00 GMT · run logs
Avg deltas compare against the most recent benchmark run on Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) Scenarios — stream: one step that streams chunks back to the client; no hooks, so the run stays in turbo mode · hook + stream: registers a hook before the same streaming step, which exits turbo mode · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges 🟢/🔴 mark percentiles within/above target. Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120 All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor ( Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the |
🧪 E2E Test Results❌ Some tests failed Summary
❌ Failed Tests▲ Vercel Production (1 failed)express (1 failed):
Details by Category❌ ▲ Vercel Production
✅ 💻 Local Development
✅ 📦 Local Production
✅ 🐘 Local Postgres
✅ 🪟 Windows
✅ 📋 Other
✅ vercel-multi-region
❌ Some E2E test jobs failed:
Check the workflow run for details. |
…` meta-package, breaking the documented `import { NestLocalBuilder } from "workflow/nest"` and leaving no working alternative.
This commit fixes the issue reported at packages/nest/src/index.ts:6
## The bug
This PR changed `packages/nest/src/index.ts` from a value+type export:
```ts
export { type NestBuilderOptions, NestLocalBuilder } from './builder.js';
```
to a type-only export:
```ts
export type { NestBuilderOptions } from './builder.js';
```
The rationale is valid — `builder.ts` imports `@workflow/builders` (esbuild/SWC) at top level, and the runtime entry must stay free of build-time deps so a NestJS app can be bundled into a serverless function. The `NestLocalBuilder` value is instead intended to be reached via the `workflow/nest/builder` subpath (as the new comment in `index.ts` states).
The problem is that the escape hatch was only half-wired:
- `@workflow/nest`'s own `package.json` *does* expose a `./builder` subpath (`./dist/builder.js`).
- But the recommended consumer package is the **meta `workflow` package**, whose `package.json` only exposes `./nest` → `./dist/nest.js`, and `packages/workflow/src/nest.ts` is just `export * from '@workflow/nest'` (the runtime entry that no longer value-exports `NestLocalBuilder`). There was **no** `./nest/builder` subpath in the meta package.
### Concrete trigger
A user following the API-reference docs writes:
```ts
import { NestLocalBuilder } from "workflow/nest";
```
(`docs/content/docs/v5/api-reference/workflow-nest/nest-local-builder.mdx` line 15 and the v4 equivalent.) At runtime this resolves to `@workflow/nest`'s main entry, which now exports only the *type* `NestBuilderOptions` — so `NestLocalBuilder` is `undefined`, and `new NestLocalBuilder(...)` throws `TypeError: NestLocalBuilder is not a constructor`. There was also no `workflow/nest/builder` subpath to fall back to, so the value class was completely unreachable through the `workflow` meta package.
## The fix
1. Added a `./nest/builder` subpath to `packages/workflow/package.json` mapping to `./dist/nest-builder.js`.
2. Created `packages/workflow/src/nest-builder.ts` = `export * from '@workflow/nest/builder'`, mirroring the existing `src/nest.ts` pattern, so the value export is threaded through the meta package and compiled to `dist/nest-builder.js`.
3. Updated the v4 and v5 `nest-local-builder.mdx` docs to the working import path `import { NestLocalBuilder } from "workflow/nest/builder"`.
This restores reachability of `NestLocalBuilder` for meta-package consumers while preserving the PR's goal of keeping build-time deps out of the runtime entry.
Note: the sandbox has no installed `node_modules`, so a full `tsc` build could not be executed; the change is structurally identical to the already-working `./nest` subpath wiring.
Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
Co-authored-by: VaguelySerious <mittgfu@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
| '@swc/core/*', | ||
| '@swc/wasm', | ||
| 'esbuild', | ||
| '*.node', |
There was a problem hiding this comment.
ai review: *.node is still always externalized, but this builder does not trace or copy external files into appFuncDir; the emitted function contains only index.js, package.json, and .vc-config.json. Any Nest app dependency with a native addon therefore keeps a runtime reference to a .node file that is absent from the deployed .func, causing MODULE_NOT_FOUND on cold start. Please trace/copy native artifacts (and their package layout) into the function, or fail the build with a clear unsupported-dependency error instead of producing a broken deployment.
There was a problem hiding this comment.
Addressed as a documented limitation rather than adding native-module tracing, which is out of scope for this exploratory integration (reliably tracing/copying .node artifacts also requires handling the runtime resolution these modules do via bindings/node-gyp-build).
*.node stays externalized so esbuild does not fail the build on a .node file it cannot bundle; I added a comment at that line making the intentional choice and its consequence explicit (b047a12), and called out the limitation in the changeset-linked README section ("Deploying to Vercel") plus the changeset. If native-addon support is needed we can follow up with @vercel/nft tracing.
| @@ -0,0 +1,5 @@ | |||
| --- | |||
| '@workflow/nest': minor | |||
There was a problem hiding this comment.
ai review: This is marked as a minor change, but the PR removes the public NestLocalBuilder value export from the @workflow/nest and workflow/nest main entries. Existing documented imports stop working; adding a new /builder subpath provides a migration target but does not preserve compatibility. Because this bump type also governs stable backports, please either retain a compatible main-entry export or mark the change as major.
There was a problem hiding this comment.
Bumped @workflow/nest to major (b047a12). Retaining the main-entry NestLocalBuilder export is not an option — it would re-drag the build toolchain (@workflow/builders, esbuild, SWC) into the runtime bundle of every app that imports WorkflowModule, which is the bug this PR fixes — so the subpath (@workflow/nest/builder) is the migration target and the changeset now documents it as a breaking change.
|
|
||
| Commands: | ||
| init Generate .swcrc configuration with the workflow plugin | ||
| build Build workflow bundles (and the Vercel Build Output when on Vercel) |
There was a problem hiding this comment.
ai review: The package README still documents only the init CLI command and has no Vercel deployment/build instructions. Repository standards require corresponding README updates when package functionality changes, and direct @workflow/nest consumers otherwise have no package-local documentation for this new command. Please update packages/nest/README.md with build, its options, the Vercel entry module, and the vercel-build flow.
There was a problem hiding this comment.
Updated packages/nest/README.md (b047a12): added a "Deploying to Vercel" section (serverless entry module, skipBuild on Vercel, the vercel-build flow, native-addon limitation), documented the build command with an options table (--vercel, --dirs, --entry, --out-dir, --module), and noted the NestLocalBuilder/NestVercelBuilder subpaths in "How It Works".
…ive-addon limitation - Changeset: bump @workflow/nest to major (removing NestLocalBuilder from the main entry is a breaking change) and document the subpath migration + the native-addon limitation. - README: document the `build` command and its options, the Vercel entry module and `vercel-build` flow, the NestVercelBuilder subpath, and the native-addon limitation. - vercel-builder: comment why `*.node` is externalized and that native addons are not yet traced/copied into the deployed function. - Minor wording: Workflow DevKit -> Workflow SDK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| ```typescript | ||
| // _vercel/entry.ts | ||
| import { NestFactory } from '@nestjs/core'; | ||
| import { AppModule } from '../src/app.module'; |
There was a problem hiding this comment.
ai review: This entry bypasses the nest build output by importing ../src/app.module, so the Vercel builder feeds the Nest TypeScript graph back through esbuild. That contradicts the builder assumption that it bundles already-compiled JS, and esbuild does not emit the TypeScript emitDecoratorMetadata that Nest constructor injection relies on; apps with injected providers can therefore boot with missing DI metadata. Please import ../dist/app.module.js here, matching the checked-in workbench and the v5 guide, so the SWC-compiled decorator metadata is retained.
There was a problem hiding this comment.
Good catch — fixed in 6e6f9ab. The entry example now imports the compiled ../dist/app.module.js (produced by nest build, whose SWC pass emits emitDecoratorMetadata) instead of raw ../src/app.module, and adds import 'reflect-metadata'. This matches the checked-in workbench/nest/_vercel/entry.ts and the getting-started docs, both of which already used dist/ + reflect-metadata.
…t + reflect-metadata The entry snippet imported AppModule from raw `../src/app.module` and omitted `import 'reflect-metadata'`. Importing raw TS routes the app back through esbuild (which does not emit `emitDecoratorMetadata`), breaking NestJS constructor injection; the entry must import the `nest build` output (`../dist/app.module.js`) and register reflect-metadata — matching the workbench entry and getting-started docs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes #1472 and #878
NestJS currently produces no
.vercel/output, so nothing emits a queue-consumer function withexperimentalTriggers, and Vercel Queue never dispatches the run.Every other server framework (Express/Fastify/Hono) sidesteps this by being a Nitro app whose Vercel preset emits the consumer. NestJS has its own compiler/DI and can't ride that path, so it needs to emit the Build Output itself.
Approach
Keep
nest build(SWC compiles decorators +emitDecoratorMetadatanatively). After it,workflow-nest build --vercel(also auto-detected via theVERCELenv var) writes a complete.vercel/output:flow.func(+webhook/[token].func, static manifest, routing) is produced by the sharedVercelBuildOutputAPIBuilder, i.e. the exact code path Nitro/Next/etc. use, so it's registered with the__wkf_workflow_*experimentalTriggersand VQS discovers it. Single combined trigger — matches current arch.dist/) is bundled as a catch-all function, with the workflow world statically injected (createWorkflowWorldTargetEsbuildPlugin) sostart()works inside it.config.json: workflow routes + filesystem win, then fall through to the app.WorkflowModulenow lazy-loads the build toolchain (@workflow/builders, esbuild, SWC) so importing it no longer drags the compiler into the runtime serverless bundle. The runtime entry (workflow/nest) no longer re-exports the builders; they live atworkflow/nest/builderandworkflow/nest/vercel-builder.This deliberately avoids the earlier #1472 hacks (base64-embedding bundles into source,
/tmpwrites,globalThisinjection, separate step/flow triggers) — each now has a clean shared equivalent.Testing
peter-w).POST /api/start→wrun_…; the run completed (both steps + asleepran):{ "status": "completed", "returnValue": { "hello": "Hello, vercel!", "bye": "Goodbye, vercel.", "completedAt": "…" } }pending.@workflow/builders) and boots underVERCEL=1.flow.func/.vc-config.jsoncarries the__wkf_workflow_*queue trigger.@workflow/nestunit tests pass (37).Status / notes
@vercel/nft-style tracing is a more robust future option if the allowlist proves brittle.