Skip to content

[core] Add wire-level framing for byte streams#1853

Open
TooTallNate wants to merge 1 commit intonate/healthcheck-coreversionfrom
nate/byte-stream-framing
Open

[core] Add wire-level framing for byte streams#1853
TooTallNate wants to merge 1 commit intonate/healthcheck-coreversionfrom
nate/byte-stream-framing

Conversation

@TooTallNate
Copy link
Copy Markdown
Member

@TooTallNate TooTallNate commented Apr 25, 2026

Summary

Wraps each chunk of type: 'bytes' ReadableStreams in a 4-byte big-endian length prefix on the wire, so consumers can identify chunk boundaries. The user-facing API is unchanged — getReader() still yields raw Uint8Array chunks; only the wire envelope is new.

This enables byte-stream auto-reconnect to be added in a follow-up. With frames on the wire, a reconnecting reader can count completed frames and resume from startIndex + consumed after a transient error, which is the same trick that createReconnectingFramedStream uses today for object streams.

Stacked on #1854 — this PR's base is nate/healthcheck-coreversion rather than main. The cross-deployment capability probe in start.ts depends on HealthCheckResult.workflowCoreVersion, which is added by #1854. Merge order: #1854 → main, then this PR rebased onto main.

Relationship to #1847

Independent. #1847 (targeting stable) moves stream reconnect into core via createReconnectingFramedStream, applying it to object streams only — it explicitly opts byte streams out, with this comment:

No auto-reconnect here: raw byte streams have no wire framing, so the caller owns its own reconnect strategy if it needs one.

That decision is correct on stable because the legacy wire format for byte streams is unframed. This PR (targeting main / v5) introduces the framed byte-stream wire format, opt-in per stream and gated on a capability flag. Once both this PR and #1847 land, a follow-up on main can apply createReconnectingFramedStream to byte streams whose ref carries framing: 'framed-v1', completing the reconnect story for byte streams without touching the legacy raw path.

#1847 is unaffected by this PR and can land on stable as-is.

Mechanism

The framing decision is made per-stream at serialization time and recorded in the stream ref:

ReadableStream:
  | {
      name: string;
      type?: 'bytes';
      startIndex?: number;
      framing?: 'raw' | 'framed-v1';   // new
    }
  | { bodyInit: any };

Readers dispatch on the field. Absent or 'raw' → existing legacy behavior (no unframing). 'framed-v1' → pipe through getByteUnframingStream() to strip the length prefix before handing chunks to the user.

Capability gating

Producers consult a new framedByteStreams capability in getRunCapabilities() (in capabilities.ts), keyed on the target run's workflowCoreVersion. The choice is then baked into the ref so consumers know what to do without re-doing the lookup.

Producer site Target Capability resolution
dehydrateStepReturnValue (step → workflow) Current run Always framed (version skew protection: workflow VM is on this same SDK)
getWritable() Current run Always framed
dehydrateWorkflowArguments (start() same-deployment) Current deployment Always framed
dehydrateWorkflowArguments (start() cross-deployment) Different deployment Probe target via healthCheck (2s timeout); fall back to raw on miss/timeout
dehydrateStepReturnValue (resumeHook payload) Hook's owning run Look up workflowRun.executionContext.workflowCoreVersion (already loaded for encryption capability check)

The cross-deployment probe relies on HealthCheckResult.workflowCoreVersion being surfaced by healthCheck() — that's PR #1854.

Backwards compatibility

  • Older runs whose serialized refs lack the framing field are read as raw bytes. ✓
  • New SDKs reading streams from old runs: ref carries no framing → raw path. ✓
  • New SDK calling resumeHook against an old run: target's workflowCoreVersion is below cutoff → producer emits raw bytes → old reader sees what it expects. ✓
  • New SDK calling start({ deploymentId: 'old-deployment' }): probe times out or returns an old workflowCoreVersion → producer emits raw bytes. ✓

The framing format identifier is opaque ('framed-v1') so future framing variants ('framed-v2', etc.) can be added without breaking existing consumers.

Tests

  • byte-stream-framing.test.ts (new, 16 tests):
    • getByteFramingStream: prefix shape, empty-chunk drop, large chunks, EOF
    • getByteUnframingStream: round-trip, split frames across reads, coalesced frames in one read, mid-frame truncation error, oversized-frame guard
    • End-to-end: framedByteStreams=false emits no framing field (back-compat); framedByteStreams=true emits 'framed-v1'; both modes round-trip user bytes unchanged
  • capabilities.test.ts (extended): framedByteStreams is false for invalid/old versions, true from the cutoff (5.0.0-beta.3) onward

All 624 core tests pass on the rebased branch.

