Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
c9a2f11
sdk side for workflow server websockets
vercel[bot] Aug 8, 2026
9944538
hardcoded workflow server
vercel[bot] Aug 8, 2026
48ce01c
debug info
vercel[bot] Aug 8, 2026
4652406
more debug
vercel[bot] Aug 8, 2026
bba29bc
remove unnecessary debug
vercel[bot] Aug 8, 2026
e2d42c6
default on websockets, and override url
vercel[bot] Aug 8, 2026
782654b
fix for missing funcs
vercel[bot] Aug 8, 2026
254a1e7
make websockets opt outo
vercel[bot] Aug 8, 2026
c8b6cd9
enable ws again
vercel[bot] Aug 8, 2026
56bac0a
[revert later] reduce test to single test, test both http and ws at t…
vercel[bot] Aug 8, 2026
1e9418b
empty
vercel[bot] Aug 8, 2026
93da2b7
Run full suite with and without ws
vercel[bot] Aug 8, 2026
e455eb5
empty
vercel[bot] Aug 8, 2026
98978cc
improve e2e test
vercel[bot] Aug 8, 2026
ca72293
minimize tests
vercel[bot] Aug 8, 2026
9dc3cd3
fallback to http when proxy present
vercel[bot] Aug 8, 2026
4690bd5
default to websockets, remove matrix
vercel[bot] Aug 8, 2026
8fb9d9b
remove smoke test
vercel[bot] Aug 8, 2026
b8f521e
update to new protocol
vercel[bot] Aug 8, 2026
a147706
adjust for new protocol (runid in path)
vercel[bot] Aug 8, 2026
1228472
fix ws transport error
vercel[bot] Aug 8, 2026
0b8d9bb
blank
vercel[bot] Aug 8, 2026
fb55e86
fix ws dep
vercel[bot] Aug 8, 2026
f9aa924
fix ws external: only accelerators, not ws itself
vercel[bot] Aug 8, 2026
fe80ddb
add dedicated WS-transport e2e job; flip WS default back to opt-in
vercel[bot] Aug 8, 2026
5032530
build all packages before local vercel build (needs workflow/nitro on…
vercel[bot] Aug 8, 2026
c67d974
force NITRO_PRESET=vercel for the local vercel build step
vercel[bot] Aug 8, 2026
f0f9b8d
install vercel CLI once instead of npx-ing it per command
vercel[bot] Aug 8, 2026
fabd095
add changeset for WS events transport
vercel[bot] Aug 8, 2026
f7ecda8
Harden the WS events transport and gate its e2e jobs
vercel[bot] Aug 8, 2026
db919d0
ci: re-trigger to confirm the prior e2e failures were flake
vercel[bot] Aug 8, 2026
d39c325
Add WS wire-contract conformance tests and pin the transport gate
vercel[bot] Aug 8, 2026
32bbb3a
match the HTTP RetryAgent's transient-failure policy on WS
vercel[bot] Aug 8, 2026
6694cdf
lazy-load `ws` so the default HTTP path never evaluates it
vercel[bot] Aug 8, 2026
df8dc38
release idle WS transports instead of renewing them forever
vercel[bot] Aug 8, 2026
95d6799
refresh the bearer on an auth_expiry drain
vercel[bot] Aug 8, 2026
99db44b
document the WS path's instrumentation gap
vercel[bot] Aug 8, 2026
adcbc4b
ship the ws-accelerator externals instead of documenting a workaround
vercel[bot] Aug 8, 2026
07cdfc0
one retry policy for both transports, and no unanswerable waiters
vercel[bot] Aug 8, 2026
d984235
Open the ws socket when the invocation starts, not on its first write
vercel[bot] Aug 8, 2026
0e89321
changeset: just the env var
vercel[bot] Aug 8, 2026
271a564
inline the ws-accelerator predicate at its only call site
vercel[bot] Aug 8, 2026
4ac2557
refactor(world-vercel): trim ws-transport comments
vercel[bot] Aug 8, 2026
79f2860
own transport selection in the transport module
vercel[bot] Aug 8, 2026
ad29424
import `ws` statically
vercel[bot] Aug 8, 2026
57b6177
tighten the comments on the ws transport
vercel[bot] Aug 8, 2026
b3ee568
inject W3C trace context on the ws upgrade
vercel[bot] Aug 8, 2026
0b62e6b
load the ws transport module only when it is enabled
vercel[bot] Aug 8, 2026
178a005
document WORKFLOW_EVENTS_TRANSPORT as experimental
vercel[bot] Aug 8, 2026
2bf2c1f
correct why the ws accelerators are externalized
vercel[bot] Aug 8, 2026
368c266
trim the WORKFLOW_EVENTS_TRANSPORT docs to user level
vercel[bot] Aug 8, 2026
9e5ecdb
cover the vite bundler in the ws transport lane
vercel[bot] Aug 8, 2026
b4c1d7f
claim only what is measured about rollup and the ws accelerators
vercel[bot] Aug 8, 2026
4921cef
Give the events socket an explicit lifetime instead of an idle timer
vercel[bot] Aug 8, 2026
c2f94eb
Name the one reply header the WS path does not map
vercel[bot] Aug 8, 2026
d9c934f
docs: note that WORKFLOW_EVENTS_TRANSPORT=ws is ignored on the proxy …
vercel[bot] Aug 8, 2026
a800f82
ci: gate the ws-transport e2e lanes on a label
vercel[bot] Aug 8, 2026
256a1c6
chore: regenerate pnpm-lock against current main
vercel[bot] Aug 8, 2026
27811ea
fix(world-vercel): align ws on the version main already resolves
vercel[bot] Aug 8, 2026
2a8d8c3
Bind the channel release to the instance it claimed
vercel[bot] Aug 8, 2026
37a5e8f
Decode a transport result, not a Response
vercel[bot] Aug 8, 2026
34ac1e9
Re-run CI
vercel[bot] Aug 8, 2026
b3ccab7
Re-run CI
vercel[bot] Aug 8, 2026
be15f05
Reconcile the WS transport with main's v4 POST rework
vercel[bot] Aug 8, 2026
6607983
Re-run CI
vercel[bot] Aug 8, 2026
e8f1d66
Re-run CI
vercel[bot] Aug 8, 2026
d1b3581
Re-run CI
vercel[bot] Aug 8, 2026
9bffb4b
blank
shalabhc Aug 9, 2026
edc10df
blank
shalabhc Aug 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/ws-events-transport.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@workflow/world-vercel': minor
'@workflow/builders': patch
'@workflow/rollup': patch
'@workflow/next': patch
---

Add an opt-in WebSocket transport for event writes during step execution, enabled with `WORKFLOW_EVENTS_TRANSPORT=ws`. Defaults to HTTP; no behavior change unless explicitly enabled.
2 changes: 2 additions & 0 deletions .github/scripts/aggregate-e2e-results.js
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,8 @@ function parseJobInfo(filename) {
'mongodb',
'redis',
'starter',
'nest',
'tanstack',
].some((app) => p.startsWith(app))
);

