chore(deps): update undici to v8 - #3438
Conversation
Bumps the workspace catalog from undici 7.29.0 to 8.10.0 and adapts world-vercel and world-local to the v8 dispatcher API. - Bridge every dispatcher handed to the global `fetch` through `Dispatcher1Wrapper`, including a caller-supplied `APIConfig.dispatcher`, and strip the `allowH2: false` that bridge injects. - Drop the pipelining and idempotent-relabelling workarounds that used to be needed for H2 multiplexing; v8 multiplexes on its own. - Turn H2 off for the stream write/close agents, which relied on v7's non-idempotent `busy()` gate for append isolation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: c32d012 The changes in this PR will be included in the next version bump. This PR includes changesets to release 19 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 |
📊 Workflow Benchmarkscommit Backend:
📈 STSO distribution vs main (inline / queue-hop histograms)1020 steps (inline) Cumulative STSO time: main 170410ms → this run 123292ms (Δ -47118ms, -28%) 📜 Previous results (1)4889e13Tue, 11 Aug 2026 00:52:18 GMT · run logs
ℹ️ Metric definitions & methodologyThe collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: Best/P75/P90/P99 deltas compare against the most recent benchmark run on Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window) Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost 🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000 All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor ( Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the |
🧪 E2E Test Results✅ All tests passed E2E Test SummarySummary
Details by Category✅ ▲ Vercel Production
✅ 💻 Local Development
✅ 📦 Local Production
✅ 🐘 Local Postgres
✅ 🪟 Windows
✅ vercel-multi-region
|
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
undici's own v1/v2 bridge drops every response header over HTTP/2:
`controller.rawHeaders` is a Buffer[] pair list on H1 but a plain
{ name: value } object on H2, and the v1 handler's pair-wise loop reads
nothing out of the object. undici hides this by forcing allowH2: false on
every dispatch; restoring H2 under it exposed it, and the v4 events path
rejected every reply for a missing content-type.
The bridge here normalizes the header shape instead, so allowH2 is left
alone and the events agent keeps H2. It also keeps the v1 callbacks on the
handler it emits, because undici 6 validates and drives the v1 ABI only
and APIConfig.dispatcher accepts any undici version.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two comments still credited Dispatcher1Wrapper for work this package now does itself, and the world-local queue said nothing about why undici's wrapper is fine there (that agent is HTTP/1.1 only, so the header shape the wrapper mishandles never arises). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bumps the workspace catalog from
undici@7.29.0toundici@8.10.0and adapts the two packages that depend on it,@workflow/world-verceland@workflow/world-local.undici 8 is a breaking release for anyone who constructs a dispatcher and hands it to the global
fetch, which is exactly what both packages do. Most of the diff is that adaptation. A secondary chunk removes workarounds that v8 made unnecessary, and one change gives up a property v8 removed.Relevant changes between undici 7 and 8
1. Node.js floor moved to 22.19
undici@8declaresengines: {"node": ">=22.19.0"};undici@7.29.0declared>=20.18.1.Nothing under
packages/declaresengines, so this PR does not change what npm/pnpm will let a consumer install. It does mean@workflow/world-verceland@workflow/world-localno longer support Node 18 or 20 in practice. The rootpackage.jsonstill says^18.0.0 || ^20.0.0 || ^22.0.0 || ^24.0.0. Whether to narrow that, and whether to start declaringengineson the published packages, is a policy call I left for review rather than making here. CI runsnode-version: 22.x, which resolves above the floor.2. The dispatcher handler ABI is v2 only (#4786)
v8 removed the legacy handler wrappers. A dispatcher is now driven with the v2 callbacks:
onConnectonRequestStartonHeadersonResponseStartonDataonResponseDataonCompleteonResponseEndonErroronResponseErroronUpgradeonRequestUpgradePause, resume and abort moved onto a
DispatchControllerhanded to the callbacks.This matters because every request in
world-vercelgoes through the globalfetch(makeRequestinhttp-core.ts, andfetchV4inevents-v4.ts, which does so deliberately to stay visible in Vercel's outgoing-requests view). Thatfetchbelongs to the undici bundled into Node, not to the undici in our dependencies, and it drives a customdispatcherwith a v1 handler. Two failure modes, both observed:Agent:TypeError: fetch failed, causeInvalidArgumentError: invalid onRequestStart method;RetryAgentor a composed dispatcher: no callback the v1 handler implements is ever called, sofetch()never settles. The request hangs until the caller's own timeout.The fix is a v1-to-v2 bridge applied as the outermost layer of every dispatcher this package hands to
fetch(forGlobalFetch). It is applied once at construction, not per request, becauseDispatcherRecycler.note()identifies the dispatcher a request used by reference.3. undici's own bridge drops every response header over HTTP/2
undici ships
Dispatcher1Wrapperfor exactly this, and it cannot be used on an H2 path. It forwardscontroller.rawHeadersto the v1onHeaders.lib/core/request.jssets that field unconditionally, but the two protocols fill it with different shapes: the H1 parser supplies the rawBuffer[]name/value pair list a v1 handler expects, whileclient-h2.jssupplies a parsed{ name: value }object. That object has no.length, so the pair-wise loop infetch's v1 handler reads zero headers. Status and body arrive intact, andHeadersis empty.undici masks this by forcing
allowH2: falseinto every dispatch:The stated reason is that a v1 handler cannot carry a WebSocket upgrade over H2 (nodejs/undici#4989).
Agenthonours the flag per request by selecting a different pool:So using undici's wrapper silently downgrades the events agent to HTTP/1.1: every option would still say H2 while ALPN negotiated
http/1.1, undoing the multiplexing and flow-control work those agents exist for. It also breaksMockAgent, which registers its interceptors on the un-suffixed key, so the injected flag sends mocked requests to a pool holding no interceptors and they escape to the network.An earlier revision of this PR used the wrapper and composed an interceptor under it to delete the injected flag. That produced the header bug above in production: every E2E Vercel Prod lane failed with
v4 listEvents: expected application/vnd.workflow.v4-frames, got (none), becausefetchV4checks the responsecontent-typeand there were no response headers at all.This PR therefore carries its own bridge (
V1BridgeDispatcherinhttp-client.ts), which normalizes the header shape to theBuffer[]pair list a v1 handler parses, and leavesallowH2untouched. That is safe for these dispatchers specifically: they carry plain request/response traffic only, and the WebSocket transport (events-v4-ws.ts) does not go through them.http-client.test.tsnow asserts that response headers survive over h2, including a repeated header; the h2 harness previously replied with no headers at all, which is why nothing caught this.4. Caller-supplied dispatchers now need bridging too, across three undici majors
APIConfig.dispatcheris documented as accepting a dispatcher from any undici version, and the four accessors used to return it untouched. A caller who installsundicitoday gets 8, and that dispatcher hangs under globalfetchfor the reason in (2).bridgeCallerDispatcherapplies the same bridge, memoized in aWeakMapso the recycler's and the retry bookkeeping's by-reference comparisons still hold.undici 6 complicates this: it speaks the v1 ABI only, validates the handler on dispatch, and rejects a pure v2 one with
invalid onError method. Since a v6 dispatcher works fine unbridged today, bridging it naively would be a regression. The bridge therefore emits a handler carrying both ABIs. Measured against undici 6.28, 7.29 and 8.10 with a handler exposing both: 6 drives only the v1 callbacks, 7 and 8 drive only the v2 ones, and none of them drives both. All three complete a real h2 request and aMockAgentrequest through the bridge.5. HTTP/2 is on by default (#4828)
v8 negotiates H2 whenever the origin offers it over ALPN. Any path that must stay on HTTP/1.1 has to say so.
DEFAULT_AGENT_OPTIONSalready did;world-local's queue agent now setsallowH2: falseexplicitly rather than inheriting the old default.6. The H2 in-flight ceiling is protocol-aware, so
pipeliningis no longer a multiplexing knob (#5362, fixing #4143)In v7,
pipelininghad to be set for H2 to multiplex at all: undici gated in-flight requests ongetPipelining(client), and the Client constructor coercedpipeliningto a number, soclient-h2.js'sdefaultPipelining: Infinitywas unreachable. Our events agent carriedpipelining: 100purely for that.v8 picks the ceiling by protocol: on H2 it is the peer's
SETTINGS_MAX_CONCURRENT_STREAMS, andpipeliningonly applies before a protocol is negotiated.pipeliningis now removed fromEVENTS_AGENT_OPTIONS. Measured against a loopback H2 origin, 16 concurrent requests run as 16 parallel streams on one connection with nopipeliningset. Leaving it in would have raised H1 pipelining depth on an H2 fallback, which is whatDEFAULT_AGENT_OPTIONSdeliberately avoids.7.
busy()no longer serializes non-idempotent or stream-bodied requests (#5391, #5538)v7's H2
busy()returned true forrequest.idempotent === falseand for a stream/async-iterable body. v8 dispatches both concurrently, on the reasoning that H2 streams are independent.This cuts both ways:
h2MultiplexInterceptorexisted largely to relabel POSTsidempotent: trueso they would not serialize. That relabelling is gone. What remains is the body re-buffering, so the interceptor is renamedbufferStreamedBodyInterceptor. That part is still needed for a different reason:fetchconverts every body into an async iterable, an async iterable can be consumed once, and on a RetryAgent re-dispatch the exhausted body aborts the request withNGHTTP2_PROTOCOL_ERRORinstead of resending.busy()gate. Stream appends are not idempotent, andSTREAM_RETRY_OPTIONSretries PUT on transienterrorCodes, so multiplexing N appends onto one connection would let a single GOAWAY or socket reset fail all N and resend chunks the server may already have applied. Measured on v8: 16 concurrent appends went from 1 to 16 in flight on one connection. There is no client-side option that caps it back (see 8), soSTREAM_AGENT_OPTIONSnow setsallowH2: false. Those requests send a fully buffered body or none, so H2 was safe for them, but with multiplexing suppressed it was doing nothing the H1 agent does not.8. h2 options were namespaced, and the replacement has two bugs (#5498, 8.10.0)
The flat
maxConcurrentStreams,initialWindowSizeandconnectionWindowSizeare marked@deprecatedin favour ofh2Options.*. The events agent keeps the flat spelling, because the documented replacement does not work:h2Options.maxConcurrentStreamsthrowsInvalidArgumentError. While validatingmaxConcurrentStreams, undici validatesh2Options.connectionWindowSize(lib/dispatcher/client.js), so the option is unusable on its own.h2Options.settings.initialWindowSizeis validated and typed, and then ignored: undici readsh2Options.initialWindowSize. Measured against a loopback H2 origin, the flat option puts 4 MiB on the wire, while the documented spelling leaves the server seeing undici's 256 KiB default.Separately confirmed (unchanged from v7, worth stating because it is easy to reach for): the
maxConcurrentStreamsoption only seedspeerMaxConcurrentStreamsat connect time and is overwritten by the server's SETTINGS, so it cannot be used to cap in-flight streams. That is why (7) is solved withallowH2: falserather than a stream cap.9.
MockAgentchanges the shape ofopts.bodyseen by reply callbacksTest-only, but it accounts for a chunk of the diff. When the handler exposes body-sent hooks, and
Dispatcher1Wrapper'sLegacyHandlerWrapperalways defines them, v8'smockDispatchdefers the reply callback, drains the outgoing body, and substitutes a replayable async iterable. v7 passed whateverfetchdispatched (aUint8Array) straight through. Reply callbacks that inspect the request body therefore have to drain it, which makes them async. v8 also accepts promise-returning reply callbacks, which is what makes that possible.10. Unchanged, checked because a break here would be silent
Dispatcher.compose()still returns a Proxy that losesthis.close()on a composedRetryAgentthrowsTypeError: Cannot read private member #agent. The existingwithBoundLifecycleworkaround stays.require('node:http2')(lib/dispatcher/client-h2.js). ThecreateRequirebanner shims inpackages/nitro,packages/sveltekitandpackages/webare still load-bearing and are unchanged.Testing
@workflow/world-vercel: 479 tests across 23 files pass.@workflow/world-local: 524 tests across 14 files pass.New coverage in
http-client.test.ts:dispatch, passes the dispatch options through untouched, and is stable per input.Headersif the bridge forwards the H2 header object through unnormalized.undici@8Agentcompletes a real request through globalfetch, still negotiates h2, and sees its response headers. Unbridged, that request fails withinvalid onRequestStart method.allowH2: false, expected not to) are measured against the same concurrency barrier.Measurements referenced above were taken with throwaway probe scripts against a loopback
node:http2origin, not checked in.Risk
The behavior change most likely to be felt in production is (7): stream write and close now speak HTTP/1.1 rather than h2-without-multiplexing. Wire-level shape per request is the same, one request per connection either way.
The bridge in (2), (3) and (4) sits on every outgoing request in
world-vercel, so it is the highest-blast-radius part of the diff. It is also the part that already failed once in production shape, on the header path in (3), which the E2E Vercel Prod lanes caught and unit tests did not.