Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/events-transport-default-http.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflow/world-vercel': minor
---

Default the events transport back to HTTP. The WebSocket events transport is now opt-in with `WORKFLOW_EVENTS_TRANSPORT=ws`; any other value, including unset or `http`, uses HTTP.
30 changes: 14 additions & 16 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -556,10 +556,10 @@ jobs:
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 Vercel lane inherits the
# default transport but checks nothing; this is the only lane that
# *asserts* the socket carried the events. See
# e2e-vercel-http-transport below for the other side.
# isWsEventsTransportEnabled). The transport is opt-in, so every other
# Vercel lane runs on the HTTP default; this is the only lane that
# exercises the socket, and it *asserts* the socket carried the events.
# See e2e-vercel-http-transport below for the other side.
#
# Runs the full e2e suite against its own `vercel deploy` rather than the
# Git deployment: that is the only way to get a second, independent
Expand Down Expand Up @@ -651,16 +651,16 @@ jobs:
e2e-start-ms: ${{ steps.e2eStart.outputs.ms }}

e2e-vercel-http-transport:
# The counterpart to e2e-vercel-ws-transport. With
# WORKFLOW_EVENTS_TRANSPORT defaulting to `ws`, e2e-vercel-prod — which
# sets no transport env var at all — is a WebSocket lane, so nothing else
# exercises the HTTP events transport against a real Vercel deployment.
# The counterpart to e2e-vercel-ws-transport. WORKFLOW_EVENTS_TRANSPORT
# now defaults to `http`, so e2e-vercel-prod — which sets no transport
# env var at all — also runs on HTTP. This lane pins the explicit
# `http` value on a dedicated deployment so the HTTP transport stays
# covered regardless of where the default sits.
#
# HTTP is the fallback path, and the fallback is *silent*:
# HTTP is also the fallback path, and the fallback is *silent*:
# resolveWsTransport returning null costs a write nothing and logs
# nothing. A path that is both unexercised and quiet is the exact
# shape of the durabench bug this lane came out of, so it is
# unconditional and required.
# nothing. A path that is quiet is the exact shape of the durabench bug
# this lane came out of, so it is unconditional and required.
#
# Six apps, chosen by server shape rather than by count; each row is a
# real `vercel deploy` charged to every PR, so the full 14 is too
Expand Down Expand Up @@ -714,10 +714,8 @@ jobs:
- name: Build CLI
run: pnpm turbo run build --filter='@workflow/cli'

# `http` is the one value that opts out; the gate compares against
# exactly this string, so a typo here would silently test the default
# instead of the fallback and this lane would quietly become a
# duplicate of e2e-vercel-prod.
# `http` is the default; set explicitly so this lane keeps testing HTTP
# even if the default moves again.
- name: Deploy a dedicated HTTP-transport preview
id: deployment
uses: ./.github/actions/deploy-vercel-preview
Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/v5/configuration/runtime-tuning.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL

Node's own modules do less than the client they replace, so enabling this drops the per-call-site tuning the Worlds configure:

- Event-log requests lose HTTP/2, so concurrent reads and writes no longer share one connection, and the enlarged HTTP/2 receive windows no longer apply. This is the largest difference, and it slows down replays that read a big event log. It does not apply to event writes on the [WebSocket events transport](/docs/configuration/worlds#workflow_events_transport), which is the default and takes neither transport.
- Event-log requests lose HTTP/2, so concurrent reads and writes no longer share one connection, and the enlarged HTTP/2 receive windows no longer apply. This is the largest difference, and it slows down replays that read a big event log. It does not apply to event writes on the opt-in [WebSocket events transport](/docs/configuration/worlds#workflow_events_transport), which takes neither transport.
- Requests lose their transport-level retry. Failures still surface to the layers above, which retry event writes and redeliver queue messages, so nothing is silently dropped, but a failure that a same-connection retry would have hidden now costs a full redelivery.
- Stream close loses its retry of retriable server errors. A transient failure at close can leave a stream marked closing until the run expires, where it would previously have resolved on the retry.

Expand Down
4 changes: 2 additions & 2 deletions docs/content/docs/v5/configuration/worlds.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,6 @@ When enabled (the default), a suspension's eager `step_created` and `wait_create

- Factory option: none
- CLI flag: none
- Default: `ws`
- Ships workflow run events to the Vercel World over a WebSocket instead of one HTTP request each. Set to exactly `http` to opt out; any other value, including unset or empty, uses the WebSocket.
- Default: `http`
- Set to `ws` to ship workflow run events to the Vercel World over a WebSocket instead of one HTTP request each. Only `ws` (case-insensitive) opts in; any other value, including unset, empty, or `http`, keeps HTTP.
- 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: 3 additions & 3 deletions docs/content/worlds/v5/vercel.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -252,11 +252,11 @@ Before routine authentication expiry or server max duration, the server sends a

### `WORKFLOW_EVENTS_TRANSPORT`

Workflow run events ship to the Vercel World over a WebSocket instead of one HTTP request each. Default: `ws`.
Opt-in WebSocket transport for workflow run events, which ships them to the Vercel World over one socket per run instead of one HTTP request each. Default: `http`.

Set `WORKFLOW_EVENTS_TRANSPORT=http` to opt out. Only that exact value disables the WebSocket — any other value, including unset or empty, takes it — so a typo fails toward the default rather than silently pinning a deployment to HTTP.
Set `WORKFLOW_EVENTS_TRANSPORT=ws` to opt in. Only that value (case-insensitive) enables the WebSocket — any other value, including unset, empty, or `http`, keeps HTTP — so a typo fails toward the default rather than enabling a transport nobody asked for.

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. The fallback is silent — now that the WebSocket is the default, nothing asked for it — and is reported once per process under `DEBUG=workflow:*`. `workflow.events.transport` on the per-write span records which transport actually carried a run.
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. The fallback is silent, because the variable is typically set deployment-wide and a `projectConfig` World (such as the CLI) cannot act on it, and is reported once per process under `DEBUG=workflow:*`. `workflow.events.transport` on the per-write span records which transport actually carried a run.

Tracing is unaffected by the choice. Each event write emits an `http POST` client span against the same `url.full`, regardless of which transport carries it. On the WebSocket path, the span is synthesized around the frame because no HTTP request is made. Attributes distinguish the transports:

Expand Down
6 changes: 3 additions & 3 deletions packages/world-vercel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ See [Backend connection failures](https://workflow-sdk.dev/docs/foundations/erro

## Events channel

Event writes go over a per-run WebSocket (the default `WORKFLOW_EVENTS_TRANSPORT`)
only while that run's channel is open. The queue handler opens it for each
delivery, so workflows need nothing extra. Code that writes a run's events
With `WORKFLOW_EVENTS_TRANSPORT=ws` (opt-in; the default is HTTP), event
writes go over a per-run WebSocket only while that run's channel is open. The
queue handler opens it for each delivery, so workflows need nothing extra. Code that writes a run's events
outside a delivery (a custom driver, a long-lived process) opens and releases
it itself; otherwise those writes go over HTTP:

Expand Down
11 changes: 9 additions & 2 deletions packages/world-vercel/src/events-channel-export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,22 @@ describe('openEventsChannel', () => {
expect(typeof openEventsChannel).toBe('function');
});

it('returns undefined, so writes stay on HTTP, when the transport is disabled', () => {
it('returns undefined, so writes stay on HTTP, when the transport is set to http', () => {
process.env.WORKFLOW_EVENTS_TRANSPORT = 'http';
expect(
openEventsChannel('wrun_01ARZ3NDEKTSV4RRFFQ69G5FAV')
).toBeUndefined();
});

it('returns undefined for a projectConfig World, which cannot hold a socket', () => {
it('returns undefined by default, so writes stay on HTTP', () => {
delete process.env.WORKFLOW_EVENTS_TRANSPORT;
expect(
openEventsChannel('wrun_01ARZ3NDEKTSV4RRFFQ69G5FAV')
).toBeUndefined();
});

it('returns undefined for a projectConfig World, which cannot hold a socket', () => {
process.env.WORKFLOW_EVENTS_TRANSPORT = 'ws';
expect(
openEventsChannel('wrun_01ARZ3NDEKTSV4RRFFQ69G5FAV', {
projectConfig: { projectId: 'prj_test', teamId: 'team_test' },
Expand Down
9 changes: 4 additions & 5 deletions packages/world-vercel/src/events-v4-ws.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,7 @@ afterEach(() => {
* The gate is the whole safety story for this feature: everything else in
* the PR is dead code for anyone who hasn't opted in. `events-v4.test.ts`
* covers the HTTP path itself in depth, but nothing there pins the
* *choice* of path — so a future edit that flipped the default (as an
* earlier revision of this branch did deliberately, for benchmarking)
* *choice* of path — so a future edit that flipped the default
* would sail through with every HTTP assertion still green, because the
* two transports are built to be indistinguishable at the result layer.
*/
Expand Down Expand Up @@ -210,9 +209,9 @@ describe('strict fallback (WORKFLOW_INTERNAL_EVENTS_TRANSPORT_STRICT)', () => {

describe('transport gate', () => {
it('goes over HTTP, never touching the WS transport, when the gate is off', async () => {
// "Off" is now an explicit opt-out rather than an absent variable, since
// the default flipped. Deleting it here would assert the opposite of what
// this test is named for.
// An explicit `http` rather than an absent variable, so this pins the
// opt-out value as well as the default; `beforeEach` sets `ws` for the
// rest of the file.
process.env.WORKFLOW_EVENTS_TRANSPORT = 'http';
const origin =
WORKFLOW_SERVER_URL_OVERRIDE || 'https://vercel-workflow.com';
Expand Down
44 changes: 17 additions & 27 deletions packages/world-vercel/src/ws-transport-enabled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,45 +8,34 @@
*/

/**
* WS unless `WORKFLOW_EVENTS_TRANSPORT=http`. Only `createWorkflowRunEventV4`
* HTTP unless `WORKFLOW_EVENTS_TRANSPORT=ws`. Only `createWorkflowRunEventV4`
* (POST) is wired to it. GET/LIST aren't on the hot per-step path, and LIST's
* streamed, sentinel-terminated multi-frame response doesn't map onto a single
* WS message.
*
* This file used to name a prerequisite for defaulting on: that a WS write opens
* no client span. That is met. `postEventFrameOverWs` opens one per frame,
* carrying `workflow.events.transport: 'ws'`, `network.protocol.name` and the
* `workflow.events.ws.req_id` that joins it to the server's log line. What
* remains absent is Vercel's *outgoing requests* view, which is built by
* A WS write still opens a client span: `postEventFrameOverWs` opens one per
* frame, carrying `workflow.events.transport: 'ws'`, `network.protocol.name`
* and the `workflow.events.ws.req_id` that joins it to the server's log line.
* What remains absent is Vercel's *outgoing requests* view, which is built by
* instrumenting the global `fetch` rather than by reading spans, and which a
* transport whose purpose is to issue no request cannot appear in.
*
* `http` is the only value that opts out, rather than "anything that isn't
* `ws`". An unrecognized value takes the default instead of quietly pinning a
* deployment to the old transport.
*
* That opt-out is matched case-insensitively and trimmed, which is the one
* place this gate deliberately does *not* fail toward the default. Everything
* else here is written on the assumption that being quietly on the wrong
* transport is the failure mode to design against, and the reader most exposed
* to it is whoever is reaching for the escape hatch: plausibly mid-incident,
* plausibly typing `HTTP` into a dashboard field. Silently ignoring their
* opt-out because of case is the same bug this default flip is trying to stop
* shipping, pointed at the person least able to afford it.
* `ws` is the only value that opts in. Anything else — unset, empty, `http`,
* or an unrecognized value — stays on HTTP, so a typo fails toward the
* default rather than enabling a transport nobody asked for. The opt-in is
* matched case-insensitively and trimmed, so `WS` typed into a dashboard field
* is not silently ignored.
*/
export function isWsEventsTransportEnabled(): boolean {
return process.env.WORKFLOW_EVENTS_TRANSPORT?.trim().toLowerCase() !== 'http';
return process.env.WORKFLOW_EVENTS_TRANSPORT?.trim().toLowerCase() === 'ws';
}

/**
* Whether a WS fallback that should not happen must fail loudly instead of
* quietly writing over HTTP. Internal, undocumented, and meant for the WS e2e
* lane, which otherwise passes whether or not the socket carried anything.
*
* Note the asymmetry with the gate above, which is deliberate and the opposite
* way round. There, an unrecognized value takes the default, because the risk
* is a deployment quietly sitting on the wrong transport. Here an unrecognized
* value means *off*, because the risk runs the other way: this turns a silent
* Like the gate above, an unrecognized value means *off*: this turns a silent
* degradation into a failed run, and nobody should acquire that by typo.
*/
export function isWsEventsTransportStrict(): boolean {
Expand All @@ -60,10 +49,11 @@ export function isWsEventsTransportStrict(): boolean {
* server authoritatively accepts or declines every upgrade, and a decline
* falls back directly to the HTTP stream writer.
*
* HTTP is the compatibility path and the default. Unlike the default-on events
* gate above, this opt-in is exact-match: a typo must fail toward HTTP rather
* than unexpectedly enabling an experimental transport. This deliberately has
* no package-version or tenant-policy heuristic; rollout policy belongs to the
* HTTP is the compatibility path and the default. Unlike the events gate
* above, this opt-in is exact-match (case-sensitive, untrimmed): a typo must
* fail toward HTTP rather than unexpectedly enabling an experimental
* transport. This deliberately has no package-version or tenant-policy
* heuristic; rollout policy belongs to the
* server. v1 is `/websockets/v1`, independently versioned from REST v2/v4 and
* persisted workflow spec versions.
*/
Expand Down
8 changes: 4 additions & 4 deletions packages/world-vercel/src/ws-transport-spans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,10 +513,10 @@ describe('connection span', () => {

describe('transport parity', () => {
it('does not tag an HTTP event read as an event-write transport', async () => {
// Explicit opt-out rather than an absent variable: the default is ws now,
// and this test is about the HTTP path. A read would take HTTP either way
// (only the POST write is wired to the socket), so leaving this unset
// would still pass — while no longer testing what it says it does.
// Explicitly HTTP, overriding the suite's `ws`: this test is about the
// HTTP path. A read would take HTTP either way (only the POST write is
// wired to the socket), so leaving `ws` set would still pass — while no
// longer testing what it says it does.
process.env.WORKFLOW_EVENTS_TRANSPORT = 'http';
const agent = new MockAgent();
agent.disableNetConnect();
Expand Down
Loading
Loading