Expand Down
198 changes: 196 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,185 @@ jobs:
retention-days: 7
if-no-files-found: ignore

e2e-vercel-ws-transport:
# Dedicated coverage for world-vercel's WebSocket events transport
# (WORKFLOW_EVENTS_TRANSPORT=ws — see events-v4.ts's
# isWsEventsTransportEnabled). Every other job in this file exercises
# the default HTTP transport only, now that the WS-on-by-default POC
# hack is gone.
#
# Mirrors e2e-vercel-prod's shape (same apps subset, same full e2e
# suite) but deploys itself via a plain `vercel deploy` (no
# `--prebuilt`) instead of waiting for the GitHub App's passive
# deployment — that's the only way to get a *second*, independent
# deployment of a project we don't own the Environment Variables UI
# for, with its own env var, without creating a whole new project.
#
# Deliberately NOT `--prebuilt`: an earlier version of this job built
# locally on the runner via `vercel build --prebuilt` and hit a string
# of environment-specific bugs that don't reproduce on a real
# deployment (missing `serverExternalPackages` tracing for
# `@workflow/world-vercel`, nitro preset auto-detection picking
# `node-server` instead of `vercel`, needing the whole monorepo
# pre-built on this runner). Plain `vercel deploy` uploads source and
# builds remotely on Vercel's own infra — the exact same pipeline the
# GitHub App uses for e2e-vercel-prod's deployments — so none of that
# applies here.
# Opt-in on PRs, unconditional on main. Three real `vercel deploy`s per
# run is too much to charge every unrelated PR in the repo for a transport
# that is off by default; main still gets the signal on every commit.
# `workflow-server-test` counts as opt-in too — that label exists to test
# workflow-server changes, which is where the WS protocol lives.
#
# `on.pull_request.types` has no `labeled`, so a label added to an already
# open PR takes effect on the next push, not immediately.
name: E2E Vercel WS Transport Test (${{ matrix.app.name }})
runs-on: ubuntu-latest
timeout-minutes: 30
needs: ci-scope
if: >-
${{ needs.ci-scope.outputs.fast-path != 'true'
&& (github.event_name != 'pull_request'
|| contains(github.event.pull_request.labels.*.name, 'ws-transport-test')
|| contains(github.event.pull_request.labels.*.name, 'workflow-server-test')) }}
permissions:
id-token: write
contents: read
strategy:
fail-fast: false
matrix:
app:
- name: "example"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