Notes

  • The minVersion for framedByteStreams is set to '5.0.0-beta.3' based on the next beta release. Update this if the actual ship version differs.
  • The cross-deployment probe is gated on world.streams?.get being a function — minimal test mocks that don't implement it skip the probe (avoiding the 2s timeout in test runs).
  • Framing for byte streams also sets the stage for byte-stream encryption in a future PR (the existing encr format prefix system could wrap each frame's payload). Out of scope here.

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings April 25, 2026 07:21
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 25, 2026

🦋 Changeset detected

Latest commit: b3a7f7f

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

This PR includes changesets to release 17 packages
Name Type
@workflow/core Minor
workflow Minor
@workflow/builders Patch
@workflow/cli Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/vitest Patch
@workflow/web-shared Patch
@workflow/web Patch
@workflow/world-testing Patch
@workflow/ai Major
@workflow/astro Patch
@workflow/nest Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch
@workflow/nuxt 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
Copy link
Copy Markdown
Contributor

vercel Bot commented Apr 25, 2026

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

Project Deployment Actions Updated (UTC)
example-nextjs-workflow-turbopack Ready Ready Preview, Comment Apr 27, 2026 4:50pm
example-nextjs-workflow-webpack Ready Ready Preview, Comment Apr 27, 2026 4:50pm
example-workflow Ready Ready Preview, Comment Apr 27, 2026 4:50pm
workbench-astro-workflow Ready Ready Preview, Comment Apr 27, 2026 4:50pm
workbench-express-workflow Ready Ready Preview, Comment Apr 27, 2026 4:50pm
workbench-fastify-workflow Ready Ready Preview, Comment Apr 27, 2026 4:50pm
workbench-hono-workflow Ready Ready Preview, Comment Apr 27, 2026 4:50pm
workbench-nitro-workflow Ready Ready Preview, Comment Apr 27, 2026 4:50pm
workbench-nuxt-workflow Ready Ready Preview, Comment Apr 27, 2026 4:50pm
workbench-sveltekit-workflow Ready Ready Preview, Comment Apr 27, 2026 4:50pm
workbench-vite-workflow Ready Ready Preview, Comment Apr 27, 2026 4:50pm
workflow-docs Ready Ready Preview, Comment, Open in v0 Apr 27, 2026 4:50pm
workflow-swc-playground Ready Ready Preview, Comment Apr 27, 2026 4:50pm
workflow-web Ready Ready Preview, Comment Apr 27, 2026 4:50pm

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 25, 2026

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 0.037s (-15.8% 🟢) 1.005s (~) 0.968s 10 1.00x
💻 Local Nitro 0.038s (-11.8% 🟢) 1.006s (~) 0.968s 10 1.02x
💻 Local Next.js (Turbopack) 0.050s 1.005s 0.956s 10 1.33x
🐘 Postgres Express 0.056s (-3.1%) 1.010s (~) 0.953s 10 1.51x
🐘 Postgres Nitro 0.059s (-38.4% 🟢) 1.010s (-3.1%) 0.952s 10 1.57x
🐘 Postgres Next.js (Turbopack) 0.060s 1.009s 0.949s 10 1.61x
workflow with 1 step

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 1.097s (-3.1%) 2.006s (~) 0.909s 10 1.00x
💻 Local Express 1.099s (-2.4%) 2.006s (~) 0.907s 10 1.00x
🐘 Postgres Next.js (Turbopack) 1.124s 2.009s 0.884s 10 1.03x
💻 Local Next.js (Turbopack) 1.126s 2.006s 0.880s 10 1.03x
🐘 Postgres Nitro 1.139s (~) 2.010s (~) 0.872s 10 1.04x
🐘 Postgres Express 1.142s (~) 2.009s (~) 0.868s 10 1.04x
workflow with 10 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 10.659s (-2.4%) 11.023s (~) 0.364s 3 1.00x
💻 Local Nitro 10.671s (-2.5%) 11.023s (~) 0.351s 3 1.00x
🐘 Postgres Next.js (Turbopack) 10.711s 11.020s 0.309s 3 1.00x
💻 Local Next.js (Turbopack) 10.775s 11.023s 0.249s 3 1.01x
🐘 Postgres Nitro 10.876s (~) 11.021s (~) 0.146s 3 1.02x
🐘 Postgres Express 10.893s (-0.6%) 11.022s (~) 0.128s 3 1.02x
workflow with 25 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 14.199s (-5.7% 🟢) 15.030s (-6.2% 🟢) 0.831s 4 1.00x
🐘 Postgres Next.js (Turbopack) 14.223s 15.027s 0.804s 4 1.00x
💻 Local Express 14.272s (-4.7%) 15.028s (~) 0.756s 4 1.01x
🐘 Postgres Nitro 14.517s (-0.5%) 15.018s (~) 0.502s 4 1.02x
🐘 Postgres Express 14.553s (~) 15.025s (~) 0.472s 4 1.02x
💻 Local Next.js (Turbopack) 14.636s 15.029s 0.393s 4 1.03x
workflow with 50 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Next.js (Turbopack) 13.218s 14.022s 0.804s 7 1.00x
🐘 Postgres Nitro 13.822s (-1.0%) 14.021s (-2.0%) 0.198s 7 1.05x
🐘 Postgres Express 13.890s (-0.8%) 14.021s (-3.9%) 0.131s 7 1.05x
💻 Local Nitro 14.859s (-11.5% 🟢) 15.027s (-11.8% 🟢) 0.168s 6 1.12x
💻 Local Express 14.882s (-10.4% 🟢) 15.028s (-11.8% 🟢) 0.146s 6 1.13x
💻 Local Next.js (Turbopack) 15.974s 16.196s 0.221s 6 1.21x
Promise.all with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Next.js (Turbopack) 1.189s 2.010s 0.821s 15 1.00x
🐘 Postgres Nitro 1.247s (-2.2%) 2.008s (~) 0.762s 15 1.05x
🐘 Postgres Express 1.271s (+0.8%) 2.010s (~) 0.738s 15 1.07x
💻 Local Nitro 1.445s (-11.4% 🟢) 2.006s (-3.3%) 0.561s 15 1.22x
💻 Local Express 1.465s (-1.6%) 2.005s (~) 0.540s 15 1.23x
💻 Local Next.js (Turbopack) 1.517s 2.005s 0.488s 15 1.28x
Promise.all with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 2.333s (-0.8%) 3.008s (~) 0.675s 10 1.00x
🐘 Postgres Express 2.346s (-0.6%) 3.009s (~) 0.663s 10 1.01x
🐘 Postgres Next.js (Turbopack) 2.353s 3.008s 0.655s 10 1.01x
💻 Local Nitro 2.649s (-15.7% 🟢) 3.108s (-20.0% 🟢) 0.459s 10 1.14x
💻 Local Express 2.732s (-7.5% 🟢) 3.107s (-10.0% 🟢) 0.375s 10 1.17x
💻 Local Next.js (Turbopack) 2.773s 3.008s 0.235s 10 1.19x
Promise.all with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 3.453s (-0.8%) 4.009s (~) 0.556s 8 1.00x
🐘 Postgres Express 3.474s (~) 4.011s (~) 0.537s 8 1.01x
🐘 Postgres Next.js (Turbopack) 3.564s 4.009s 0.445s 8 1.03x
💻 Local Nitro 6.731s (-19.4% 🟢) 7.017s (-22.2% 🟢) 0.286s 5 1.95x
💻 Local Express 7.269s (-12.8% 🟢) 8.015s (-11.2% 🟢) 0.746s 4 2.11x
💻 Local Next.js (Turbopack) 7.524s 8.266s 0.742s 4 2.18x
Promise.race with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Next.js (Turbopack) 1.180s 2.008s 0.828s 15 1.00x
🐘 Postgres Nitro 1.262s (~) 2.007s (~) 0.745s 15 1.07x
🐘 Postgres Express 1.264s (+0.6%) 2.008s (~) 0.744s 15 1.07x
💻 Local Nitro 1.456s (-21.9% 🟢) 2.005s (-14.3% 🟢) 0.548s 15 1.23x
💻 Local Next.js (Turbopack) 1.513s 2.006s 0.493s 15 1.28x
💻 Local Express 1.569s (-17.1% 🟢) 2.073s (-12.3% 🟢) 0.504s 15 1.33x
Promise.race with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 2.313s (-1.1%) 3.011s (~) 0.697s 10 1.00x
🐘 Postgres Express 2.324s (-0.7%) 3.011s (~) 0.687s 10 1.00x
🐘 Postgres Next.js (Turbopack) 2.346s 3.009s 0.663s 10 1.01x
💻 Local Nitro 2.660s (-13.2% 🟢) 3.007s (-22.6% 🟢) 0.348s 10 1.15x
💻 Local Express 2.711s (-13.4% 🟢) 3.009s (-20.0% 🟢) 0.298s 10 1.17x
💻 Local Next.js (Turbopack) 2.868s 3.343s 0.475s 9 1.24x
Promise.race with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 3.431s (-1.4%) 4.009s (~) 0.578s 8 1.00x
🐘 Postgres Express 3.459s (-1.1%) 4.011s (~) 0.551s 8 1.01x
🐘 Postgres Next.js (Turbopack) 3.542s 4.010s 0.469s 8 1.03x
💻 Local Nitro 7.262s (-20.6% 🟢) 8.014s (-20.0% 🟢) 0.753s 4 2.12x
💻 Local Express 7.787s (-11.5% 🟢) 8.017s (-13.5% 🟢) 0.230s 4 2.27x
💻 Local Next.js (Turbopack) 8.336s 8.773s 0.436s 4 2.43x
workflow with 10 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Next.js (Turbopack) 0.620s 1.006s 0.386s 60 1.00x
💻 Local Nitro 0.678s (-30.9% 🟢) 1.004s (-8.2% 🟢) 0.327s 60 1.09x
🐘 Postgres Nitro 0.798s (-2.8%) 1.006s (~) 0.208s 60 1.29x
💻 Local Express 0.811s (-17.6% 🟢) 1.115s (+3.7%) 0.305s 54 1.31x
🐘 Postgres Express 0.811s (-3.3%) 1.006s (-1.7%) 0.194s 60 1.31x
💻 Local Next.js (Turbopack) 0.837s 1.005s 0.167s 60 1.35x
workflow with 25 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Next.js (Turbopack) 1.557s 2.029s 0.472s 45 1.00x
🐘 Postgres Nitro 1.853s (-3.9%) 2.029s (-3.4%) 0.176s 45 1.19x
🐘 Postgres Express 1.919s (-2.9%) 2.076s (-8.1% 🟢) 0.156s 44 1.23x
💻 Local Nitro 2.214s (-27.1% 🟢) 3.007s (-20.0% 🟢) 0.793s 30 1.42x
💻 Local Express 2.271s (-24.7% 🟢) 3.007s (-16.1% 🟢) 0.736s 30 1.46x
💻 Local Next.js (Turbopack) 2.636s 3.008s 0.372s 30 1.69x
workflow with 50 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Next.js (Turbopack) 3.133s 3.978s 0.845s 31 1.00x
🐘 Postgres Nitro 3.797s (-7.5% 🟢) 4.009s (-12.9% 🟢) 0.212s 30 1.21x
🐘 Postgres Express 3.924s (-1.7%) 4.148s (-5.1% 🟢) 0.224s 29 1.25x
💻 Local Nitro 7.256s (-22.0% 🟢) 7.952s (-20.6% 🟢) 0.697s 16 2.32x
💻 Local Express 7.357s (-20.1% 🟢) 8.014s (-20.0% 🟢) 0.658s 15 2.35x
💻 Local Next.js (Turbopack) 8.646s 9.089s 0.443s 14 2.76x
workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Next.js (Turbopack) 0.200s 1.007s 0.807s 60 1.00x
🐘 Postgres Express 0.277s (-1.9%) 1.007s (~) 0.730s 60 1.39x
🐘 Postgres Nitro 0.277s (-2.1%) 1.006s (~) 0.729s 60 1.39x
💻 Local Nitro 0.553s (-8.6% 🟢) 1.004s (-1.7%) 0.452s 60 2.77x
💻 Local Next.js (Turbopack) 0.557s 1.004s 0.447s 60 2.79x
💻 Local Express 0.562s (~) 1.004s (~) 0.442s 60 2.82x
workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Next.js (Turbopack) 0.415s 1.006s 0.591s 90 1.00x
🐘 Postgres Express 0.489s (-4.1%) 1.007s (~) 0.517s 90 1.18x
🐘 Postgres Nitro 0.490s (-1.3%) 1.006s (~) 0.516s 90 1.18x
💻 Local Express 2.485s (-1.1%) 3.007s (~) 0.523s 30 5.98x
💻 Local Nitro 2.499s (-1.5%) 3.181s (+5.7% 🔺) 0.681s 29 6.02x
💻 Local Next.js (Turbopack) 2.574s 3.008s 0.435s 30 6.20x
workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Next.js (Turbopack) 0.624s 1.006s 0.383s 120 1.00x
🐘 Postgres Nitro 0.782s (-1.1%) 1.007s (~) 0.225s 120 1.25x
🐘 Postgres Express 0.791s (-3.4%) 1.008s (-0.9%) 0.217s 120 1.27x
💻 Local Nitro 9.877s (-11.7% 🟢) 10.524s (-9.8% 🟢) 0.647s 12 15.84x
💻 Local Express 10.420s (-6.9% 🟢) 11.026s (-7.7% 🟢) 0.605s 11 16.71x
💻 Local Next.js (Turbopack) 10.664s 11.299s 0.635s 11 17.10x
Stream Benchmarks (includes TTFB metrics)
workflow with stream

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 0.138s (-35.3% 🟢) 1.004s (~) 0.010s (-21.6% 🟢) 1.015s (~) 0.877s 10 1.00x
💻 Local Express 0.142s (-28.7% 🟢) 1.004s (~) 0.009s (-23.1% 🟢) 1.015s (~) 0.873s 10 1.03x
🐘 Postgres Next.js (Turbopack) 0.159s 1.001s 0.001s 1.010s 0.851s 10 1.15x
💻 Local Next.js (Turbopack) 0.166s 1.003s 0.011s 1.018s 0.852s 10 1.20x
🐘 Postgres Nitro 0.199s (-3.1%) 0.996s (~) 0.001s (-20.0% 🟢) 1.009s (~) 0.811s 10 1.44x
🐘 Postgres Express 0.215s (+5.0% 🔺) 0.996s (~) 0.001s (-18.8% 🟢) 1.009s (~) 0.794s 10 1.56x
stream pipeline with 5 transform steps (1MB)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Next.js (Turbopack) 0.535s 1.008s 0.004s 1.022s 0.487s 59 1.00x
💻 Local Express 0.586s (-22.5% 🟢) 1.011s (-1.7%) 0.009s (-0.8%) 1.023s (-1.7%) 0.436s 59 1.10x
🐘 Postgres Nitro 0.600s (-3.8%) 1.004s (~) 0.004s (-8.6% 🟢) 1.025s (~) 0.425s 59 1.12x
🐘 Postgres Express 0.617s (-2.1%) 1.005s (~) 0.013s (+229.2% 🔺) 1.031s (+0.8%) 0.414s 59 1.15x
💻 Local Next.js (Turbopack) 0.655s 1.011s 0.009s 1.023s 0.369s 59 1.22x
💻 Local Nitro 0.772s (-8.0% 🟢) 1.011s (~) 0.009s (-4.4%) 1.226s (+9.8% 🔺) 0.454s 49 1.44x
10 parallel streams (1MB each)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Next.js (Turbopack) 0.883s 1.092s 0.000s 1.098s 0.215s 55 1.00x
🐘 Postgres Nitro 0.933s (-3.7%) 1.081s (-13.3% 🟢) 0.000s (-56.4% 🟢) 1.098s (-12.7% 🟢) 0.165s 55 1.06x
🐘 Postgres Express 0.985s (+2.5%) 1.246s (-2.5%) 0.000s (+91.7% 🔺) 1.258s (-3.7%) 0.273s 48 1.12x
💻 Local Nitro 1.126s (-7.9% 🟢) 2.017s (~) 0.000s (+266.7% 🔺) 2.018s (~) 0.893s 30 1.27x
💻 Local Express 1.187s (-3.1%) 2.020s (~) 0.000s (+10.0% 🔺) 2.022s (~) 0.836s 30 1.34x
💻 Local Next.js (Turbopack) 1.244s 2.020s 0.000s 2.022s 0.778s 30 1.41x
fan-out fan-in 10 streams (1MB each)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.719s (-4.0%) 2.136s (~) 0.000s (~) 2.167s (~) 0.448s 28 1.00x
🐘 Postgres Express 1.764s (~) 2.144s (-1.5%) 0.000s (NaN%) 2.152s (-2.1%) 0.389s 28 1.03x
🐘 Postgres Next.js (Turbopack) 1.807s 2.106s 0.000s 2.142s 0.335s 29 1.05x
💻 Local Nitro 3.454s (+1.9%) 4.030s (~) 0.000s (-25.0% 🟢) 4.033s (~) 0.580s 15 2.01x
💻 Local Next.js (Turbopack) 3.558s 4.100s 0.001s 4.104s 0.546s 15 2.07x
💻 Local Express 3.668s (+5.8% 🔺) 4.101s (+1.7%) 0.001s (+8.3% 🔺) 4.103s (+1.7%) 0.435s 15 2.13x

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World 🥇 Fastest Framework Wins
💻 Local Nitro 17/21
🐘 Postgres Next.js (Turbopack) 15/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework 🥇 Fastest World Wins
Express 🐘 Postgres 14/21
Next.js (Turbopack) 🐘 Postgres 20/21
Nitro 🐘 Postgres 15/21
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run


Some benchmark jobs failed:

  • Local: success
  • Postgres: success
  • Vercel: failure

Check the workflow run for details.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 25, 2026

🧪 E2E Test Results

Some tests failed

Summary

Passed Failed Skipped Total
❌ 💻 Local Development 1052 2 86 1140
✅ 📦 Local Production 1054 0 86 1140
✅ 🐘 Local Postgres 1054 0 86 1140
✅ 🪟 Windows 95 0 0 95
✅ 📋 Other 267 0 18 285
Total 3522 2 276 3800

❌ Failed Tests

💻 Local Development (2 failed)

vite-stable (2 failed):

  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack

Details by Category

❌ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 89 0 6
✅ express-stable 89 0 6
✅ fastify-stable 89 0 6
✅ hono-stable 89 0 6
✅ nextjs-turbopack-canary 76 0 19
✅ nextjs-turbopack-stable 95 0 0
✅ nextjs-webpack-canary 76 0 19
✅ nextjs-webpack-stable 95 0 0
✅ nitro-stable 89 0 6
✅ nuxt-stable 89 0 6
✅ sveltekit-stable 89 0 6
❌ vite-stable 87 2 6
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 89 0 6
✅ express-stable 89 0 6
✅ fastify-stable 89 0 6
✅ hono-stable 89 0 6
✅ nextjs-turbopack-canary 76 0 19
✅ nextjs-turbopack-stable 95 0 0
✅ nextjs-webpack-canary 76 0 19
✅ nextjs-webpack-stable 95 0 0
✅ nitro-stable 89 0 6
✅ nuxt-stable 89 0 6
✅ sveltekit-stable 89 0 6
✅ vite-stable 89 0 6
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 89 0 6
✅ express-stable 89 0 6
✅ fastify-stable 89 0 6
✅ hono-stable 89 0 6
✅ nextjs-turbopack-canary 76 0 19
✅ nextjs-turbopack-stable 95 0 0
✅ nextjs-webpack-canary 76 0 19
✅ nextjs-webpack-stable 95 0 0
✅ nitro-stable 89 0 6
✅ nuxt-stable 89 0 6
✅ sveltekit-stable 89 0 6
✅ vite-stable 89 0 6
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 95 0 0
✅ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 89 0 6
✅ e2e-local-postgres-nest-stable 89 0 6
✅ e2e-local-prod-nest-stable 89 0 6

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: failure
  • Local Dev: failure
  • Local Prod: success
  • Local Postgres: success
  • Windows: success

Check the workflow run for details.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds opt-in wire-level length-prefix framing for type: 'bytes' ReadableStreams, gated by target run capabilities, to preserve chunk boundaries on the wire and enable future transparent auto-reconnect.

Changes:

  • Introduces byte-stream framing/unframing transforms and carries a per-stream framing field through serialization refs and VM symbols.
  • Adds framedByteStreams capability detection (via workflowCoreVersion) and uses a cross-deployment healthCheck probe in start() to decide framing.
  • Updates producer/consumer sites (step return values, getWritable, resumeHook, workflow argument dehydration) and adds dedicated tests + changeset.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
packages/core/src/symbols.ts Adds STREAM_FRAMING_SYMBOL to persist framing choice through the VM boundary.
packages/core/src/step/writable-stream.ts Forces framed byte-stream support when serializing values written from steps (same-deployment assumption).
packages/core/src/serialization.ts Implements byte framing/unframing, adds framing to serialized stream refs, and threads framedByteStreams through reducers/revivers.
packages/core/src/runtime/step-handler.ts Always enables framed byte streams for step return value dehydration (same-deployment assumption).
packages/core/src/runtime/start.ts Adds capability probing for cross-deployment start() to decide whether to frame byte streams.
packages/core/src/runtime/resume-hook.ts Uses run capabilities to decide whether to emit framed byte streams in hook resume payloads.
packages/core/src/runtime/helpers.ts Parses and surfaces workflowCoreVersion from health check responses.
packages/core/src/capabilities.ts Adds framedByteStreams capability with semver cutoff logic.
packages/core/src/capabilities.test.ts Adds coverage for framedByteStreams cutoff/invalid-version behavior.
packages/core/src/byte-stream-framing.test.ts Adds unit + e2e tests for framing/unframing and ref behavior.
.changeset/byte-stream-wire-framing.md Declares a minor release for the new wire framing capability and behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +504 to +509
function appendToBuffer(data: Uint8Array) {
const next = new Uint8Array(buffer.length + data.length);
next.set(buffer, 0);
next.set(data, buffer.length);
buffer = next;
}
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

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

appendToBuffer() in getByteUnframingStream() reallocates and copies the entire buffered data on every incoming transport chunk. With many small chunks this becomes O(n²) copying and can cause significant GC/memory churn during long byte streams. Consider a chunk-list + offset approach (similar to a rope), or a growable buffer strategy (amortized linear) to avoid repeated full copies.

Copilot uses AI. Check for mistakes.
Comment on lines +497 to +502
// Sanity cap: 100MB per chunk. Workflow byte chunks are typically far
// smaller; anything bigger almost certainly means we got a non-framed
// wire fed through this transform by mistake (e.g. legacy raw bytes
// routed to a framed reader).
const MAX_FRAME_SIZE = 100_000_000;
let buffer = new Uint8Array(0);
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

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

MAX_FRAME_SIZE is hard-coded to 100MB in getByteUnframingStream(). This introduces a new per-chunk size limit for framed byte streams (previously raw byte streams had no SDK-level cap), and will cause valid >100MB chunks to fail even when framing is correct. If a cap is required, it should be aligned with documented/transport limits and ideally enforced (or at least surfaced) consistently on the producer side too; otherwise consider making it configurable or raising/removing it.

Copilot uses AI. Check for mistakes.
Comment on lines +483 to +522
it('hydrate of a framed-v1 ref unframes; absent ref reads raw', async () => {
// Direct exercise of the reviver dispatch: write framed bytes to a
// mock world under a known name, then construct the stream ref two
// different ways (with framing and without) to verify the consumer
// dispatches correctly.
setWorld(makeMockWorld());
const world = await (await import('./runtime/world.js')).getWorld();

// Frame three user chunks into the wire format and stash them.
const chunks = [
new Uint8Array([1, 2]),
new Uint8Array([3, 4, 5]),
new Uint8Array([6]),
];
const reader = new ReadableStream<Uint8Array>({
pull(c) {
for (const ch of chunks) c.enqueue(ch);
c.close();
},
})
.pipeThrough(getByteFramingStream())
.getReader();

const wireBytes: Uint8Array[] = [];
for (;;) {
const r = await reader.read();
if (r.done) break;
wireBytes.push(r.value);
}
for (const b of wireBytes) {
await world.streams.write('wrun_test', 'strm_known', b);
}

// Now read back via wire stream + unframer — should produce original chunks.
const wireStream = await world.streams.get('wrun_test', 'strm_known');
const got = await readBytes(
wireStream.pipeThrough(getByteUnframingStream())
);
expect(got).toEqual(chunks);
});
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

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

The test name/description doesn't match what the test actually exercises: "hydrate of a framed-v1 ref unframes; absent ref reads raw" only writes framed bytes and then manually pipes through getByteUnframingStream(); it doesn't construct/read a serialized ref with/without framing to verify the reviver dispatch. Either rename this test to match its behavior, or extend it to actually cover the value.framing branching in getExternalRevivers/getStepRevivers.

Copilot uses AI. Check for mistakes.
Comment on lines +468 to +475
return new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
if (chunk.length === 0) return;
const frame = new Uint8Array(FRAME_HEADER_SIZE + chunk.length);
new DataView(frame.buffer).setUint32(0, chunk.length, false);
frame.set(chunk, FRAME_HEADER_SIZE);
controller.enqueue(frame);
},
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

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

