[world-vercel] Switch event endpoints to v4 wire format#2055
[world-vercel] Switch event endpoints to v4 wire format#2055VaguelySerious wants to merge 11 commits into
Conversation
Mirrors the v4 server-side handlers landing in workflow-server. The
v4 wire format moves event metadata into x-wf-* request/response
headers and treats payloads as opaque user-data bytes (streamed
end-to-end). The SDK passes Uint8Array bytes through unchanged at
this layer; higher-level world-vercel adapter glue handles CBOR.
Adds:
- packages/world-vercel/src/frames.ts: encoder + async-iterable
decoder for the length-prefixed binary frame format used by the
v4 list-events response.
- packages/world-vercel/src/events-v4.ts: three new functions:
* createWorkflowRunEventV4 — POST with x-wf-* headers + payload
bytes, returns event/run ids and timestamp from response
headers.
* getEventV4 — GET single event, returns metadata + body bytes.
* getWorkflowRunEventsV4 — GET list, parses frame stream, returns
events + pagination cursor.
- V4_HEADERS exported as the canonical name map; mirrors the
server-side constant.
V4 client characteristics:
- Required runId in URL for run_created too (no /runs/null/events
shortcut; the runId is part of the S3 key the server allocates).
Higher-level callers generate the ULID client-side.
- Payload bytes flow through without CBOR encode/decode on this
layer. Callers CBOR-encode for parity with v3 if they want.
- Pagination cursor surfaces in the LIST response — eliminates the
per-large-payload /refs round-trip used by v2/v3.
Tests (10 new in src/frames.test.ts, no new e2e):
- Canonical wire layout round-trip.
- Multi-frame round-trip with pagination cursor.
- Decoder survives 1-byte chunk delivery (matching spike B's chunk-
boundary robustness requirement).
- 64 KB body split across many small chunks.
- Bodies containing 0xff padding don't mis-frame.
- Back-to-back frames in a single chunk.
- Truncated stream raises.
- Meta CBOR types (numbers, booleans, arrays) preserved.
The world-vercel adapter still defaults to the v3 path; v4 is exposed
for direct callers and a follow-up PR will switch the adapter over
once the matching server-side PR is on staging.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: a2dac63 The changes in this PR will be included in the next version bump. This PR includes changesets to release 17 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 |
🧪 E2E Test Results❌ Some tests failed Summary
❌ Failed Tests▲ Vercel Production (515 failed)astro (40 failed):
example (38 failed):
express (40 failed):
fastify (39 failed):
hono (41 failed):
nextjs-turbopack (44 failed):
nextjs-webpack (115 failed):
nitro (41 failed):
nuxt (39 failed):
sveltekit (39 failed):
vite (39 failed):
📋 Other (40 failed)e2e-vercel-prod-tanstack-start (40 failed):
Details by Category❌ ▲ Vercel Production
✅ 💻 Local Development
✅ 📦 Local Production
✅ 🐘 Local Postgres
✅ 🪟 Windows
❌ 📋 Other
❌ Some E2E test jobs failed:
Check the workflow run for details. |
📊 Benchmark Results
workflow with no steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) workflow with 1 step💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) workflow with 10 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) workflow with 25 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) workflow with 50 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) Promise.all with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) Promise.all with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) Promise.all with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) Promise.race with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) Promise.race with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) Promise.race with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) workflow with 10 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) workflow with 25 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) workflow with 50 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) workflow with 10 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) workflow with 25 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) workflow with 50 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) Stream Benchmarks (includes TTFB metrics)workflow with stream💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) stream pipeline with 5 transform steps (1MB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) 10 parallel streams (1MB each)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) fan-out fan-in 10 streams (1MB each)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) SummaryFastest Framework by WorldWinner determined by most benchmark wins
Fastest World by FrameworkWinner determined by most benchmark wins
Column Definitions
Worlds:
❌ Some benchmark jobs failed:
Check the workflow run for details. |
Sets WORKFLOW_SERVER_URL_OVERRIDE in packages/world-vercel/src/utils.ts to https://workflow-server-git-peter-v4.vercel.sh so that e2e tests running off this SDK branch exercise the v4-enabled workflow-server preview instead of production. The override is the inline mechanism documented at the constant — when set, it wins over both the default (https://vercel-workflow.com) and the VERCEL_WORKFLOW_SERVER_URL env var. The same pattern is used in v4 testing on the workflow- server side: CI rewrites this string on PR branches. Reset to '' before merging to main. Companion to vercel/workflow-server#439. Updates four tests in utils.test.ts that previously assumed the override is empty. Each affected assertion gets a comment noting what the expectation looks like on main; flipping back to the main behavior is a one-line edit per test when the override is reset. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…CBOR The matching server-side change (workflow-server PR #439, commit 5d79cf1) now returns: - GET single event: full event entity CBOR-encoded in the body (resolves refs server-side and bakes payload into eventData). - LIST events: each frame's meta is the full event entity (CBOR), payload stays as a RefDescriptor in eventData[field], resolved bytes ride in the frame body. This commit threads that through the world-vercel adapter: - events-v4.ts: * getEventV4 returns DecodedV4Event (CBOR-decode of response body). Drop the parseEventMetaFromHeaders / readHeader-driven reconstruction. * ListedEventV4 carries `{ event: DecodedV4Event, body: Uint8Array }` — the full entity plus the resolved payload bytes to splice in. - events.ts: * buildEventFromV4 takes (decoded entity, payload bytes) and splices the bytes into eventData[payloadField] for the LIST path. For the GET path the server already baked the bytes in, so buildEventFromV4 is called with an empty body. * CBOR-decode the payload bytes back into the original JS value on read. Matches the unconditional CBOR-encode on write (Uint8Array round-trips via cbor-x's binary type). Why this matters: the workflow runtime's replay path reads arbitrary fields off eventData (executionContext, hookToken, isWebhook, resumeAt, error shape, …). The previous cherry-picked-metadata shape dropped those fields, which is why every E2E Vercel Prod test on this branch was getting stuck after run_started with no further events — the runtime's invocation of the workflow function on the workbench deployment couldn't reconstruct state correctly. Companion to workflow-server PR #439 commit 5d79cf1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| @@ -0,0 +1,5 @@ | |||
| --- | |||
| "@workflow/world-vercel": major | |||
There was a problem hiding this comment.
| "@workflow/world-vercel": major | |
| "@workflow/world-vercel": minor |
| "@workflow/world-vercel": major | ||
| --- | ||
|
|
||
| Switch the world-vercel adapter's event endpoints from the v2/v3 wire format to v4. Event metadata now rides in `x-wf-*` HTTP headers and payloads stream end-to-end as opaque bytes — no server-side CBOR parse on writes, and no per-event `/refs` round-trip on list responses. POST event response carries the materialized EventResult as a CBOR body. Public `createWorkflowRunEvent` / `getEvent` / `getWorkflowRunEvents` signatures are unchanged; the underlying wire calls swap to v4. `listEventsByCorrelationId` is not yet implemented on v4 and now throws — callers should fetch hooks directly via `storage.hooks.getByToken`. Requires workflow-server with v4 routes mounted. |
There was a problem hiding this comment.
| Switch the world-vercel adapter's event endpoints from the v2/v3 wire format to v4. Event metadata now rides in `x-wf-*` HTTP headers and payloads stream end-to-end as opaque bytes — no server-side CBOR parse on writes, and no per-event `/refs` round-trip on list responses. POST event response carries the materialized EventResult as a CBOR body. Public `createWorkflowRunEvent` / `getEvent` / `getWorkflowRunEvents` signatures are unchanged; the underlying wire calls swap to v4. `listEventsByCorrelationId` is not yet implemented on v4 and now throws — callers should fetch hooks directly via `storage.hooks.getByToken`. Requires workflow-server with v4 routes mounted. | |
| New internal API format: separately encode event metadata from user payloads. Eliminates the need for calling separate endpoints for ref resolution, which improves performance on longer runs. |
Companion to workflow-server PR #439 commit 2a55acd, which switched GET /api/v4/runs/:runId/events/:eventId from a CBOR-entity body to a single v4 frame (same wire shape as one LIST frame). - getEventV4 now returns `{ event: DecodedV4Event, body: Uint8Array }` by reading exactly one frame off the response body via `decodeFrames` — same reader the LIST path uses. No content-type branching, no separate CBOR decode path. - getEvent (the storage adapter wrapper) passes both pieces to `buildEventFromV4`, which splices the CBOR-decoded body into `eventData[payloadField]`. Same path LIST already uses, so no GET-specific shape exists anymore. - Drop the special-case fallback in `buildEventFromV4` that used to re-decode an in-eventData Uint8Array — only one input shape now. Net effect: server-side memory for GET single-event is now bounded by S3 chunk size (~64 KB) instead of full payload size. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion to workflow-server PR #439 commit 5036b56, which added `GET /api/v4/events?correlationId=...`. The SDK adapter's `storage.events.listByCorrelationId` no longer throws. Implementation: - New `getEventsByCorrelationIdV4` wire helper alongside `getWorkflowRunEventsV4`. Both share a small `consumeListFrameStream(url, config, opName)` that drives the same frame-stream-to-page conversion — only the URL differs. - `events.ts`'s `getWorkflowRunEvents` dispatches between the two based on whether params carry `runId` or `correlationId`. Same return shape on either path. Changeset updated to drop the "not yet implemented" caveat. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…st headers Mirrors the server-side change in workflow-server: the POST request body is now one length-prefixed frame containing CBOR-encoded metadata plus the opaque payload. Eliminates the V4_HEADERS constant, the percent-encoding for non-ASCII values, the base64-CBOR encoding for executionContext, and the implicit 32 KB cap on Vercel header size. The response side still uses x-wf-event-id/run-id/created-at headers for callers that want eventId without decoding the CBOR body. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| "@workflow/world-vercel": major | ||
| --- | ||
|
|
||
| Switch the world-vercel adapter's event endpoints from the v2/v3 wire format to v4. Event metadata now rides in `x-wf-*` HTTP headers and payloads stream end-to-end as opaque bytes — no server-side CBOR parse on writes, and no per-event `/refs` round-trip on list responses. POST event response carries the materialized EventResult as a CBOR body. GET single event and LIST events use the same `application/vnd.workflow.v4-frames` binary frame stream; `listEventsByCorrelationId` is wired through too. Public `createWorkflowRunEvent` / `getEvent` / `getWorkflowRunEvents` signatures are unchanged. Requires workflow-server with v4 routes mounted. |
There was a problem hiding this comment.
| Switch the world-vercel adapter's event endpoints from the v2/v3 wire format to v4. Event metadata now rides in `x-wf-*` HTTP headers and payloads stream end-to-end as opaque bytes — no server-side CBOR parse on writes, and no per-event `/refs` round-trip on list responses. POST event response carries the materialized EventResult as a CBOR body. GET single event and LIST events use the same `application/vnd.workflow.v4-frames` binary frame stream; `listEventsByCorrelationId` is wired through too. Public `createWorkflowRunEvent` / `getEvent` / `getWorkflowRunEvents` signatures are unchanged. Requires workflow-server with v4 routes mounted. | |
| New internal API format: separately encode event metadata from user payloads. Eliminates the need for calling separate endpoints for ref resolution, which improves performance especially on longer runs. |
Payload fields (input / output / result / error / payload / metadata) reach world-vercel after the runtime has already serialized them via dehydrateRunError / dehydrateStepReturnValue / dehydrateStepArguments — they're Uint8Arrays carrying a devalue blob with a format prefix. splitEventDataForV4 was running them through cbor-x.encode again, so the wire bytes ended up as cbor(Uint8Array). On reads through runs.get (which goes through v2 and just returns the raw stored bytes), the consumer saw the CBOR wrapping and hydrateRunError couldn't parse the format prefix — every failed workflow run surfaced as "Failed to hydrate workflow run error". Pass the bytes through unchanged on write and read; symmetric with world-local and the v2/v3 wire format. Throw on non-Uint8Array to flag non-runtime callers loudly instead of silently double-wrapping. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…emitter) Hook-emitting runtimes set eventData.token (matches the world contract in packages/world/src/events.ts). splitEventDataForV4 was looking for eventData.hookToken instead, so the frame meta arrived without a token and the server's hook materialization failed validation. The v4 wire name (meta.hookToken) is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cbor-x encodes Date natively (CBOR tag 1) and the server (post-23c79b9) decodes it back to a Date for the materialization service. Stop pre-flattening to an ISO string — that was a workaround for the original header-based v4 contract and now leaves the runtime with a string in eventData.resumeAt after replay, blowing up on .getTime(). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Draft. Switches the
world-verceladapter's event endpoints from v2/v3 to v4. Companion to workflow-server PR #439; both land together.What changes
The adapter's
createWorkflowRunEvent/getEvent/getWorkflowRunEventskeep their public signatures and theEventResult/Event/PaginatedResponse<Event>shapes the workflow runtime consumes. What changes is what's on the wire under those calls:x-wf-*HTTP request/response headers instead of inside the body.eventData[field]boundary on write and CBOR-decodes on read; the server treats the bytes as opaque and streams them straight to S3.EventResultas a CBOR body. The runtime reads e.g.result.run.startedAtimmediately after POST without a second round-trip.application/vnd.workflow.v4-frames). One frame per event with CBOR metadata + raw payload bytes inline. The per-event/refsround-trip used by the v3 client is gone.What goes away
packages/world-vercel/src/refs.ts— deleted. The/refshydration path is no longer used.hydrateEventRefs/collectPendingRefs/eventDataRefFieldMapand the wire schemas (EventResultResolveWireSchema,EventResultLazyWireSchema,EventWithRefsSchema) — deleted fromevents.ts.createWorkflowRunEvent— the server still respectsremoteRefBehavior(passed via header foreventsNeedingResolvetypes) and bakes the resolution decision into its CBOR response, so the SDK has nothing to do.Net diff:
+297 / -601lines on this PR.What stays
v1Compatpath increateWorkflowRunEvent— still uses/v1endpoints for legacy SDK migrations that haven't moved to event sourcing. v4 doesn't cover these.validateUlidTimestamponrun_created, theHookNotFoundErrortranslation on hook404s, and thestripEventDataRefspath forresolveData='none'.events-v4.tsis now an internal helper module — not re-exported from the package's public API.Not yet covered (by design)
storage.events.listByCorrelationIdthrows a clear error explaining the v4 server has no by-correlation-id list endpoint. The runtime mostly used this for hook lookups, which can usestorage.hooks.getByTokeninstead. If real callers need it, a follow-up server PR can add/api/v4/events?correlationId=.Test plan
pnpm --filter @workflow/world-vercel buildcleanpnpm --filter @workflow/world-vercel test— 79/79 passpnpm build(full workspace) — 27/27 packages buildWORKFLOW_SERVER_URL_OVERRIDEon this branch points at the workflow-server PR Fix command injection vulnerability in CI workflow via untrusted fork PR #439 preview deployment for e2e tests.🤖 Generated with Claude Code