There is no latency measurement for the WS path anywhere in the PR, even though latency is the motivation. benchmarks.yml never sets WORKFLOW_EVENTS_TRANSPORT and waits on the passive deployment, so the benchmark referenced in the Testing section is measuring HTTP. Since this job already stands up its own deployment with the flag set, it is the cheapest place to capture a before/after number worth deciding the default on.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Dropping a durabench screenshot here for reference. This force enabled websockets so there is promise. Shows consistent STSO p75 drop.

image

project-id: "prj_xWq20Dd860HHAfzMjK2Mb6TPVxMa"
project-slug: "example-workflow"
- name: "nextjs-turbopack"
project-id: "prj_yjkM7UdHliv8bfxZ1sMJQf1pMpdi"
project-slug: "example-nextjs-workflow-turbopack"
- name: "express"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

This lane covers example, nextjs-turbopack, and express: esbuild and Turbopack, the two paths this PR says need no fix. Neither webpack nor any Rollup-family app is covered, which inverts the risk.

I checked the Rollup claim empirically. Adding a kill switch to @workflow/rollup's externalization and rebuilding vite, nitro, nuxt, sveltekit, tanstack-start, and astro against the Vercel world: all six build green. Rollup never reports Could not resolve "bufferutil". Vite instead substitutes a silent stub:

const __viteOptionalPeerDep_bufferutil_ws_true = {};

That object does not throw, so ws's try { require('bufferutil') } catch {} never fires and bufferUtil.mask is left undefined. ws only calls the native masker at >= 48 bytes. Running the actual built chunk as a client:

payload without the externals fix with it
8 B ok ok
47 B ok ok
48 B bufferUtil$1.mask is not a function ok
1 KiB throws ok
64 KiB throws ok

So pre-fix this is total WS breakage on the Rollup family, and every CBOR event payload clears 48 bytes. Two consequences:

  1. No build check can catch this class, so "all workbenches build" is not evidence either way. The fix is genuinely load-bearing and should stay, but the rationale in optional-ws-native.ts describes a build failure that does not happen on any workbench here.
  2. A WS smoke test with a small payload would also pass. Please add at least one Rollup-family app to this matrix, or assert a >= 48-byte masked frame against a real socket in unit tests. Right now the only frameworks whose bundlers were changed are the ones with no WS coverage.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed the claim and rationale.
Also instead of a smoke test, added vite e2e test which should be better coverage for this.

project-id: "prj_cCZjpBy92VRbKHHbarDMhOHtkuIr"
project-slug: "workbench-express-workflow"
# Vite resolves `ws`'s absent optional accelerators to a stub
# instead of failing the require, so a build check can't see the
# breakage and only a masked frame at or above 48 bytes trips it
# (see WORKFLOW_OPTIONAL_WS_NATIVE_MODULES). Every CBOR event
# frame clears that, so this lane is the coverage for it.
- name: "vite"
project-id: "prj_uLIcNZNDmETulAvj5h0IcDHi5432"
project-slug: "workbench-vite-workflow"
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
WORKFLOW_PUBLIC_MANIFEST: '1'
WS_TEAM_ID: "team_nO2mCG4W8IxPIeKoSsqwAxxB"
steps:
- name: Checkout Repo
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}