getByteFramingStream() drops zero-length Uint8Array chunks (if (chunk.length === 0) return;). That is an observable semantic change for type: 'bytes' streams when framing is enabled (a producer can enqueue empty chunks today and the consumer would receive them). Either preserve empty chunks by emitting a 0-length frame (the unframer already supports it), or explicitly document/justify that empty chunks are intentionally lossy despite the PR description claiming the user-facing API is unchanged.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

@VaguelySerious VaguelySerious left a comment

Choose a reason for hiding this comment

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

AI review: no blocking issues

framedByteStreams = getRunCapabilities(
probe?.workflowCoreVersion
).framedByteStreams;
}
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 probe is synchronous on every cross-deployment start() call and gates the dehydrate. With a 2s timeout, repeated cross-deployment starts to a deployment that doesn't recognise the health check (or where workflowCoreVersion is absent) each pay up to 2s of latency, even when the workflow args contain no byte streams.

Worth caching the probe result per deploymentId (or per (deploymentId, workflowCoreVersion) once known) — the target's capabilities don't change between calls within a process. Without caching, a workflow that fans out N runs across deployments via start({ deploymentId }) would re-probe N times.

A softer alternative: only probe lazily when a byte stream is actually encountered in the args. Today the probe runs unconditionally.

// the wire format without runtime negotiation. Producers that target a
// run whose deployment doesn't support framing (see `getRunCapabilities`
// in capabilities.ts) emit raw bytes and a ref without the field — which
// the reader treats as legacy raw bytes for backwards compatibility.
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

