diff --git a/AGENTS.md b/AGENTS.md index 71b3e45e..303ce464 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,11 @@ This file is the canonical contributor guide for both human and AI agents workin - Keep public runtime exports, `index.d.ts` (ESM types), and `index.d.cts` (CJS `export = axios` types) in sync for API changes. - `lib/env/data.js` is version-generated by `gulp version`; do not edit it for normal feature work. +## Pre-Release Notes + +- Add user-visible unreleased changes to `PRE_RELEASE_CHANGELOG.md`, not `CHANGELOG.md`. `CHANGELOG.md` is release-owned and should only be updated as part of preparing an actual release. +- Do not update `README.md` or the docs site for unreleased runtime/API changes unless the task is explicitly release preparation. Instead, record the exact README/docs updates needed under `PRE_RELEASE_CHANGELOG.md` so they can be applied during release work. + ## Architecture Boundaries - `lib/core/` is axios domain logic: request dispatch, config merge, interceptors, headers, errors. Key classes: `Axios` (request dispatch + interceptor chains), `AxiosError` (standardized error codes), `AxiosHeaders` (case-insensitive header normalization), `InterceptorManager` (sync/async interceptor registration). diff --git a/PRE_RELEASE_CHANGELOG.md b/PRE_RELEASE_CHANGELOG.md new file mode 100644 index 00000000..eae2bf3a --- /dev/null +++ b/PRE_RELEASE_CHANGELOG.md @@ -0,0 +1,13 @@ +# Pre-Release Changelog + +## Unreleased + +## New Features + +- **HTTP Adapter - Zstandard:** Added automatic zstd decompression on Node.js versions that support it. `zstd` is only advertised in the default `Accept-Encoding` header when `advertiseZstd: true` is set. (**#6792**) + +## Release Documentation TODO + +- Update `README.md` request config docs for `advertiseZstd` and zstd decompression support. +- Update `docs/pages/advanced/request-config.md` for `advertiseZstd` and zstd decompression support. +- Update decompression-bomb security guidance in `README.md` and `docs/pages/misc/security.md` to mention zstd. diff --git a/index.d.cts b/index.d.cts index 916b9958..b434400d 100644 --- a/index.d.cts +++ b/index.d.cts @@ -512,6 +512,7 @@ declare namespace axios { proxy?: AxiosProxyConfig | false; cancelToken?: CancelToken | undefined; decompress?: boolean; + advertiseZstd?: boolean; transitional?: TransitionalOptions; signal?: GenericAbortSignal; insecureHTTPParser?: boolean; diff --git a/index.d.ts b/index.d.ts index e25555fa..b6e4da26 100644 --- a/index.d.ts +++ b/index.d.ts @@ -410,6 +410,7 @@ export interface AxiosRequestConfig { proxy?: AxiosProxyConfig | false; cancelToken?: CancelToken | undefined; decompress?: boolean; + advertiseZstd?: boolean; transitional?: TransitionalOptions; signal?: GenericAbortSignal; insecureHTTPParser?: boolean; diff --git a/lib/adapters/http.js b/lib/adapters/http.js index 3e0f4f3a..37a87fdc 100755 --- a/lib/adapters/http.js +++ b/lib/adapters/http.js @@ -44,7 +44,15 @@ const brotliOptions = { finishFlush: zlib.constants.BROTLI_OPERATION_FLUSH, }; +const zstdOptions = { + flush: zlib.constants.ZSTD_e_flush, + finishFlush: zlib.constants.ZSTD_e_flush, +}; + const isBrotliSupported = utils.isFunction(zlib.createBrotliDecompress); +const isZstdSupported = utils.isFunction(zlib.createZstdDecompress); +const ACCEPT_ENCODING = 'gzip, compress, deflate' + (isBrotliSupported ? ', br' : ''); +const ACCEPT_ENCODING_WITH_ZSTD = ACCEPT_ENCODING + (isZstdSupported ? ', zstd' : ''); const { http: httpFollow, https: httpsFollow } = followRedirects; @@ -861,7 +869,7 @@ export default isHttpAdapterSupported && headers.set( 'Accept-Encoding', - 'gzip, compress, deflate' + (isBrotliSupported ? ', br' : ''), + own('advertiseZstd') === true ? ACCEPT_ENCODING_WITH_ZSTD : ACCEPT_ENCODING, false ); @@ -1037,6 +1045,13 @@ export default isHttpAdapterSupported && streams.push(zlib.createBrotliDecompress(brotliOptions)); delete res.headers['content-encoding']; } + break; + case 'zstd': + if (isZstdSupported) { + streams.push(zlib.createZstdDecompress(zstdOptions)); + delete res.headers['content-encoding']; + } + break; } } diff --git a/lib/core/mergeConfig.js b/lib/core/mergeConfig.js index 760f5ad7..eaa00493 100644 --- a/lib/core/mergeConfig.js +++ b/lib/core/mergeConfig.js @@ -96,6 +96,7 @@ export default function mergeConfig(config1, config2) { onUploadProgress: defaultToConfig2, onDownloadProgress: defaultToConfig2, decompress: defaultToConfig2, + advertiseZstd: defaultToConfig2, maxContentLength: defaultToConfig2, maxBodyLength: defaultToConfig2, beforeRedirect: defaultToConfig2, diff --git a/tests/unit/adapters/http.test.js b/tests/unit/adapters/http.test.js index 56e2e934..b8867cd7 100644 --- a/tests/unit/adapters/http.test.js +++ b/tests/unit/adapters/http.test.js @@ -727,6 +727,9 @@ describe('supports http with nodejs', () => { }); describe('compression', async () => { + const isZstdSupported = typeof zlib.createZstdDecompress === 'function' && + typeof zlib.zstdCompress === 'function'; + it('should support transparent gunzip', async () => { const data = { firstName: 'Fred', @@ -828,6 +831,50 @@ describe('supports http with nodejs', () => { } }); + it('should not advertise zstd by default', async () => { + let acceptEncoding; + + const server = await startHTTPServer( + (req, res) => { + acceptEncoding = req.headers['accept-encoding']; + res.end('ok'); + }, + { port: SERVER_PORT } + ); + + try { + await axios.get(`http://localhost:${server.address().port}/`); + assert.strictEqual(acceptEncoding.includes('zstd'), false); + } finally { + await stopHTTPServer(server); + } + }); + + it('should advertise zstd when enabled and supported', async () => { + if (!isZstdSupported) { + return; + } + + let acceptEncoding; + + const server = await startHTTPServer( + (req, res) => { + acceptEncoding = req.headers['accept-encoding']; + res.end('ok'); + }, + { port: SERVER_PORT } + ); + + try { + await axios.get(`http://localhost:${server.address().port}/`, { + advertiseZstd: true, + }); + assert.strictEqual(acceptEncoding.includes('zstd'), true); + } finally { + await stopHTTPServer(server); + } + }); + describe('algorithms', () => { const responseBody = 'str'; @@ -879,6 +926,18 @@ describe('supports http with nodejs', () => { }); }); + const zstdCompress = (value) => + new Promise((resolve, reject) => { + zlib.zstdCompress(value, (error, compressed) => { + if (error) { + reject(error); + return; + } + + resolve(compressed); + }); + }); + for (const [typeName, zipped] of Object.entries({ gzip: gzip(responseBody), GZIP: gzip(responseBody), @@ -886,6 +945,7 @@ describe('supports http with nodejs', () => { deflate: deflate(responseBody), 'deflate-raw': deflateRaw(responseBody), br: brotliCompress(responseBody), + ...(isZstdSupported ? { zstd: zstdCompress(responseBody) } : {}), })) { const type = typeName.split('-')[0];