- name: Setup environment
uses: ./.github/actions/setup-workflow-dev
with:
build-packages: 'false'

- name: Build CLI
run: pnpm turbo run build --filter='@workflow/cli'

# Pinned and installed once rather than `npx vercel@<version>` per
# command — repeated `npx` resolution showed inconsistent behavior
# (different Node version in the CLI's own banner, a doubled project
# path) across otherwise-identical earlier CI attempts.
- name: Install vercel CLI
run: npm install -g vercel@56.2.0

- name: Deploy a dedicated WS-transport preview
id: wsDeploy
env:
WS_VERCEL_TOKEN: ${{ secrets.VERCEL_LABS_TOKEN }}
run: |
set -euo pipefail
# `vercel deploy` (source-upload mode, no --prebuilt) re-applies
# the linked project's own Root Directory setting internally —
# unlike `vercel build`, it expects to run from the repo root and
# narrow down itself, the same way the GitHub App's own deploy
# does. Passing `--cwd=<app dir>` here double-joins that Root
# Directory underneath itself ("workbench/x/workbench/x does not
# exist"). Link and deploy from the repo root — no --cwd at all —
# and let the project's own Root Directory setting do the
# narrowing, matching how every passively-triggered deployment in
# this file already works.
vercel pull --yes --environment=preview \
--project="${{ matrix.app.project-id }}" \
--scope="$WS_TEAM_ID" --token="$WS_VERCEL_TOKEN"
# `vercel deploy` writes the new deployment's URL to stdout and
# progress/logs to stderr, specifically so it composes as
# `url=$(vercel deploy ...)` in scripts — do not redirect stderr
# into this capture. `--target=preview` is explicit here even
# though it's the default without `--prod` — these are shared,
# production-adjacent projects, and this shouldn't ever silently
# start deploying to their production aliases if a future edit
# changes flags around this call.
URL=$(vercel deploy --yes --target=preview \
-e WORKFLOW_EVENTS_TRANSPORT=ws \
--scope="$WS_TEAM_ID" --token="$WS_VERCEL_TOKEN")
echo "url=$URL" >> "$GITHUB_OUTPUT"
# Best-effort: only used by the runtime-logs capture on failure,
# which already skips gracefully if this doesn't parse.
DEPLOYMENT_ID=$(vercel inspect "$URL" --format=json \
--scope="$WS_TEAM_ID" --token="$WS_VERCEL_TOKEN" 2>/dev/null \
| node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{console.log(JSON.parse(d).id||'')}catch{}})" || true)
echo "deploymentId=$DEPLOYMENT_ID" >> "$GITHUB_OUTPUT"

- name: Record E2E start time
id: e2eStart
run: echo "ms=$(($(date +%s) * 1000))" >> "$GITHUB_OUTPUT"

- name: Run E2E Tests
run: pnpm run test:e2e --reporter=verbose --reporter=json --reporter=./packages/core/e2e/github-reporter.ts "--outputFile=e2e-vercel-ws-transport-$APP_NAME.json"
env:
NODE_OPTIONS: "--enable-source-maps"
DEPLOYMENT_URL: ${{ steps.wsDeploy.outputs.url }}
VERCEL_DEPLOYMENT_ID: ${{ steps.wsDeploy.outputs.deploymentId }}
APP_NAME: ${{ matrix.app.name }}
WORKFLOW_VERCEL_ENV: "preview"
WORKFLOW_VERCEL_AUTH_TOKEN: ${{ secrets.VERCEL_LABS_TOKEN }}
WORKFLOW_VERCEL_TEAM: ${{ env.WS_TEAM_ID }}
WORKFLOW_VERCEL_PROJECT: ${{ matrix.app.project-id }}
WORKFLOW_VERCEL_PROJECT_SLUG: ${{ matrix.app.project-slug }}
VERCEL_WORKFLOW_SERVER_URL: ${{ github.ref != 'refs/heads/main' && secrets.VERCEL_WORKFLOW_SERVER_URL || '' }}

