Skip to content

fix(builders): fail the build when the flow bundle still needs require() - #4268

Merged
VaguelySerious merged 7 commits into
mainfrom
cjs-flow-bundle-guard
Oct 1, 2026
Merged

VaguelySerious merged 7 commits into
mainfrom
cjs-flow-bundle-guard

Conversation

@VaguelySerious

@VaguelySerious VaguelySerious commented Sep 19, 2026 •

Copy link
Copy Markdown
Member

Closes #1161

The workflow ("flow") bundle is evaluated with vm.runInContext() in a sandbox that only shims module and exports — there is no require. esbuild emits that bundle as CJS, so:

  • an import left external becomes a literal require("…") hoisted to the top of the bundle, and
  • a require(…) esbuild could not statically resolve is copied through verbatim.

Both throw ReferenceError: require is not defined. The external case is the worst: it fires while the bundle's top level is still evaluating, so every workflow in the deployment fails to load, not just the code path that wanted the module.

This was silent at build time. createNodeModuleErrorPlugin() marks Node.js/Bun builtins external: true and only reports the ones it can trace back to an import … from "pkg" statement in user code (node-module-esbuild-plugin.ts) — everything else was externalized without a word.

Reproduced before this change: a workflow importing wrapper-pkg, which requires leaky-pkg, which requires node:fs, builds successfully and emits