The unframer relies on an implicit invariant that's not stated here: each server-side stored chunk corresponds to exactly one frame, so startIndex (used by WorkflowServerReadableStream for resume) aligns to frame boundaries. If the server ever coalesces or splits stored chunks (or WorkflowServerWritableStream ever switches to writing partial frames per write() call), the unframer's first read would land mid-frame, the bogus length header would trip the MAX_FRAME_SIZE guard, and the stream would error.

Worth a sentence in this block (or near startIndex in WorkflowServerReadableStream) calling out that the framer must enqueue one whole frame per chunk to the underlying writable, and that any future change to the writable's chunk batching needs to honour that.

next.set(buffer, 0);
next.set(data, buffer.length);
buffer = next;
}
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: Nit

appendToBuffer allocates a fresh Uint8Array and copies the entire pending buffer + new chunk on every transport read — O(n²) when frames are split across many small reads.

This mirrors the pre-existing pattern in getDeserializeStream, so it's not new debt introduced by this PR. But byte streams can plausibly carry far larger volumes per chunk than object streams (the PR's MAX_FRAME_SIZE is 100MB). Worth a follow-up to switch both to a chunk-list accumulator with concat-on-extract.

frame.set(chunk, FRAME_HEADER_SIZE);
controller.enqueue(frame);
},
});
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: Nit