- name: Capture runtime logs on failure
if: failure()
env:
APP_NAME: ${{ matrix.app.name }}
WORKFLOW_VERCEL_AUTH_TOKEN: ${{ secrets.VERCEL_LABS_TOKEN }}
WORKFLOW_VERCEL_TEAM: ${{ env.WS_TEAM_ID }}
WORKFLOW_VERCEL_PROJECT: ${{ matrix.app.project-id }}
WORKFLOW_VERCEL_ENV: "preview"
VERCEL_DEPLOYMENT_ID: ${{ steps.wsDeploy.outputs.deploymentId }}
E2E_START_MS: ${{ steps.e2eStart.outputs.ms }}
run: node .github/scripts/fetch-e2e-runtime-logs.mjs

- name: Generate E2E summary
if: always()
env:
APP_NAME: ${{ matrix.app.name }}
run: node .github/scripts/aggregate-e2e-results.js . --job-name "E2E Vercel WS Transport ($APP_NAME)" >> $GITHUB_STEP_SUMMARY || true

- name: Upload E2E results
if: always()
uses: actions/upload-artifact@v4
with:
name: e2e-results-vercel-ws-transport-${{ matrix.app.name }}
path: |
e2e-vercel-ws-transport-${{ matrix.app.name }}.json
e2e-metadata-${{ matrix.app.name }}-vercel.json
e2e-failures-${{ matrix.app.name }}-vercel.json
e2e-diagnostics-${{ matrix.app.name }}-vercel.json
e2e-runtime-logs-${{ matrix.app.name }}-vercel.json
retention-days: 7
if-no-files-found: ignore

getTestMatrix:
name: Get Test Matrix
runs-on: ubuntu-latest
Expand Down Expand Up @@ -1129,7 +1308,7 @@ jobs:
summary:
name: E2E Summary
runs-on: ubuntu-latest
needs: [ci-scope, e2e-vercel-prod, e2e-local-dev, e2e-local-prod, e2e-local-postgres, e2e-windows]
needs: [ci-scope, e2e-vercel-prod, e2e-vercel-ws-transport, e2e-local-dev, e2e-local-prod, e2e-local-postgres, e2e-windows]
if: always() && !cancelled() && needs.ci-scope.outputs.fast-path != 'true'
timeout-minutes: 10

Expand Down Expand Up @@ -1165,7 +1344,7 @@ jobs:
e2e-required-check:
name: E2E Required Check
runs-on: ubuntu-latest
needs: [ci-scope, unit, e2e-package-build, e2e-vercel-prod, e2e-local-dev, e2e-local-prod, e2e-local-postgres, e2e-windows]
needs: [ci-scope, unit, e2e-package-build, e2e-vercel-prod, e2e-vercel-ws-transport, e2e-local-dev, e2e-local-prod, e2e-local-postgres, e2e-windows]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

This adds three live vercel deploy lanes to the required aggregate for every PR in the repo, including PRs that touch nothing in this area. Given how often the existing Vercel prod lanes go red for reasons unrelated to the change under test, this raises the false-red rate for everyone. Gating the job on a label or on paths touching packages/world-vercel / the bundler integrations would keep the signal without making every unrelated PR wait on three extra deployments.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Now 4 new lanes.

Gated on a new label ws-transport-test which has now been added to this PR.

if: always()
timeout-minutes: 5

Expand All @@ -1175,13 +1354,20 @@ jobs:
UNIT_STATUS: ${{ needs.unit.result }}
BUILD_STATUS: ${{ needs.e2e-package-build.result }}
VERCEL_STATUS: ${{ needs.e2e-vercel-prod.result }}
VERCEL_WS_STATUS: ${{ needs.e2e-vercel-ws-transport.result }}
LOCAL_DEV_STATUS: ${{ needs.e2e-local-dev.result }}
LOCAL_PROD_STATUS: ${{ needs.e2e-local-prod.result }}
POSTGRES_STATUS: ${{ needs.e2e-local-postgres.result }}
WINDOWS_STATUS: ${{ needs.e2e-windows.result }}
FAST_PATH: ${{ needs.ci-scope.outputs.fast-path }}
VALIDATION_FAST_PATH: ${{ needs.ci-scope.outputs.validation-fast-path }}
HAS_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'workflow-server-test') }}
# e2e-vercel-ws-transport runs on main and on labelled PRs only, so
# its status is required in exactly those cases and must be allowed
# to be `skipped` otherwise.
WS_REQUIRED: ${{ github.event_name != 'pull_request'
|| contains(github.event.pull_request.labels.*.name, 'ws-transport-test')
|| contains(github.event.pull_request.labels.*.name, 'workflow-server-test') }}
run: |
FAILED_JOBS=()

