fix: decompress gzipped responses on Node 24+ (undici 7)#63
fix: decompress gzipped responses on Node 24+ (undici 7)#63scottlovegrove merged 2 commits intomainDoist/outline-cli:mainfrom scottl/node-26-undici-gzip-decompressionDoist/outline-cli:scottl/node-26-undici-gzip-decompressionCopy head branch name to clipboard
Conversation
Compose undici's response-decompression interceptor onto the dispatcher so gzip/deflate/br/zstd response bodies are decoded before the CLI parses them. On Node 24+, attaching any custom dispatcher to global `fetch` strips `content-encoding` from the response headers but does not actually decompress the body, so `res.json()` chokes on raw gzipped bytes with "Unexpected token ... is not valid JSON" — and `ol auth status` (and the OAuth login flow's `auth.info` step) fail. Suppress undici's one-time `ExperimentalWarning` for the decompress interceptor so it does not leak to every CLI invocation's stderr on the first request. The interceptor is fully implemented and stable for our gzipped-JSON-over-HTTPS use case. Same fix already shipped in @doist/todoist-sdk 10.1.3 (Doist/todoist-cli#318) and @doist/twist-sdk 2.5.1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
doistbot
left a comment
There was a problem hiding this comment.
Thank you for putting together this fix for Node 24+ decompression; composing the native Undici interceptor is a great approach that preserves bandwidth while resolving the JSON parsing errors. The implementation is clean and effective, though there are a few noted areas for refinement around the experimental warning suppression helper. The feedback focuses on enforcing strict synchronous execution in the helper's type signature, avoiding forced type assertions to better align with repository guidelines, and adding an integration test to verify that the dispatcher successfully swallows the warning in practice.
- Constrain `suppressExperimentalWarningsSync` to synchronous callbacks at the type level via `SyncOnly<T>` (`T extends PromiseLike<unknown> ? never : T`). Async usage is now a compile error. Add a runtime thenable guard for defence-in-depth. - Drop the `(originalEmit as (...args: unknown[]) => void)` cast. Forward args via `Reflect.apply` with a `Parameters<typeof process.emitWarning>` tuple. One cast remains at the assignment boundary because TS overloads can't be matched cleanly otherwise. - Add an integration test that mocks `undici.interceptors.decompress` to emit an `ExperimentalWarning` synchronously at compose time and verifies `getDefaultDispatcher()` does not forward it. Catches the regression case the unit tests can't (helper not wrapping decompress, warning emitted before suppression installed). - Add a runtime test for the thenable guard. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## [1.5.2](v1.5.1...v1.5.2) (2026-05-09) ### Bug Fixes * decompress gzipped responses on Node 24+ (undici 7) ([#63](#63)) ([c44fbea](c44fbea))
|
🎉 This PR is included in version 1.5.2 🎉 The release is available on: Your semantic-release bot 📦🚀 |
Summary
interceptors.decompress()onto the dispatcher sogzip/deflate/br/zstdresponse bodies are decoded before the CLI parses them.ExperimentalWarningfor the decompress interceptor so it does not leak to everyolinvocation's stderr.Problem
On Node 24+, attaching any custom dispatcher to global
fetchstrips the response'scontent-encodingheader but does not actually decompress the body.ol auth status(and the OAuth login flow'sauth.infostep) fail withUnexpected token '...' is not valid JSONbecauseres.json()is parsing raw gzipped bytes.Fix
undici 7 ships a built-in
decompressinterceptor (public API:undici.interceptors.decompress) that handles gzip/deflate/br/zstd, stripscontent-encoding/content-lengthfrom upstream headers (so Node's fetch layer doesn't try to decompress again), and is composed via the standardDispatcher.prototype.compose(...)API. ~1 line of dispatcher change; preserves wire compression (noAccept-Encoding: identityworkaround, no bandwidth regression).The undici interceptor is currently flagged
ExperimentalWarningbut is fully implemented and stable for HTTPS gzipped-JSON. The change includes a smallsuppressExperimentalWarningsSynchelper (exported for direct unit testing) that suppresses everyExperimentalWarningduring the synchronous dispatcher-init critical section. The synchronous contract is what makes the globalprocess.emitWarningoverride safe — no unrelated code can interleave during the microsecond window.Supersedes #62
#62 sidesteps the bug by sending
Accept-Encoding: identityso the server skips compression entirely. That works but trades wire bandwidth for simplicity (~70-80% inflation on JSON payloads). Composing the interceptor is the proper fix and preserves compression. Recommend closing #62 once this lands.This is the same fix already shipped in
@doist/todoist-sdk@10.1.3(Doist/todoist-cli#318) and@doist/twist-sdk@2.5.1. outline-cli has its own transport layer rather than going through a shared SDK, so the fix lives directly in the CLI.Test plan
npm test— 129/129 passing, including 4 new dispatcher tests:Agent/EnvHttpProxyAgentinstanceof + cache + reset assertions still pass (compose preserves the prototype chain)decompresses gzip-encoded response bodies— spins up anode:httpserver, returnsContent-Encoding: gzipwith raw gzipped JSON, verifies the dispatcher decodes itsuppressExperimentalWarningsSyncunit tests — directly verify the helper swallows bothemitWarningsignatures, restores the original on throw, and returns the callback resultnpm run lint:check— cleannpm run format:check— cleannpm run type-check— clean🤖 Generated with Claude Code