fix: use undici's own fetch to avoid version mismatch on Node 26#44
Merged
Conversation
doistbot
reviewed
Jul 2, 2026
doistbot
left a comment
Member
There was a problem hiding this comment.
This PR pairs undici's own fetch with its dispatcher to eliminate the version mismatch that caused gzip responses to fail on Node 26, with a clean fallback to global fetch for browser/edge paths.
Few things worth tightening:
- Atomicity of dispatcher/fetch pairing:
dispatcherandfetchImplare read from separate mutable singletons, so a concurrentcloseDefaultDispatcher()can cleardefaultFetchwhile the old dispatcher is still in use — recreating the exact mismatch this PR fixes. Consider returning{ dispatcher, fetch }from a single accessor or caching them together so callers can never observe a mixed transport. - Missing test for the core wiring: The assignment
defaultFetch = undiciFetch(aftergetDefaultDispatcher()resolves on the Node path) is untested — removing it would silently revert the fix with no test failure. A simple assertion alongside the existing "returns a dispatcher in Node" test (e.g.,expect(getDefaultFetch()).toEqual(undiciFetch)) would cover the bridge. - Unnecessary optional chaining:
getDefaultFetchis always a defined named import (and every mock suppliesvi.fn()), so?.()is a pointless truthiness check on every request. UsegetDefaultFetch() ?? fetchinstead.
I also included a few optional follow-up notes in the details below.
Optional follow-up note (1)
src/transport/fetch-with-retry.ts:97:
getDefaultFetchis always a defined function export — it can never benullorundefinedat the call site (it's a named import, and every test mock also supplies it asvi.fn()). The?.()optional chaining adds a pointless truthiness check on every request. UsegetDefaultFetch() ?? fetchinstead.
The transport builds its dispatcher (EnvHttpProxyAgent + decompress
interceptor) from the npm `undici` package, then hands it to Node's global
`fetch`, which is backed by whatever undici ships inside the Node release
(6.x on Node 22 … 8.x on Node 26). Driving a dispatcher from one undici
version with a `fetch` core from another makes the decompress interceptor
terminate gzip responses mid-stream with `terminated` — every authenticated
Comms request fails on Node 26.
Global `fetch` cannot satisfy both response framings on Node 26: chunked
responses need the decompress interceptor, whereas the Comms API's
Content-Length responses break with it. Only pairing the dispatcher with
undici's own `fetch` — one version end to end — decodes both correctly.
Source `fetch` from the same `import('undici')` that builds the dispatcher
and use it on the Node path; fall back to the global `fetch` in the
browser/edge path where there is no dispatcher. Server-side compression is
kept.
Tests intercept the global `fetch` via MSW, which does not see the separate
undici instance, so a suite-wide seam routes MSW tests through the global
`fetch`; the transport tests opt out and exercise the real undici path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
e6abe3e to
6908bd3
Compare
Address review feedback: - Cache the dispatcher and its paired `fetch` as one `DefaultTransport` object read via `getDefaultTransport()`, so a concurrent `closeDefaultDispatcher()` can no longer clear the fetch between the two reads and leave an old dispatcher paired with the global `fetch`. - Add a test asserting `getDefaultFetch()` returns undici's own `fetch` after the dispatcher resolves, covering the wiring that fixes the mismatch. - Drop the pointless optional chaining on the `getDefaultFetch` import. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
doist-release-bot Bot
added a commit
that referenced
this pull request
Jul 2, 2026
## [0.7.1](v0.7.0...v0.7.1) (2026-07-02) ### Bug Fixes * use undici's own fetch to avoid version mismatch on Node 26 ([#44](#44)) ([59f3677](59f3677))
Contributor
|
🎉 This PR is included in version 0.7.1 🎉 The release is available on: Your semantic-release bot 📦🚀 |
This was referenced Jul 2, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
On Node 26, every authenticated Comms request fails with
CommsRequestError: terminated. Reproduced against the real CLI on Node 26 (tdc doctor→Stored credentials failed validation: terminated); Node 22/24 are unaffected.Root cause
The transport builds its dispatcher (
EnvHttpProxyAgent+interceptors.decompress()) from the npmundicipackage (7.28.0), then hands it to Node's globalfetch, which is backed by whatever undici ships inside the Node release:Driving a v7 dispatcher/interceptor with a v8
fetchcore makes the decompress interceptor terminate gzip responses mid-stream. Bumping the npmundiciis not a fix — it just moves the mismatch (npm undici 8 on Node 24 hangs).Global
fetchcannot satisfy both response framings on Node 26 either:Only pairing the dispatcher with undici's own
fetch— one undici version end to end — decodes both framings correctly.Fix
fetchfrom the sameimport('undici')that builds the dispatcher, and use it on the Node path; fall back to the globalfetchin the browser/edge path (no dispatcher).Tests
MSW intercepts the global
fetch, not the separate undici instance, so a suite-wide seam inmsw-setup.tsroutes MSW tests through the globalfetch; the transport tests opt out and exercise the real undici path. Added coverage that undici's pairedfetchis preferred, with global-fetchfallback.Verified
tsc+ oxlint + oxfmt clean.doctor+inboxon Node 22 / 24 / 26: all pass (wasterminatedon 26).The same three-line pattern applies to
twist-sdkandtodoist-sdk(near-identical transport, minus theallowH2line) — follow-up once this is confirmed.🤖 Generated with Claude Code