Expand All @@ -1199,6 +1385,9 @@ jobs:
if [[ "$HAS_LABEL" == "true" ]]; then
echo "workflow-server-test label detected - only checking required jobs"
[[ "$VERCEL_STATUS" == "success" ]] || FAILED_JOBS+=("e2e-vercel-prod ($VERCEL_STATUS)")
# This label is itself opt-in for the WS lane (see that job's `if`),
# so it runs here and is required.
[[ "$VERCEL_WS_STATUS" == "success" ]] || FAILED_JOBS+=("e2e-vercel-ws-transport ($VERCEL_WS_STATUS)")

# Verify other jobs were actually skipped
[[ "$UNIT_STATUS" == "skipped" ]] || echo "Warning: unit was not skipped ($UNIT_STATUS)"
Expand All @@ -1211,6 +1400,11 @@ jobs:
[[ "$UNIT_STATUS" == "success" ]] || FAILED_JOBS+=("unit ($UNIT_STATUS)")
[[ "$BUILD_STATUS" == "success" ]] || FAILED_JOBS+=("e2e-package-build ($BUILD_STATUS)")
[[ "$VERCEL_STATUS" == "success" ]] || FAILED_JOBS+=("e2e-vercel-prod ($VERCEL_STATUS)")
if [[ "$WS_REQUIRED" == "true" ]]; then
[[ "$VERCEL_WS_STATUS" == "success" ]] || FAILED_JOBS+=("e2e-vercel-ws-transport ($VERCEL_WS_STATUS)")
else
[[ "$VERCEL_WS_STATUS" == "skipped" ]] || echo "Warning: e2e-vercel-ws-transport ran unlabelled ($VERCEL_WS_STATUS)"
fi
[[ "$LOCAL_DEV_STATUS" == "success" ]] || FAILED_JOBS+=("e2e-local-dev ($LOCAL_DEV_STATUS)")
[[ "$LOCAL_PROD_STATUS" == "success" ]] || FAILED_JOBS+=("e2e-local-prod ($LOCAL_PROD_STATUS)")
[[ "$POSTGRES_STATUS" == "success" ]] || FAILED_JOBS+=("e2e-local-postgres ($POSTGRES_STATUS)")
Expand Down
8 changes: 8 additions & 0 deletions docs/content/docs/v5/configuration/worlds.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -273,3 +273,11 @@ Platform-provided values such as `VERCEL_DEPLOYMENT_ID`, `VERCEL_PROJECT_ID`, an
- CLI flag: none
- Default: `1000`
- Maximum stream chunks written in one Vercel World request. Larger batches are split.

### `WORKFLOW_EVENTS_TRANSPORT`

- Factory option: none
- CLI flag: none
- Default: `http`
- Experimental. Set to `ws` to ship workflow run events to the Vercel World over a WebSocket instead of one HTTP request each.
- Ignored when the World is configured with `projectConfig` and routes through the `api-workflow` proxy — that endpoint is an HTTP-only REST gateway and does not forward a WebSocket upgrade, so events stay on HTTP.
6 changes: 6 additions & 0 deletions docs/content/worlds/v5/vercel.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,12 @@ Per-request timeout, in milliseconds, for Vercel World HTTP calls to workflow-se

Maximum stream chunks written in one Vercel World request. Larger batches are split across multiple requests. Default: `1000`. Minimum: `1`.

### `WORKFLOW_EVENTS_TRANSPORT`

Experimental. Set `WORKFLOW_EVENTS_TRANSPORT=ws` to ship workflow run events to the Vercel World over a WebSocket instead of one HTTP request each. Default: `http`.

The setting is ignored when the World is configured with `projectConfig` and therefore routes through the `api-workflow` proxy: that endpoint is an HTTP-only REST gateway and does not forward a WebSocket upgrade, so events stay on HTTP and a warning is logged once per process.

### Programmatic configuration