var require_leaky_pkg = __commonJS({
  "node_modules/leaky-pkg/index.js"(exports, module2) {
    var fs = require("node:fs");   // ReferenceError at load time

What this adds

packages/builders/src/flow-bundle-safety.ts, run on the finished interim bundle inside createWorkflowsBundle() (and on Next.js dev hot rebuilds, which drive the esbuild context directly and would otherwise only catch it after a restart).

External imports — from the metafile. outputs[*].imports with external: true is authoritative for what esbuild actually emitted; inputs[*].imports gives the importing module, and a BFS over the input graph reconstructs the chain back to user code.

Free require() — text scan + esbuild scope analysis. A lexer blanks comments, strings, template text (substitutions stay code) and regex literals, then looks for the require identifier. Confirmation runs esbuild.transform(bundle, { define: { require: <sentinel> } }): define only rewrites identifiers that resolve to the global scope, so the bundler's own scope analysis has the final say. That pass only runs when the cheap scan already found a candidate (~600 ms per MB otherwise), so clean builds pay nothing.

try { require(…) } catch is excused. Guards are read from an acorn AST of the bundle (parsed only when a candidate exists). A require() inside one is how packages probe for an optional dependency — the sandbox's ReferenceError lands in the catch and the bundle still loads. This applies to externals too: esbuild only externalizes an unresolvable require() when it is wrapped that way (its "Could not resolve" hint literally suggests doing so), so the metafile can report an import for code designed to fail. A specifier is excused only when every one of its call sites is guarded.

Diagnostics:

Workflow bundle cannot run in the workflow sandbox.

Workflow functions are evaluated in a sandbox without `require`. The bundle below still contains
`require()` calls, which throw `ReferenceError: require is not defined` when the bundle is loaded.

Imports left external (1):
  • "node:fs"
    imported by node_modules/leaky-pkg/index.js
    via workflow.ts → node_modules/wrapper-pkg/index.js → node_modules/leaky-pkg/index.js

Unresolved require() calls (1):
  • dynamic require() in node_modules/wrapper-pkg/index.js (bundle 7:33)
    module2.exports.lazy = (n) => require(n);

Learn more: https://workflow-sdk.dev/err/node-js-module-in-workflow
╰▶ hint: Move the code that needs Node.js built-ins into a "use step" function… Set
   WORKFLOW_ALLOW_UNSAFE_FLOW_BUNDLE=1 to downgrade this to a warning.

Compatibility: what this can newly break, and the guard for each

Every case below already produced a bundle that crashes at runtime — this turns a production ReferenceError into a build error. The risk is mis-detection, so each is either narrowed or has an escape hatch.

Case Surfaces as Guard / mitigation
Previously-silent externalized Node builtins — a builtin the existing plugin marked external but could not attribute to an import statement Build error naming the specifier The real fix: it was a guaranteed load-time crash. Error carries the importer + chain so the user knows which dependency to move behind a step
Transitive dependencies — your-code → pkg-a → pkg-b → node:fs Build error Chain is reconstructed from the metafile, so the message points at the first file the user owns, not an opaque node_modules path
Re-exports / side-effect imports — export * from 'pkg', import 'pkg' Build error Same. These are precisely the shapes getViolationLocation()'s import … from regex misses, so they used to ship broken
User-written dynamic require() in workflow code Build error with bundle line + source line Only flagged when esbuild's define pass confirms the identifier is global
Dynamic require() inside a dependency (plugin loaders, optional deps) Build error naming the dependency module Same confirmation; WORKFLOW_ALLOW_UNSAFE_FLOW_BUNDLE=1 if the call is genuinely unreachable
typeof require UMD/CJS probes — extremely common in bundled packages Not flagged typeof on an undeclared name cannot throw. A require behind an if/?:/&&/|| is excused only when the test is provably false (or true for || and else) given typeof require === "undefined", evaluated on the AST, so negated checks and non-short-circuit operators are still reported
esbuild's own helpers — __require (the __commonJS shim), require_<name>() module wrappers Not flagged Identifier-boundary matching (_ is a word character), verified against real esbuild output in tests
Shadowed bindings — function (require, exports) { … } UMD factories Not flagged esbuild renames these to require2, and the define pass independently confirms the reference is not global
require as a property — obj.require(…), { require: … } Not flagged Member-access and property-key positions are excluded
require inside strings / comments / regexes / template text Not flagged Lexer blanks those regions before scanning
try { require('opt') } catch {} — optional-dependency probe (framer-motion ships one); the ReferenceError is caught Not flagged TryStatement nodes with a handler in the AST, and cover both shapes: a free require(pkgVar) and a literal require("pkg") that esbuild externalized because of the try/catch
A lazy loader defined inside a try block (try { exports.load = n => require(n) } catch {}) — protected lexically but not at call time Not flagged (under-report) Deliberate: the rule is lexical, and under-reporting keeps a new build gate from breaking working apps. The runtime error still surfaces if it is ever called
Double reporting of the same require("node:fs") as both external and dynamic — Literal requires matching a reported external are dropped from the dynamic list

Escape hatch: WORKFLOW_ALLOW_UNSAFE_FLOW_BUNDLE=1 turns the failure into a warning (the bundle still fails at runtime if the code is reached). Documented on the node-js-module-in-workflow error page.

Tests

  • flow-bundle-safety.test.ts (19) — lexer offset/newline preservation, division vs. regex, template substitutions, banner comments; helper-wrapper and typeof exclusions; metafile chain building and <runtime> filtering; external/dynamic de-duplication; shadowed-binding suppression; throw vs. warn.
  • flow-bundle-safety.build.test.ts (6) — real BaseBuilder builds against esbuild's actual output: a clean CJS dependency still builds; transitive builtin, re-export, user dynamic require, and dependency dynamic require all fail with the expected diagnostics. The escape-hatch test also evaluates the produced bundle in a bare vm context and asserts the ReferenceError it would have shipped.

Validation

  • pnpm --filter @workflow/builders test — 276 passed.
  • turbo test --continue — 51/51 tasks (@workflow/swc-plugin skipped: no cargo in this environment).
  • pnpm build, pnpm --filter @workflow/builders typecheck, pnpm lint (no new errors; 2 pre-existing-style complexity warnings on the lexer).
  • No false positives across the workbench: example, nextjs-turbopack, nextjs-webpack, sveltekit, nuxt, nitro, nitro-v2, nitro-v3, astro, vite, hono, express, fastify, nest, tanstack-start all build clean.
  • packages/core/e2e/dev.test.ts against the nextjs-turbopack dev server — 7 passed, 2 skipped. The first push failed the 8 Next dev HMR jobs here: adding a workflow to app/page.tsx drags the page's component tree (and framer-motion's optional-dependency probe) into the flow bundle. Reproduced locally, fixed, and confirmed by a negative control — with the try-block guard removed the suite fails identically to CI, with it the suite passes.

Out of scope: the Vitest issue.

🤖 Generated with Claude Code

The workflow bundle is evaluated with vm.runInContext() in a sandbox that
only shims `module` and `exports` — there is no `require`. esbuild emits
that bundle as CJS, so an import left external becomes a top-level
`require("…")` and an unresolved dynamic `require()` is copied through
verbatim. Both throw `ReferenceError: require is not defined` when the
bundle loads, which takes down every workflow in the deployment.

createNodeModuleErrorPlugin() marks Node.js/Bun builtins external and only
reports the ones it can trace back to an `import … from "pkg"` statement in
user code, so builtins reached through a transitive dependency, a
re-export, or a side-effect-only import shipped a bundle that could never
run.

Add flow-bundle-safety.ts, which inspects the finished bundle:

- external imports come from the metafile, reported with the importing
  module and the import chain back to user code;
- free `require` references are found with a lexer that blanks comments,
  strings, template text and regexes, so esbuild's own `__require` helper,
  `require_<name>()` module wrappers, `obj.require` and `typeof require`
  probes are not flagged;
- candidates are then confirmed with esbuild's scope analysis (a `define`
  pass only rewrites identifiers that resolve to the global scope), so a
  shadowed `require` binding cannot fail a build. That pass only runs when
  the cheap scan found something.

WORKFLOW_ALLOW_UNSAFE_FLOW_BUNDLE=1 downgrades the failure to a warning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Co-Authored-By: Peter Wielander <29887157+VaguelySerious@users.noreply.github.com>
@changeset-bot

changeset-bot Bot commented Sep 19, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3db4259

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 17 packages
Name Type
@workflow/builders Patch
@workflow/next Patch
@workflow/astro Patch
@workflow/cli Patch
@workflow/nest Patch
@workflow/nitro Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch
@workflow/vitest Patch
workflow Patch
@workflow/world-testing Patch
@workflow/nuxt Patch
@workflow/core Patch
@workflow/world-vercel Patch
@workflow/web-shared Patch
@workflow/web Patch

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

@vercel

vercel Bot commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
example-nextjs-workflow-turbopack Ready Ready Preview, v0 Sep 30, 2026 8:29pm UTC
example-nextjs-workflow-webpack Ready Ready Preview, v0 Sep 30, 2026 8:29pm UTC
example-workflow Ready Ready Preview, v0 Sep 30, 2026 8:29pm UTC
workbench-astro-workflow Ready Ready Preview, v0 Sep 30, 2026 8:29pm UTC
workbench-express-workflow Ready Ready Preview, v0 Sep 30, 2026 8:29pm UTC
workbench-fastify-workflow Ready Ready Preview, v0 Sep 30, 2026 8:29pm UTC
workbench-hono-workflow Ready Ready Preview, v0 Sep 30, 2026 8:29pm UTC
workbench-nestjs-workflow Ready Ready Preview, v0 Sep 30, 2026 8:29pm UTC
workbench-nitro-workflow Ready Ready Preview, v0 Sep 30, 2026 8:29pm UTC
workbench-nuxt-workflow Ready Ready Preview, v0 Sep 30, 2026 8:29pm UTC
workbench-python-workflow Ready Ready Preview, v0 Sep 30, 2026 8:29pm UTC
workbench-sveltekit-workflow Ready Ready Preview, v0 Sep 30, 2026 8:29pm UTC
workbench-tanstack-start-workflow Ready Ready Preview, v0 Sep 30, 2026 8:29pm UTC
workbench-vite-workflow Ready Ready Preview, v0 Sep 30, 2026 8:29pm UTC
workflow-docs Ready Ready Preview, v0 Sep 30, 2026 8:29pm UTC
workflow-swc-playground Ready Ready Preview, v0 Sep 30, 2026 8:29pm UTC
workflow-tarballs Ready Ready Preview, v0 Sep 30, 2026 8:29pm UTC
workflow-web Ready Ready Preview, v0 Sep 30, 2026 8:29pm UTC

@github-actions

github-actions Bot commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

✅ All tests passed

🛠 Infra Events (absorbed by the harness)

Platform anomalies the e2e harness detected and worked around (e.g. a run the queue never picked up, replaced by a fresh run). Clustered timestamps indicate a backend blip; a steady drip indicates a platform issue worth escalating.

  • run-pickup-stall · runs app-generated source against a registered step (tanstack-start) · at 20:29:52Z · abandoned wrun_01M3T04A4K1PSGFEX7WWZ9W3NN
  • cold-start-warmup · suite warmup (tanstack-start) · at 20:29:59Z · abandoned wrun_01M3T04A5MEH4V6ZX98W2DHT0X
  • run-pickup-stall · hookCleanupTestWorkflow - hook token reuse after workflow completion (nextjs-webpack) · at 20:35:26Z · abandoned wrun_01M3T0EG419YHP5QFDFCQXEH2W
  • run-pickup-stall · ChainableService.processWithThis - static step methods using this to reference the class (nextjs-webpack) · at 20:36:47Z · abandoned wrun_01M3T0GZCJG4F8E7HH7GXJNE34
  • run-pickup-stall · thisSerializationWorkflow - step function invoked with .call() and .apply() (nextjs-webpack) · at 20:36:48Z · abandoned wrun_01M3T0H10W1C18WKPFPV70M9RT

E2E Test Summary

Summary
Passed Failed Skipped Total
✅ ▲ Vercel Production 3904 0 875 4779
✅ 💻 Local Development 4406 0 550 4956
✅ 📦 Local Production 4406 0 550 4956
✅ 🐘 Local Postgres 4406 0 550 4956
✅ 🪟 Windows 342 0 12 354
✅ 🌐 Cross-language Conformance 68 0 84 152
✅ dynamic-runs 0 0 0 0
✅ vercel-http-transport 879 0 183 1062
✅ vercel-multi-region 27 0 0 27
✅ vercel-ws-transport 595 0 113 708
Total 19033 0 2917 21950
Details by Category

✅ ▲ Vercel Production

App Passed Failed Skipped
✅ astro-node 142 0 35
✅ astro-quickjs 142 0 35
✅ example-node 142 0 35
✅ example-quickjs 142 0 35
✅ express-node 142 0 35
✅ express-quickjs 142 0 35
✅ fastify-node 142 0 35
✅ fastify-quickjs 142 0 35
✅ hono-node 142 0 35
✅ hono-quickjs 142 0 35
✅ nest-node 142 0 35
✅ nest-quickjs 142 0 35
✅ nextjs-turbopack-node 169 0 8
✅ nextjs-turbopack-quickjs 169 0 8
✅ nextjs-webpack-node 169 0 8
✅ nextjs-webpack-quickjs 169 0 8
✅ nitro-node 142 0 35
✅ nitro-quickjs 142 0 35
✅ nuxt-node 142 0 35
✅ nuxt-quickjs 142 0 35
✅ python-node 66 0 111
✅ sveltekit-node 161 0 16
✅ sveltekit-quickjs 161 0 16
✅ tanstack-start-node 142 0 35
✅ tanstack-start-quickjs 142 0 35
✅ vite-node 142 0 35
✅ vite-quickjs 142 0 35

✅ 💻 Local Development

App Passed Failed Skipped
✅ astro-stable-node 148 0 29
✅ astro-stable-quickjs 148 0 29
✅ express-stable-node 148 0 29
✅ express-stable-quickjs 148 0 29
✅ fastify-stable-node 148 0 29
✅ fastify-stable-quickjs 148 0 29
✅ hono-stable-node 148 0 29
✅ hono-stable-quickjs 148 0 29
✅ nest-stable-node 148 0 29
✅ nest-stable-quickjs 148 0 29
✅ nextjs-turbopack-canary-node 176 0 1
✅ nextjs-turbopack-canary-quickjs 176 0 1
✅ nextjs-turbopack-stable-node 176 0 1
✅ nextjs-turbopack-stable-quickjs 176 0 1
✅ nextjs-webpack-canary-node 176 0 1
✅ nextjs-webpack-canary-quickjs 176 0 1
✅ nextjs-webpack-stable-node 176 0 1
✅ nextjs-webpack-stable-quickjs 176 0 1
✅ nitro-stable-node 148 0 29
✅ nitro-stable-quickjs 148 0 29
✅ nuxt-stable-node 148 0 29
✅ nuxt-stable-quickjs 148 0 29
✅ sveltekit-stable-node 167 0 10
✅ sveltekit-stable-quickjs 167 0 10
✅ tanstack-start-node 148 0 29
✅ tanstack-start-quickjs 148 0 29
✅ vite-stable-node 148 0 29
✅ vite-stable-quickjs 148 0 29

✅ 📦 Local Production

App Passed Failed Skipped
✅ astro-stable-node 148 0 29
✅ astro-stable-quickjs 148 0 29
✅ express-stable-node 148 0 29
✅ express-stable-quickjs 148 0 29
✅ fastify-stable-node 148 0 29
✅ fastify-stable-quickjs 148 0 29
✅ hono-stable-node 148 0 29
✅ hono-stable-quickjs 148 0 29
✅ nest-stable-node 148 0 29
✅ nest-stable-quickjs 148 0 29
✅ nextjs-turbopack-canary-node 176 0 1
✅ nextjs-turbopack-canary-quickjs 176 0 1
✅ nextjs-turbopack-stable-node 176 0 1
✅ nextjs-turbopack-stable-quickjs 176 0 1
✅ nextjs-webpack-canary-node 176 0 1
✅ nextjs-webpack-canary-quickjs 176 0 1
✅ nextjs-webpack-stable-node 176 0 1
✅ nextjs-webpack-stable-quickjs 176 0 1
✅ nitro-stable-node 148 0 29
✅ nitro-stable-quickjs 148 0 29
✅ nuxt-stable-node 148 0 29
✅ nuxt-stable-quickjs 148 0 29
✅ sveltekit-stable-node 167 0 10
✅ sveltekit-stable-quickjs 167 0 10
✅ tanstack-start-node 148 0 29
✅ tanstack-start-quickjs 148 0 29
✅ vite-stable-node 148 0 29
✅ vite-stable-quickjs 148 0 29

✅ 🐘 Local Postgres

App Passed Failed Skipped
✅ astro-stable-node 148 0 29
✅ astro-stable-quickjs 148 0 29
✅ express-stable-node 148 0 29
✅ express-stable-quickjs 148 0 29
✅ fastify-stable-node 148 0 29
✅ fastify-stable-quickjs 148 0 29
✅ hono-stable-node 148 0 29
✅ hono-stable-quickjs 148 0 29
✅ nest-stable-node 148 0 29
✅ nest-stable-quickjs 148 0 29
✅ nextjs-turbopack-canary-node 176 0 1
✅ nextjs-turbopack-canary-quickjs 176 0 1
✅ nextjs-turbopack-stable-node 176 0 1
✅ nextjs-turbopack-stable-quickjs 176 0 1
✅ nextjs-webpack-canary-node 176 0 1
✅ nextjs-webpack-canary-quickjs 176 0 1
✅ nextjs-webpack-stable-node 176 0 1
✅ nextjs-webpack-stable-quickjs 176 0 1
✅ nitro-stable-node 148 0 29
✅ nitro-stable-quickjs 148 0 29
✅ nuxt-stable-node 148 0 29
✅ nuxt-stable-quickjs 148 0 29
✅ sveltekit-stable-node 167 0 10
✅ sveltekit-stable-quickjs 167 0 10
✅ tanstack-start-node 148 0 29
✅ tanstack-start-quickjs 148 0 29
✅ vite-stable-node 148 0 29
✅ vite-stable-quickjs 148 0 29

✅ 🪟 Windows

App Passed Failed Skipped
✅ nextjs-turbopack-node 171 0 6
✅ nextjs-turbopack-quickjs 171 0 6

✅ 🌐 Cross-language Conformance

App Passed Failed Skipped
✅ python 68 0 84

✅ dynamic-runs

App Passed Failed Skipped
✅ astro-vercel 0 0 0
✅ example-vercel 0 0 0
✅ express-vercel 0 0 0
✅ fastify-vercel 0 0 0
✅ hono-vercel 0 0 0
✅ nest-vercel 0 0 0
✅ nextjs-turbopack-vercel 0 0 0
✅ nextjs-webpack-vercel 0 0 0
✅ nitro-vercel 0 0 0
✅ nuxt-vercel 0 0 0
✅ sveltekit-vercel 0 0 0
✅ tanstack-start-vercel 0 0 0
✅ vite-vercel 0 0 0

✅ vercel-http-transport

App Passed Failed Skipped
✅ example 142 0 35
✅ express 142 0 35
✅ hono 142 0 35
✅ nextjs-turbopack 169 0 8
✅ nitro 142 0 35
✅ vite 142 0 35

✅ vercel-multi-region

App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

✅ vercel-ws-transport

App Passed Failed Skipped
✅ example 142 0 35
✅ express 142 0 35
✅ nextjs-turbopack 169 0 8
✅ vite 142 0 35

📋 View full workflow run

@github-actions

github-actions Bot commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 3db4259 · Wed, 30 Sep 2026 20:46:18 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 480 (-34%) 💚 2725 🔴 (+150%) 🔻 2815 🔴 (+153%) 🔻 3103 🔴 (+158%) 🔻 30
TTFS stream 2343 (+965%) 🔻 2688 🔴 (+156%) 🔻 2757 🔴 (+151%) 🔻 2829 🔴 (+98%) 🔻 30
TTFS hook + stream 708 (-19%) 💚 2822 🔴 (+113%) 🔻 3108 🔴 (+121%) 🔻 3342 🔴 (+114%) 🔻 30
Fan-out TTFS Promise.all(100 steps) 521 (-40%) 💚 787 (-54%) 💚 808 (-55%) 💚 2833 (+51%) 🔻 10
Fan-out TTLS Promise.all(100 steps) 1505 (-34%) 💚 3355 (-7.6%) 5162 (+37%) 🔻 6557 (-9.2%) 10
STSO 1020 steps (inline) 135 (+35%) 🔻 181 (+39%) 🔻 203 (+32%) 🔻 296 (+46%) 🔻 1019
WO 1020 steps 179601 (+36%) 🔻 179601 (+36%) 🔻 179601 (+36%) 🔻 179601 (+36%) 🔻 1
CRTT first chunk (pooled) 57 (-21%) 💚 88 (-38%) 💚 137 (-35%) 💚 198 (-44%) 💚 28

Streams

Scenario CRTT 1st p75 p90 p99 CDV max iters
paced control (100/s, 60B) 68 (-31%) 123 (-36%) 146 (-52%) 399 (-19%) 123 (-45%) 10
size sweep (100/s, 160B-12KB) 75.5 (-27%) 128 (-43%) 177 (-50%) 292 (-54%) 114 (-46%) 10
replay gateway-gpt-5.4-nano-2000t (1x) 92 (-33%) 119 (-37%) 148 (-54%) 290 (-49%) 198 (-55%) 3
replay eve-gpt-5.6-sol-2000t (1x) 85.5 (-18%) 122 (-38%) 144 (-53%) 260 (-53%) 182 (-60%) 2
replay eve-gpt-5.6-sol-2000t (2x) 88 (-38%) 144 (-56%) 178 (-63%) 285 (-70%) 175 (-63%) 3
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 131526ms → this run 179185ms (Δ +47659ms, +36%)

100-150 ms  ┃███████████████████████  main 899  this  47  -852
150-200 ms  ███░░░░░░░░░░░░░░░░░░░┃   main 108  this 849  +741
200-250 ms  █┃                        main   8  this  92   +84
250-300 ms  ┃                         main   2  this  22   +20
300-350 ms  ┃                         main   0  this   4    +4
350-400 ms  ┃                         main   1  this   4    +3
400-450 ms  ┃                         main   1  this   0    -1
500-550 ms  ┃                         main   0  this   1    +1
📈 CRTT drill-down vs main (RTT distributions & profiles)
variant  RTT 1ms→5s+             avg         p50         p90         p99     n
control  ·····▁█▆▁····  101.2 (-36%)   95 (-31%)  146 (-52%)  399 (-19%)  3000
sweep    ·····▁█▇▁····  103.4 (-41%)   92 (-41%)  177 (-50%)  292 (-54%)  3000
gw 1x    ·····▁██▁····  105.2 (-34%)  100 (-26%)  148 (-54%)  290 (-49%)  5295
eve 1x   ·····▁██▁····  105.3 (-35%)   98 (-27%)  144 (-53%)  260 (-53%)  5186
eve 2x   ·····▁▄█▁····  124.7 (-46%)  118 (-45%)  178 (-63%)  285 (-70%)  7779

RTT over stream progress (avg per tenth of stream, bars scaled min→max):

control  ▅▄▃▃▁▅▅█▁▃  91–118ms
sweep    █▂▂▂▃▇▁▂█▂  95–116ms
gw 1x    ██▄▅▄▄▂▂▁▂  97–117ms
eve 1x   ▅▃▄▂▁▅█▅▅▆  95–116ms
eve 2x   ▃▂▂▄▁▁▅█▃▃  114–151ms

RTT by chunk size (avg per log size bin, ~160B → ~12KB serialized, bars scaled min→max):

sweep  ▄██▆▅▂▁  100–106ms

Delivery jitter over stream progress (avg positive CDV per tenth of stream, bars scaled min→max):

control  ▇▅▅▅▃█▅▄▁▄  24–32ms
sweep    ▆▃▃▄▆█▂▃▅▁  35–45ms
gw 1x    ██▅▃▅▅▃▆▁▃  27–33ms
eve 1x   ▂▄▂▁▂█▇▂▄█  19–27ms
eve 2x   ▅▅▂▄▅▃▃▁▃█  18–28ms
ℹ️ Metric definitions & methodology

Streams: first-chunk RTT (the stream-open path, before any buffering/backpressure), CRTT percentiles, and worst delivery stall (CDV max). Cells are medians across iterations; per-run values in the artifacts. No 🔴/🟢 marks until targets attach.

The collapsed STSO distribution section above buckets every step gap, split inline (same warm process — pure framework overhead) vs queue-hop (fresh process — dispatch, reinit, replay). █ = main, ┃ = this run, ░ = fill.

The collapsed CRTT drill-down: per-variant RTT histograms (fixed log bins, · = empty) and mean RTT/positive-CDV profile lines over stream progress and chunk size. Histograms, avgs, and profiles merge exactly across runs; p50–p99 are percentile-of-percentiles. Per-index rows live in the artifacts.

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body) · Fan-out TTFS: fan-out time to first step (in-deployment start() → first of the parallel step bodies to complete) · Fan-out TTLS: fan-out time to last step (in-deployment start() → last of the parallel step bodies to complete, i.e. when the Promise.all resolves) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · CRTT: chunk round-trip time (per-chunk write → read latency, one clock domain: deployment → stream backend → same deployment) · CDV: chunk delay variation / delivery jitter (inter-arrival gap minus inter-write gap per seq-adjacent pair; skew-free; the row is each run's MAX positive value, so one stall moves it)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · Promise.all(100 steps): 100 trivial no-op steps started together in a single Promise.all; Fan-out TTFS is the first of them to complete and Fan-out TTLS the last, both from the in-deployment clientStart, so their gap is the spread the runtime adds across the fan-out · paced control (100/s, 60B): the control: 300 tiny (~60B) deltas metronome-paced at 100/s — zero workload structure, so it reads the transport floor and flush cadence, and disambiguates transport-wide vs workload-specific when a replay row moves · size sweep (100/s, 160B-12KB): same pacing as the control with deltas padded in rotation across seven log-spaced sizes (~160B–12KB) — rotation decouples size from stream position, so it isolates whether chunk size causes latency · replay gateway-gpt-5.4-nano-2000t (1x): raw provider SSE cadence captured at the AI gateway boundary (gpt-5.4-nano, the most popular gateway model; per-token deltas p50 208B = the modal production chunk size), replayed exactly as measured — the typical customer's workload; its CDV is the typical customer's real delivery jitter · replay eve-gpt-5.6-sol-2000t (1x): a captured eve turn (gpt-5.6-sol, the most-used demanding eve model; ~2000 output tokens = production p50 turn length) replayed exactly as measured — eve's envelope protocol re-ships the cumulative message so sizes ramp 142B→13KB; the demanding outlier tenant's reality · replay eve-gpt-5.6-sol-2000t (2x): the same eve capture at 2x — the headroom/stress row; real fast-tier models emit the same chunk sizes at proportionally higher rate, so time compression is a faithful speed model · first chunk (pooled): every run's seq-0 RTT pooled across all stream scenarios — the first chunk precedes any workload differentiation, so pooling samples one shared stream-open path with exact percentiles

Replay cadences (semantic sha256) — eve-gpt-5.6-sol-2000t eaf22f5946e7c61f3c65c7006d550df180cfabd4e706254a09f22aec0cfb420d · gateway-gpt-5.4-nano-2000t 6f24ac518b6b83ff1d0e85a5fe78230db192716d66a7fc6b2fe022752001d041

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600

All timestamps are deployment-side; runs are triggered in-deployment, so the CI runner and api.vercel.com sit outside every measured window. TTFS = start() → first step body (includes dispatch + any cold start); Fan-out TTFS/TTLS = first/last step completion of one Promise.all from the same anchor (the gap is the runtime’s fan-out spread); STSO/WO between step bodies; CRTT inside the workflow (excludes the api.vercel.com read path).

Cold starts stay in the numbers (real bursty-workload latency, inflates P75+); Best is the warm floor.

@github-actions

github-actions Bot commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 world-sim scenario book — 1 fail of 42 total

fence=per-spec

scenario outcome events virt replay violations
✅ smoke-no-steps completed 3 0ms ok 0
✅ smoke-one-step completed 6 0ms ok 0
✅ hook-at-step-started completed 12 0ms ok 0
✅ hook-at-step-completed completed 12 0ms ok 0
✅ hook-at-hook-created completed 12 0ms ok 0
✅ deadline-hook-wins completed 7 1.0h ok 0
✅ deadline-expires completed 7 1.0h ok 0
✅ step-vs-timer-early-settlement completed 8 1.0h ok 0
✅ long-sleep completed 11 30.0d ok 0
✅ hook-never-arrives stalled 3 0ms skipped 0
✅ step-retries-twice completed 10 2.0s ok 0
✅ parallel-steps completed 9 0ms ok 0
✅ hook-on-execution-state completed 12 0ms ok 0
✅ peek-hook-before-branch completed 12 0ms ok 0
✅ peek-hook-after-branch completed 12 0ms ok 0
✅ peek-hook-at-registration completed 12 0ms ok 0
✅ race-hook-before-probe completed 12 0ms ok 0
✅ race-hook-after-probe completed 12 0ms ok 0
✅ race-duplicate-delivery completed 13 0ms ok 0
✅ attr-hook-before-step completed 11 0ms ok 0
✅ attr-hook-after-step completed 11 0ms ok 0
✅ attr-from-step-body completed 13 0ms ok 0
✅ fork-hook-after-timeout completed 14 1.0m ok 0
✅ fork-hook-before-timeout completed 14 1.0m ok 0
✅ count-hook-after-timeout completed 17 1.0m ok 0
✅ count-hook-before-timeout completed 20 1.0m ok 0
✅ stale-read-step-count-fork completed 20 1.0m ok 0
✅ stale-read-equal-step-counts completed 14 1.0m ok 0
✅ step-vs-step-fork completed 12 0ms ok 0
✅ step-vs-step-fork-fenced completed 12 0ms ok 0
✅ fence-catches-benign-direction completed 12 5ms ok 0
✅ in-flight-before-decision completed 17 1.0m ok 0
❌ in-flight-before-decision-counted completed 17 1.0m ok 0
✅ in-flight-after-decision completed 19 2.0m ok 0
✅ stale-read-step-count-fork-fenced completed 20 1.0m ok 0
✅ fork-hook-wins completed 13 1.0m ok 0
✅ fork-timeout-wins completed 13 1.0m ok 0
✅ unclaimed-payload-under-fork completed 17 1.0m ok 0
✅ claimed-payload-under-fork completed 17 1.0m ok 0
✅ writers-independent-step-bodies completed 12 0ms ok 0
✅ writers-scripted-tempo completed 12 0ms ok 0
✅ cancel-mid-step cancelled 7 0ms skipped 0

Full trace: world-sim.txt

@github-actions

github-actions Bot commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor
Framework Flow route Step reg. Framework output
hono 265.9 KiB (±0) 97.2 KiB (±0) 1.98 MiB (±0)
nextjs-turbopack 273.3 KiB (±0) 426 B (±0) 979.6 KiB (±0)
About these numbers

Sizes are gzip; parentheses show the change against main.
Flow route and Step reg. gate this job, on raw bytes rather than the gzip shown, at max(2%, 50.0 KiB). Framework output is informational.

3db4259 · run

The Next.js dev e2e suite caught a false positive: adding a workflow to
`app/page.tsx` pulls the page's component tree into the flow bundle, and
framer-motion probes for its optional `@emotion/is-prop-valid` dependency
with a `require()` it *expects* to fail:

    try {
        loadExternalIsValidProp(require("@emotion/is-prop-valid").default);
    } catch {
        // fallback
    }

The sandbox's `ReferenceError` lands in that `catch`, so the bundle loads
fine — but the check failed the rebuild, and every Next dev HMR job with
it.

Track `try { … }` bodies while lexing and skip `require` references inside
them. This covers both shapes the probe takes: a free `require(pkgVar)`
(framer-motion 12.43, reported as a dynamic require) and a literal
`require("pkg")` that esbuild externalizes *because* it is wrapped in
try/catch (12.29, reported as an external import) — esbuild's own
"Could not resolve" hint suggests that wrapping, so the metafile entry
describes code designed to fail. A specifier is only excused when every
one of its call sites is guarded.

The scope-analysis probe applies the same exclusion so a guarded require
cannot confirm an unrelated candidate.

Verified by re-running `packages/core/e2e/dev.test.ts` against the
nextjs-turbopack dev server: fails identically to CI with the guard
removed, passes with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Co-Authored-By: Peter Wielander <29887157+VaguelySerious@users.noreply.github.com>

@karthikscale3 karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agent review findings.

*/
function isRequireCheckCondition(condition: string): boolean {
return (
typeofDefinedTest('require').test(condition) && !/\|\||\?\?/.test(condition)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agent Find: This treats any condition containing a positive-looking typeof require comparison as a guard, without preserving its polarity or excluding matches inside non-code text. For example, if (!(typeof require !== "undefined")) { require("node:fs"); } executes the branch and throws in the sandbox, yet the scanner reports no violation. A regex or string containing the same text can also mark the block guarded. Please parse the condition structurally, or otherwise prove the positive check dominates the branch, before suppressing its require sites.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: Note

Fixed in 31e4dd4. Guards are now decided on an acorn AST instead of text. The sandbox never defines require, so typeof require is always "undefined" there. The walker partially evaluates each if / ?: test under that assumption (handling !, string comparisons, &&/||, sequences) and only excuses the branch that provably does not run. if (!(typeof require !== "undefined")) { require(…) } now evaluates to true, so its consequent is reported. Look-alike text inside a regex or string is a literal node, so it can never produce a guard. New tests: "follows the polarity of a typeof require check" and "does not take a guard from typeof checks inside strings or regexes". Both fail on the previous commit.

.slice(lineStart + test.index + test[0].length, index)
.trimStart();
if (between.startsWith('?')) between = between.slice(1);
if (!/[|?:;,]/.test(between)) return true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agent Find: The punctuation denylist is not equivalent to short-circuit control flow. (typeof require !== "undefined") & require("node:fs") evaluates the RHS but is treated as guarded because & is allowed, while typeof require !== "undefined" && (cached || require("pkg")) is safe but rejected because | appears later. This needs structural analysis of the expression path instead of scanning the intervening characters.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: Note

Fixed in 31e4dd4. The punctuation scan is gone. Only the right operand of a LogicalExpression counts as guarded: under && when the left side is provably false in the sandbox, under || when it is provably true. &, | and ?? never guard. So (typeof require !== "undefined") & require("node:fs") is now reported, and typeof require !== "undefined" && (cached || require("pkg")) passes (the outer && guards the whole right subtree). Covered by "only excuses operators that short-circuit" plus the ok5 case in the polarity test.

Cost: the parse only runs when the bundle has a bare require token and at least one evaluated reference. Measured ~140ms/MB for parse plus walk on a 2.5MB file, compared with ~600ms/MB for the esbuild confirmation pass that already runs in that case. If the parse fails, nothing is treated as guarded (the conservative direction), which matches how a failed esbuild probe is handled.

VaguelySerious and others added 2 commits September 30, 2026 13:23
typeof require checks and try/catch blocks are now read from an acorn AST.
A check only excuses the branch it provably skips in the sandbox (where
typeof require is always "undefined"), so negated checks, non-short-circuit
operators, and look-alike text in strings or regexes no longer suppress a
require() site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@karthikscale3 karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both Agent Find issues are addressed. Guard analysis now preserves polarity, ignores non-code look-alikes, and follows actual short-circuit control flow; the focused builders tests and typecheck pass.

@VaguelySerious
VaguelySerious merged commit ab9e640 into main Oct 1, 2026
322 of 325 checks passed
@VaguelySerious
VaguelySerious deleted the cjs-flow-bundle-guard branch October 1, 2026 21:55
@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

No backport to stable for ab9e640 (AI decision).

This adds a new build-time validation gate (flow-bundle-safety.ts), a new public export surface on @workflow/builders, a new WORKFLOW_ALLOW_UNSAFE_FLOW_BUNDLE escape-hatch env var, and a new acorn dependency — that is feature/diagnostics work, not a correctness fix: the underlying bundles behave exactly as before at runtime, the change only surfaces the failure earlier. Its whole purpose is to turn builds that previously succeeded into build errors, so a false positive would break a working app on a maintenance line whose users stayed behind for stability, and the only part that is arguably a fix (isRuntimeBuiltinSpecifier export) is just plumbing for the new check. The docs update also targets docs/content/docs/v5/, which is the 5.x line rather than stable's 4.x content.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

ab9e640f0db46e3708dcc7d915ef1241b777a795

joelfickson added a commit that referenced this pull request Oct 22, 2026
Restore the `if (location)` gate. A violation the plugin cannot locate in
user code is caught by assertFlowBundleIsSandboxSafe (#4268), which names
the builtin and the import chain, excuses `typeof require` guards, and
honors WORKFLOW_ALLOW_UNSAFE_FLOW_BUNDLE. Reporting it from the plugin
pre-empted all three and broke four flow-bundle-safety tests.

The multi-line import fix stays: those imports now get a source location.

Signed-off-by: Joel Fickson <joel.fickson@vercel.com>
VaguelySerious added a commit that referenced this pull request Oct 22, 2026
Closes #4903. `getViolationLocation` tested its import regex one line at a time, so an
import split across lines never matched. Biome and Prettier split any
import of three or more names. When the lookup fails, the plugin hands
the violation to the flow bundle backstop
(`assertFlowBundleIsSandboxSafe`, #4268) instead of reporting it.

This PR matches the import against the whole file and derives the line
from the match offset. Multi-line imports now get the plugin's error,
which points at the first use of the import in user code. The specifier
pattern is `[^;'"]+?`, not `[\s\S]+?`, so a match cannot start at an
earlier import statement and run on into this one.

Signed-off-by: Joel Fickson <joel.fickson@vercel.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Co-authored-by: Peter Wielander <peter.wielander@vercel.com>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
github-actions Bot added a commit that referenced this pull request Oct 22, 2026
Closes #4903. `getViolationLocation` tested its import regex one line at a time, so an
import split across lines never matched. Biome and Prettier split any
import of three or more names. When the lookup fails, the plugin hands
the violation to the flow bundle backstop
(`assertFlowBundleIsSandboxSafe`, #4268) instead of reporting it.

This PR matches the import against the whole file and derives the line
from the match offset. Multi-line imports now get the plugin's error,
which points at the first use of the import in user code. The specifier
pattern is `[^;'"]+?`, not `[\s\S]+?`, so a match cannot start at an
earlier import statement and run on into this one.

Signed-off-by: Joel Fickson <joel.fickson@vercel.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Co-authored-by: Peter Wielander <peter.wielander@vercel.com>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Joel Fickson <Joelngozo@gmail.com>

This branch was successfully deployed

18 active (17 outdated) deployments
Preview – workflow-docs — 3db42591 Deployed Sep 30, 2026 by vercel[bot]
Preview – workflow-swc-playground — cb5a709c Deployed Sep 24, 2026 by vercel[bot]
Preview – example-nextjs-workflow-webpack — cb5a709c Deployed Sep 24, 2026 by vercel[bot]
Preview – workbench-nuxt-workflow — cb5a709c Deployed Sep 24, 2026 by vercel[bot]
Preview – workbench-sveltekit-workflow — cb5a709c Deployed Sep 24, 2026 by vercel[bot]
Preview – workbench-astro-workflow — cb5a709c Deployed Sep 24, 2026 by vercel[bot]
Preview – example-workflow — cb5a709c Deployed Sep 24, 2026 by vercel[bot]
Preview – workbench-nestjs-workflow — cb5a709c Deployed Sep 24, 2026 by vercel[bot]
Preview – workbench-tanstack-start-workflow — cb5a709c Deployed Sep 24, 2026 by vercel[bot]
Preview – workbench-fastify-workflow — cb5a709c Deployed Sep 24, 2026 by vercel[bot]
Preview – workbench-express-workflow — cb5a709c Deployed Sep 24, 2026 by vercel[bot]
Preview – workbench-nitro-workflow — cb5a709c Deployed Sep 24, 2026 by vercel[bot]
Preview – example-nextjs-workflow-turbopack — cb5a709c Deployed Sep 24, 2026 by vercel[bot]
Preview – workbench-hono-workflow — cb5a709c Deployed Sep 24, 2026 by vercel[bot]
Preview – workbench-vite-workflow — cb5a709c Deployed Sep 24, 2026 by vercel[bot]
Preview – workflow-tarballs — cb5a709c Deployed Sep 24, 2026 by vercel[bot]
Preview – workflow-web — cb5a709c Deployed Sep 24, 2026 by vercel[bot]
Preview – workbench-python-workflow — cb5a709c Deployed Sep 24, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ReferenceError: require is not defined

3 participants