The framer drops empty chunks (good — empty frames are useless and the [0,0,0,0] shape collides with the looks-framed sniff in getDeserializeStream). But the unframer happily decodes a 4-byte zero-length header into an empty Uint8Array enqueue — no symmetry check.

In practice this only matters if a non-canonical writer ever produced a length-0 frame. Either drop empty enqueues on the unframer side too, or add a comment that the unframer tolerates them for forward-compat.

const CAPABILITY_VERSION_TABLE: ReadonlyArray<{
capability: keyof Omit<RunCapabilities, 'supportedFormats'>;
minVersion: string;
}> = [{ capability: 'framedByteStreams', minVersion: '5.0.0-beta.3' }];
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: Nit

The 5.0.0-beta.3 cutoff is a placeholder per the PR description ("Update this if the actual ship version differs"). Easy to forget — consider a // TODO(release): marker right on this line so it surfaces in a grep before cutting the beta.

);
expect(got).toEqual(chunks);
});
});
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

Two test gaps worth filling here:

  1. Cross-deployment probe in start.ts — none of the new behaviour in start() (probe success → framed, probe miss → raw, probe timeout → raw, world without streams.get → raw) is exercised. This is the most user-visible new path and the one that adds latency on cross-deployment starts.
  2. Workflow-VM round-trip propagation — the invariant that getWorkflowReviversgetWorkflowReducers/getStepReducers preserves framing across the VM boundary, even when the boundary is told framedByteStreams=false (because flipping mid-stream would corrupt already-written bytes). I confirmed this works locally by hand-constructing a ref with framing: 'framed-v1', reviving via getWorkflowRevivers, then re-serializing via dehydrateStepArguments — the field survives. Worth a regression test in this file so that invariant doesn't quietly drift in a future refactor.