`createWorld()` accepts explicit API configuration. It does not read `WORKFLOW_VERCEL_*` automatically, so pass the environment values yourself when you want a configured World module:
Expand Down
1 change: 1 addition & 0 deletions packages/builders/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export {
export { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js';
export { WORKFLOW_OPTIONAL_OTEL_API_MODULE } from './optional-otel-api.js';
export { WORKFLOW_OPTIONAL_TYPESCRIPT_ALIAS } from './optional-typescript-alias.js';
export { WORKFLOW_OPTIONAL_WS_NATIVE_MODULES } from './optional-ws-native.js';
export {
createPseudoPackagePlugin,
PSEUDO_PACKAGES,
Expand Down
32 changes: 32 additions & 0 deletions packages/builders/src/optional-ws-native.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* `bufferutil` and `utf-8-validate` are optional native accelerators for the
* `ws` package, which `@workflow/world-vercel` uses for its WebSocket events
* transport. Neither is installed by default: `ws` requires each one inside a
* try/catch and falls back to a pure-JS implementation when the require throws.
*
* Marking them external keeps that fallback reachable. Two bundlers take it away
* without changing the build's outcome, so the damage only shows up at runtime,
* on the first frame big enough to reach the native masker (`ws` uses pure JS
* below 48 bytes — small-payload smoke tests pass; CBOR event frames don't):
*
* - **webpack** bundles the JS wrapper without its native `.node` binding.
* - **Vite** resolves the absent peer to its own `optional-peer-dep` stub, so
* `ws`'s require *succeeds* and the try/catch never fires.
*
* Both end at `bufferUtil.mask is not a function`. Rollup-family setups are not
* known to break: nothing substitutes a stub, and nitro traces and externalizes
* `ws` in a production build, so the require stays a real one that fails and the
* fallback engages. Externalizing there buys determinism against a change in
* externals policy, not a fix for a known break. (Turbopack already externalizes
* Node packages.)
*
* Externalized unconditionally, unlike `WORKFLOW_OPTIONAL_OTEL_API_MODULE`,
* which is only externalized when it can't be resolved. The OTEL API has to
* load for tracing to work, so a self-contained output must bundle it when
* present; these accelerators must specifically *not* load, so a failed runtime
* require is the designed path and a half-bundled native module is not.
*/
export const WORKFLOW_OPTIONAL_WS_NATIVE_MODULES = [
'bufferutil',
'utf-8-validate',
] as const;
25 changes: 25 additions & 0 deletions packages/next/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { dirname, isAbsolute, join } from 'node:path';
import {
resolveConfiguredProjectRoot,
resolveProjectRoot,
WORKFLOW_OPTIONAL_WS_NATIVE_MODULES,
} from '@workflow/builders';
import type { NextConfig } from 'next';
import semver from 'semver';
Expand Down Expand Up @@ -321,6 +322,26 @@ function registerWorkflowDiagnosticsManifestCopy(metadata: {
globalWithMarker[marker].push(metadata);
}

/**
* Mark `ws`'s optional native accelerators external on the webpack server
* build, which otherwise bundles their JS wrapper without the native `.node`
* binding and throws "bufferUtil.mask is not a function" at runtime. See
* `WORKFLOW_OPTIONAL_WS_NATIVE_MODULES`; `@workflow/rollup` handles the
* Rollup/Vite/Nitro side.
*/
function externalizeWsNativeAccelerators(webpackConfig: {
externals?: unknown;
}): void {
const names = [...WORKFLOW_OPTIONAL_WS_NATIVE_MODULES];
if (Array.isArray(webpackConfig.externals)) {
webpackConfig.externals.push(...names);
} else if (webpackConfig.externals) {
webpackConfig.externals = [webpackConfig.externals, ...names];
} else {
webpackConfig.externals = names;
}
}

export function withWorkflow(
nextConfigOrFn:
| NextConfig
Expand Down Expand Up @@ -571,6 +592,10 @@ export function withWorkflow(
loader: loaderPath,
});

if (args[1]?.isServer) {
externalizeWsNativeAccelerators(webpackConfig);
}

return existingWebpackModify
? (existingWebpackModify(...args) ?? webpackConfig)
: webpackConfig;
Expand Down
Loading
Loading