Not blocking, but the second one in particular guards a subtle correctness property the rest of the framing story depends on.

Wraps each chunk of `type: 'bytes'` ReadableStreams in a 4-byte
big-endian length prefix on the wire, so consumers can identify chunk
boundaries and (in a follow-up) transparently reconnect on transient
stream errors. The user-facing API is unchanged — `getReader()` still
yields raw `Uint8Array` chunks; only the wire envelope is new.

The framing decision is made per-stream at serialization time and
recorded in the stream ref (`framing: 'framed-v1'`). Readers dispatch
on the field, falling back to raw bytes when absent so existing runs
keep working unchanged.

The choice is gated on a new `framedByteStreams` capability (in
`getRunCapabilities`) keyed on the target run's `workflowCoreVersion`.
Producers consult the capability of the run they're writing to:

  - Same-deployment writes (the common case for `start()`,
    `dehydrateStepReturnValue`, `getWritable()`): always frame —
    version skew protection means the consumer is on this same SDK.
  - `resumeHook` payloads: check the hook's owning run's
    `workflowCoreVersion` (already loaded for encryption capability).
  - Cross-deployment `start()` (explicit `deploymentId` or `'latest'`
    resolving to a different deployment): probe the target via
    `healthCheck` with a tight 2s timeout; fall back to raw on
    timeout. Surfaces `workflowCoreVersion` from the existing health
    check wire payload, which was already advertised but dropped on
    the read side.

This is a prerequisite for transparent byte-stream auto-reconnect
(the goal of #1847 on stable). With frames on the wire, a future
reconnecting reader can count completed frames and resume from
`startIndex + consumed` after a transient error — just like
`createReconnectingFramedStream` already does for object streams.

Tests cover the framing/unframing transforms (split frames, coalesced
frames, truncation errors, oversized-frame guard), the capability
table for byte-stream support across version cutoffs, and end-to-end
round-trips through `dehydrateStepReturnValue` in both modes.
Copy link
Copy Markdown
Member

@VaguelySerious VaguelySerious left a comment

Choose a reason for hiding this comment

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

One blocking question about version compat, otherwise LGTM

Comment on lines +194 to +196
} else if (typeof world.streams?.get !== 'function') {
framedByteStreams = false;
} else {
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.

Wouldn't this fail if you create a newer run from an older deployment? E.g. infinite chess

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.

3 participants