From 9a6c6b49e0c4df94a36172803fca59baa5ae8f21 Mon Sep 17 00:00:00 2001 From: Volodymyr Kolesnykov Date: Tue, 12 May 2026 02:20:36 +0300 Subject: [PATCH 1/4] refactor: replace node-fetch/nock with undici - Remove node-fetch, nock, and fetch-retry dependencies - Add undici as the sole fetch/mock runtime - Migrate all runtime fetch calls (api/http, analytics, dev-environment-core, client-file-uploader) to undici fetch - Replace CommonJS fetch-retry wrapper with a local exponential backoff helper in client-file-uploader - Use node:timers/promises setTimeout in retry.ts and client-file-uploader (eliminates manual Promise wrapping) - Add src/lib/http/proxy-dispatcher.ts for undici-compatible proxy support (ProxyAgent) - Replace jest.setup.js nock setup with undici MockAgent as global dispatcher; add test-utils/undici-mock.js helper - Migrate all nock-based tests to undici MockPool interceptors - Remove nock lifecycle calls from devenv e2e specs --- __tests__/devenv-e2e/002-destroy.spec.js | 5 - __tests__/devenv-e2e/003-start.spec.js | 5 - __tests__/devenv-e2e/004-stop.spec.js | 5 - __tests__/devenv-e2e/006-list.spec.js | 6 - __tests__/devenv-e2e/007-info.spec.js | 6 - __tests__/devenv-e2e/008-exec.spec.js | 5 - __tests__/devenv-e2e/010-import-sql.spec.js | 5 - __tests__/devenv-e2e/011-logs.spec.js | 5 - __tests__/devenv-e2e/012-shell.spec.js | 5 - __tests__/lib/analytics/clients/tracks.js | 51 +++++-- __tests__/lib/api-retry.ts | 5 +- .../dev-environment/dev-environment-cli.js | 39 +++-- __tests__/lib/search-and-replace.js | 4 - .../validations/is-multisite-domain-mapped.js | 40 +++-- __tests__/lib/validations/sql.js | 4 - jest.setup.js | 24 ++- npm-shrinkwrap.json | 142 ++---------------- package.json | 4 +- src/lib/analytics/clients/client.ts | 2 - src/lib/analytics/clients/pendo.ts | 1 - src/lib/analytics/clients/tracks.ts | 2 +- src/lib/analytics/index.ts | 1 - src/lib/api.ts | 3 +- src/lib/api/http.ts | 22 +-- src/lib/client-file-uploader.ts | 41 +++-- .../dev-environment/dev-environment-core.ts | 8 +- src/lib/http/proxy-dispatcher.ts | 60 ++++++++ src/lib/retry.ts | 5 +- src/lib/tracker.ts | 1 - test-utils/undici-mock.js | 25 +++ 30 files changed, 234 insertions(+), 297 deletions(-) create mode 100644 src/lib/http/proxy-dispatcher.ts create mode 100644 test-utils/undici-mock.js diff --git a/__tests__/devenv-e2e/002-destroy.spec.js b/__tests__/devenv-e2e/002-destroy.spec.js index d39fdaf26..cd0f3f32c 100644 --- a/__tests__/devenv-e2e/002-destroy.spec.js +++ b/__tests__/devenv-e2e/002-destroy.spec.js @@ -1,6 +1,5 @@ import { describe, expect, it, jest } from '@jest/globals'; import Docker from 'dockerode'; -import nock from 'nock'; import { access, mkdtemp, rm, unlink } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -28,9 +27,6 @@ describe( 'vip dev-env destroy', () => { let tmpPath; beforeAll( async () => { - nock.cleanAll(); - nock.enableNetConnect(); - cliTest = new CliTest(); tmpPath = await mkdtemp( path.join( os.tmpdir(), 'vip-dev-env-' ) ); @@ -40,7 +36,6 @@ describe( 'vip dev-env destroy', () => { } ); afterAll( () => rm( tmpPath, { recursive: true, force: true } ) ); - afterAll( () => nock.restore() ); it( 'should fail if environment does not exist', async () => { const slug = getProjectSlug(); diff --git a/__tests__/devenv-e2e/003-start.spec.js b/__tests__/devenv-e2e/003-start.spec.js index c67a1b502..66333b0f2 100644 --- a/__tests__/devenv-e2e/003-start.spec.js +++ b/__tests__/devenv-e2e/003-start.spec.js @@ -1,6 +1,5 @@ import { describe, expect, it, jest } from '@jest/globals'; import Docker from 'dockerode'; -import nock from 'nock'; import { mkdtemp, rm } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -31,9 +30,6 @@ describe( 'vip dev-env start', () => { let slug; beforeAll( async () => { - nock.cleanAll(); - nock.enableNetConnect(); - cliTest = new CliTest(); tmpPath = await mkdtemp( path.join( os.tmpdir(), 'vip-dev-env-' ) ); @@ -45,7 +41,6 @@ describe( 'vip dev-env start', () => { } ); afterAll( () => rm( tmpPath, { recursive: true, force: true } ) ); - afterAll( () => nock.restore() ); afterEach( () => killProjectContainers( docker, slug ) ); diff --git a/__tests__/devenv-e2e/004-stop.spec.js b/__tests__/devenv-e2e/004-stop.spec.js index a37ee9a87..55d72f7bf 100644 --- a/__tests__/devenv-e2e/004-stop.spec.js +++ b/__tests__/devenv-e2e/004-stop.spec.js @@ -1,6 +1,5 @@ import { afterAll, describe, expect, it, jest } from '@jest/globals'; import Docker from 'dockerode'; -import nock from 'nock'; import { mkdtemp, rm } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -31,9 +30,6 @@ describe( 'vip dev-env stop', () => { let slug; beforeAll( async () => { - nock.cleanAll(); - nock.enableNetConnect(); - cliTest = new CliTest(); tmpPath = await mkdtemp( path.join( os.tmpdir(), 'vip-dev-env-' ) ); @@ -45,7 +41,6 @@ describe( 'vip dev-env stop', () => { } ); afterAll( () => rm( tmpPath, { recursive: true, force: true } ) ); - afterAll( () => nock.restore() ); afterEach( () => killProjectContainers( docker, slug ) ); diff --git a/__tests__/devenv-e2e/006-list.spec.js b/__tests__/devenv-e2e/006-list.spec.js index 478f5de86..93242eeca 100644 --- a/__tests__/devenv-e2e/006-list.spec.js +++ b/__tests__/devenv-e2e/006-list.spec.js @@ -1,6 +1,5 @@ import { describe, expect, it, jest } from '@jest/globals'; import Docker from 'dockerode'; -import nock from 'nock'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -30,14 +29,9 @@ describe( 'vip dev-env list', () => { let tmpPath; beforeAll( () => { - nock.cleanAll(); - nock.enableNetConnect(); - cliTest = new CliTest(); } ); - afterAll( () => nock.restore() ); - beforeEach( async () => { tmpPath = await mkdtemp( path.join( os.tmpdir(), 'vip-dev-env-' ) ); process.env.XDG_DATA_HOME = tmpPath; diff --git a/__tests__/devenv-e2e/007-info.spec.js b/__tests__/devenv-e2e/007-info.spec.js index fbdb14c1b..551d8cca6 100644 --- a/__tests__/devenv-e2e/007-info.spec.js +++ b/__tests__/devenv-e2e/007-info.spec.js @@ -1,6 +1,5 @@ import { describe, expect, it, jest } from '@jest/globals'; import Docker from 'dockerode'; -import nock from 'nock'; import { mkdtemp, rm } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -24,14 +23,9 @@ describe( 'vip dev-env info', () => { let tmpPath; beforeAll( () => { - nock.cleanAll(); - nock.enableNetConnect(); - cliTest = new CliTest(); } ); - afterAll( () => nock.restore() ); - beforeEach( async () => { tmpPath = await mkdtemp( path.join( os.tmpdir(), 'vip-dev-env-' ) ); process.env.XDG_DATA_HOME = tmpPath; diff --git a/__tests__/devenv-e2e/008-exec.spec.js b/__tests__/devenv-e2e/008-exec.spec.js index 949ff51f6..edf4df1c4 100644 --- a/__tests__/devenv-e2e/008-exec.spec.js +++ b/__tests__/devenv-e2e/008-exec.spec.js @@ -1,6 +1,5 @@ import { describe, expect, it, jest } from '@jest/globals'; import Docker from 'dockerode'; -import nock from 'nock'; import { mkdtemp, rm } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -27,9 +26,6 @@ describe( 'vip dev-env exec', () => { let tmpPath; beforeAll( async () => { - nock.cleanAll(); - nock.enableNetConnect(); - cliTest = new CliTest(); tmpPath = await mkdtemp( path.join( os.tmpdir(), 'vip-dev-env-' ) ); @@ -39,7 +35,6 @@ describe( 'vip dev-env exec', () => { } ); afterAll( () => rm( tmpPath, { recursive: true, force: true } ) ); - afterAll( () => nock.restore() ); describe( 'if the environment does not exist', () => { it( 'should fail', async () => { diff --git a/__tests__/devenv-e2e/010-import-sql.spec.js b/__tests__/devenv-e2e/010-import-sql.spec.js index d28c78122..98dd06f26 100644 --- a/__tests__/devenv-e2e/010-import-sql.spec.js +++ b/__tests__/devenv-e2e/010-import-sql.spec.js @@ -1,6 +1,5 @@ import { describe, expect, it, jest } from '@jest/globals'; import Docker from 'dockerode'; -import nock from 'nock'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -27,9 +26,6 @@ describe( 'vip dev-env import sql', () => { let tmpPath; beforeAll( async () => { - nock.cleanAll(); - nock.enableNetConnect(); - cliTest = new CliTest(); tmpPath = await mkdtemp( path.join( os.tmpdir(), 'vip-dev-env-' ) ); @@ -39,7 +35,6 @@ describe( 'vip dev-env import sql', () => { } ); afterAll( () => rm( tmpPath, { recursive: true, force: true } ) ); - afterAll( () => nock.restore() ); describe( 'if the environment does not exist', () => { it( 'should fail', async () => { diff --git a/__tests__/devenv-e2e/011-logs.spec.js b/__tests__/devenv-e2e/011-logs.spec.js index 4180b3693..ea82af34a 100644 --- a/__tests__/devenv-e2e/011-logs.spec.js +++ b/__tests__/devenv-e2e/011-logs.spec.js @@ -1,6 +1,5 @@ import { describe, expect, it } from '@jest/globals'; import Docker from 'dockerode'; -import nock from 'nock'; import { mkdtemp, rm } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -25,9 +24,6 @@ describe( 'vip dev-env logs', () => { let tmpPath; beforeAll( async () => { - nock.cleanAll(); - nock.enableNetConnect(); - cliTest = new CliTest(); tmpPath = await mkdtemp( path.join( os.tmpdir(), 'vip-dev-env-' ) ); @@ -37,7 +33,6 @@ describe( 'vip dev-env logs', () => { } ); afterAll( () => rm( tmpPath, { recursive: true, force: true } ) ); - afterAll( () => nock.restore() ); describe( 'if the environment does not exist', () => { it( 'should fail', async () => { diff --git a/__tests__/devenv-e2e/012-shell.spec.js b/__tests__/devenv-e2e/012-shell.spec.js index 4fabf7801..fd79edafd 100644 --- a/__tests__/devenv-e2e/012-shell.spec.js +++ b/__tests__/devenv-e2e/012-shell.spec.js @@ -1,6 +1,5 @@ import { describe, expect, it, jest } from '@jest/globals'; import Docker from 'dockerode'; -import nock from 'nock'; import { mkdtemp, rm } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -27,9 +26,6 @@ describe( 'vip dev-env shell', () => { let tmpPath; beforeAll( async () => { - nock.cleanAll(); - nock.enableNetConnect(); - cliTest = new CliTest(); tmpPath = await mkdtemp( path.join( os.tmpdir(), 'vip-dev-env-' ) ); @@ -39,7 +35,6 @@ describe( 'vip dev-env shell', () => { } ); afterAll( () => rm( tmpPath, { recursive: true, force: true } ) ); - afterAll( () => nock.restore() ); describe( 'if the environment does not exist', () => { it( 'should fail', async () => { diff --git a/__tests__/lib/analytics/clients/tracks.js b/__tests__/lib/analytics/clients/tracks.js index 533957b42..6f0da1a34 100644 --- a/__tests__/lib/analytics/clients/tracks.js +++ b/__tests__/lib/analytics/clients/tracks.js @@ -1,14 +1,14 @@ -import nock from 'nock'; - import Tracks from '../../../../src/lib/analytics/clients/tracks'; import * as apiConfig from '../../../../src/lib/cli/apiConfig'; +import { getUndiciMockPool, resetUndiciMockAgent } from '../../../../test-utils/undici-mock'; describe( 'lib/analytics/tracks', () => { const url = new URL( Tracks.ENDPOINT ); + const pool = getUndiciMockPool( url.origin ); - const buildNock = () => nock( url.origin ).post( url.pathname ); + const buildMockRequest = () => pool.intercept( { method: 'POST', path: url.pathname } ); - afterEach( nock.cleanAll ); + afterEach( resetUndiciMockAgent ); describe( '.send()', () => { it( 'should correctly construct remote request', () => { @@ -24,13 +24,22 @@ describe( 'lib/analytics/tracks', () => { '&commonProps%5B_via_ua%5D=vip-cli' + '&extra=param'; - buildNock() - // No arrow function because we need `this` - .reply( 200, function ( uri, requestBody ) { - expect( this.req.headers[ 'user-agent' ] ).toEqual( 'vip-cli' ); // The header value is returned as a string - - expect( requestBody ).toEqual( expectedBody ); - } ); + buildMockRequest().reply( options => { + const headers = Object.fromEntries( + Object.entries( options.headers ?? {} ).map( ( [ key, value ] ) => [ + key.toLowerCase(), + String( value ), + ] ) + ); + + expect( headers[ 'user-agent' ] ).toEqual( 'vip-cli' ); + expect( String( options.body ) ).toEqual( expectedBody ); + + return { + statusCode: 200, + data: 'ok', + }; + } ); return tracksClient.send( params ); } ); @@ -53,8 +62,13 @@ describe( 'lib/analytics/tracks', () => { '&events%5B0%5D%5BbuttonName%5D=deploy' + '&events%5B0%5D%5Bis_vip%5D=true'; - buildNock().reply( 200, ( uri, requestBody ) => { - expect( requestBody ).toContain( expectedBodyMatch ); + buildMockRequest().reply( options => { + expect( String( options.body ) ).toContain( expectedBodyMatch ); + + return { + statusCode: 200, + data: 'ok', + }; } ); return tracksClient.trackEvent( eventName, eventDetails ); @@ -67,8 +81,13 @@ describe( 'lib/analytics/tracks', () => { const expectedBodyMatch = 'events%5B0%5D%5B_en%5D=existingprefix_clickButton'; - buildNock().reply( 200, ( uri, requestBody ) => { - expect( requestBody ).toContain( expectedBodyMatch ); + buildMockRequest().reply( options => { + expect( String( options.body ) ).toContain( expectedBodyMatch ); + + return { + statusCode: 200, + data: 'ok', + }; } ); return tracksClient.trackEvent( eventName, {} ); @@ -79,7 +98,7 @@ describe( 'lib/analytics/tracks', () => { const eventName = 'existingprefix_clickButton'; - buildNock().replyWithError( 'Connection reset' ); + buildMockRequest().replyWithError( 'Connection reset' ); // We expect that the promise resolves to false instead of rejecting and throwing errors with async/await await expect( tracksClient.trackEvent( eventName, {} ) ).resolves.toBe( false ); diff --git a/__tests__/lib/api-retry.ts b/__tests__/lib/api-retry.ts index 094da83b2..562f3ae95 100644 --- a/__tests__/lib/api-retry.ts +++ b/__tests__/lib/api-retry.ts @@ -2,7 +2,6 @@ import { ApolloClient, ApolloLink, ServerError } from '@apollo/client/core'; import { beforeEach, describe, expect, it, jest } from '@jest/globals'; import { OperationTypeNode } from 'graphql'; import gql from 'graphql-tag'; -import { FetchError } from 'node-fetch'; import { shouldRetryRequest } from '../../src/lib/api'; @@ -88,7 +87,7 @@ describe( 'API Retry Logic', () => { it( 'should retry on ECONNREFUSED errors', () => { const error = Object.assign( new Error( 'Connection refused' ), { code: 'ECONNREFUSED', - } ) as FetchError; + } ); const result = shouldRetryRequest( 1, mockOperation, error ); @@ -153,7 +152,7 @@ describe( 'API Retry Logic', () => { it( 'should handle FetchError without ECONNREFUSED code', () => { const error = Object.assign( new Error( 'Network error' ), { code: 'ENOTFOUND', - } ) as FetchError; + } ); const result = shouldRetryRequest( 1, mockOperation, error ); diff --git a/__tests__/lib/dev-environment/dev-environment-cli.js b/__tests__/lib/dev-environment/dev-environment-cli.js index 0e28e12af..2340e7384 100644 --- a/__tests__/lib/dev-environment/dev-environment-cli.js +++ b/__tests__/lib/dev-environment/dev-environment-cli.js @@ -2,7 +2,6 @@ import chalk from 'chalk'; import { prompt, selectRunMock, confirmRunMock } from 'enquirer'; -import nock from 'nock'; import os from 'os'; import { DEV_ENVIRONMENT_PHP_VERSIONS } from '../../../src/lib/constants/dev-environment'; @@ -47,25 +46,24 @@ jest.mock( 'enquirer', () => { const testReleaseWP = '5.9'; -const scope = nock( 'https://raw.githubusercontent.com' ) - .get( '/Automattic/vip-container-images/master/wordpress/versions.json' ) - .reply( 200, [ - { - ref: '3ae9f9ffe311e546b0fd5f82d456b3539e3b8e74', - tag: '5.9.1', - cacheable: true, - locked: false, - prerelease: true, - }, - { - ref: '5.9', - tag: '5.9', - cacheable: true, - locked: false, - prerelease: false, - }, - ] ); -scope.persist( true ); +const mockedWordPressVersions = [ + { + ref: '3ae9f9ffe311e546b0fd5f82d456b3539e3b8e74', + tag: '5.9.1', + cacheable: true, + locked: false, + prerelease: true, + }, + { + ref: '5.9', + tag: '5.9', + cacheable: true, + locked: false, + prerelease: false, + }, +]; + +const getVersionListMock = jest.spyOn( devEnvCore, 'getVersionList' ); jest.mock( '../../../src/lib/constants/dev-environment', () => { const devEnvironmentConstants = jest.requireActual( @@ -86,6 +84,7 @@ describe( 'lib/dev-environment/dev-environment-cli', () => { beforeEach( () => { prompt.mockReset(); confirmRunMock.mockReset(); + getVersionListMock.mockResolvedValue( mockedWordPressVersions ); } ); describe( 'getEnvironmentName with no environments present', () => { beforeEach( () => { diff --git a/__tests__/lib/search-and-replace.js b/__tests__/lib/search-and-replace.js index 9212e6e85..b2c54c334 100644 --- a/__tests__/lib/search-and-replace.js +++ b/__tests__/lib/search-and-replace.js @@ -4,7 +4,6 @@ import searchReplaceLib from '@automattic/vip-search-replace'; import fs from 'fs'; -import fetch, { Response } from 'node-fetch'; import path from 'path'; import * as prompt from '../../src/lib/cli/prompt'; @@ -17,9 +16,6 @@ global.console = { log: jest.fn(), error: jest.fn() }; const fixtureDir = path.resolve( __dirname, '..', '..', '__fixtures__' ); const testFilePath = path.resolve( fixtureDir, 'client-file-uploader', 'tinyfile.txt' ); -jest.mock( 'node-fetch' ); -fetch.mockReturnValue( Promise.resolve( new Response( 'ok' ) ) ); - let searchReplaceBinaryFilename = `go-search-replace-test-${ process.platform }-${ process.arch }`; if ( 'win32' === process.platform ) { searchReplaceBinaryFilename += '.exe'; diff --git a/__tests__/lib/validations/is-multisite-domain-mapped.js b/__tests__/lib/validations/is-multisite-domain-mapped.js index a84078402..ac91d334c 100644 --- a/__tests__/lib/validations/is-multisite-domain-mapped.js +++ b/__tests__/lib/validations/is-multisite-domain-mapped.js @@ -2,8 +2,6 @@ * @format */ -import nock from 'nock'; - import { API_URL } from '../../../src/lib/api'; import { getPrimaryDomainFromSQL, @@ -11,6 +9,7 @@ import { getPrimaryDomain, isMultisitePrimaryDomainMapped, } from '../../../src/lib/validations/is-multisite-domain-mapped'; +import { getUndiciMockPool, resetUndiciMockAgent } from '../../../test-utils/undici-mock'; describe( 'is-multisite-domain-mapped', () => { const capturedStatement = [ @@ -80,29 +79,28 @@ describe( 'is-multisite-domain-mapped', () => { describe( 'isMultisitePrimaryDomainMapped', () => { beforeEach( () => { const url = new URL( API_URL ); - - nock( url.origin ) - .post( url.pathname ) - .reply( 200, { - data: { - app: { - environments: [ - { - domains: { - nodes: [ - { - name: 'www.example.com', - }, - ], - }, + const pool = getUndiciMockPool( url.origin ); + + pool.intercept( { method: 'POST', path: url.pathname } ).reply( 200, { + data: { + app: { + environments: [ + { + domains: { + nodes: [ + { + name: 'www.example.com', + }, + ], }, - ], - }, + }, + ], }, - } ); + }, + } ); } ); - afterEach( nock.cleanAll ); + afterEach( resetUndiciMockAgent ); it( 'should return true if the domain is mapped to the environment', async () => { const isMapped = await isMultisitePrimaryDomainMapped( 1, 1, 'www.example.com' ); diff --git a/__tests__/lib/validations/sql.js b/__tests__/lib/validations/sql.js index 093bcecc3..6e8dd8ba9 100644 --- a/__tests__/lib/validations/sql.js +++ b/__tests__/lib/validations/sql.js @@ -3,7 +3,6 @@ */ import debugLib from 'debug'; -import fetch, { Response } from 'node-fetch'; import path from 'path'; import { validate } from '../../../src/lib/validations/sql'; @@ -21,9 +20,6 @@ jest.spyOn( global.console, 'log' ); const mockExit = jest.spyOn( process, 'exit' ).mockImplementation( () => {} ); const ERROR_CODE = 1; -jest.mock( 'node-fetch' ); -fetch.mockReturnValue( Promise.resolve( new Response( 'ok' ) ) ); - describe( 'lib/validations/sql', () => { describe( 'it fails when the SQL has (using bad-sql-dump.sql)', () => { beforeAll( async () => { diff --git a/jest.setup.js b/jest.setup.js index 098997319..75415be35 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -1,6 +1,24 @@ -import nock from 'nock'; +import { MockAgent, setGlobalDispatcher } from 'undici'; process.env.API_HOST = 'http://localhost:4000'; -// Don't let tests talk to the outside world -nock.disableNetConnect(); +delete process.env.VIP_PROXY; +delete process.env.vip_proxy; +delete process.env.SOCKS_PROXY; +delete process.env.socks_proxy; +delete process.env.HTTPS_PROXY; +delete process.env.https_proxy; +delete process.env.HTTP_PROXY; +delete process.env.http_proxy; +delete process.env.ALL_PROXY; +delete process.env.all_proxy; +delete process.env.NO_PROXY; +delete process.env.no_proxy; +delete process.env.VIP_USE_SYSTEM_PROXY; + +// Don't let tests talk to the outside world for undici-backed fetch. +const mockAgent = new MockAgent(); +mockAgent.disableNetConnect(); +setGlobalDispatcher( mockAgent ); + +global.__UNDICI_MOCK_AGENT__ = mockAgent; diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 9fc464e2d..f19c8a713 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -24,14 +24,12 @@ "ejs": "^5.0.1", "enquirer": "2.4.1", "esbuild": "^0.28.0", - "fetch-retry": "^6.0.0", "graphql": "16.14.0", "graphql-tag": "2.12.6", "https-proxy-agent": "^9.0.0", "js-yaml": "^4.1.0", "jwt-decode": "4.0.0", "lando": "github:automattic/lando-cli#78d382fcf40357c4a4cebe4a3cfaef032eab743a", - "node-fetch": "^3.3.2", "node-stream-zip": "1.15.0", "open": "^11.0.0", "proxy-from-env": "^2.0.0", @@ -42,6 +40,7 @@ "socks-proxy-agent": "^10.0.0", "ssh2": "1.17.0", "tar": "^7.4.0", + "undici": "^8.2.0", "update-notifier": "7.3.1", "xml2js": "^0.6.2" }, @@ -131,7 +130,6 @@ "@types/xml2js": "^0.4.14", "dockerode": "^5.0.0", "jest": "^30.0.0", - "nock": "13.5.6", "postject": "^1.0.0-alpha.6", "prettier": "npm:wp-prettier@3.0.3", "typescript": "^6.0.2" @@ -6637,15 +6635,6 @@ "dev": true, "license": "BSD-2-Clause" }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -8366,35 +8355,6 @@ "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", "license": "MIT" }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, - "node_modules/fetch-retry": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/fetch-retry/-/fetch-retry-6.0.0.tgz", - "integrity": "sha512-BUFj1aMubgib37I3v4q78fYo63Po7t4HUPTpQ6/QE6yK6cIQrP+W43FYToeTEyg5m2Y7eFUtijUuAv/PDlWuag==", - "license": "MIT" - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -8557,18 +8517,6 @@ "node": ">= 6" } }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -11516,13 +11464,6 @@ "license": "MIT", "peer": true }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true, - "license": "ISC" - }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -12165,21 +12106,6 @@ "dev": true, "license": "MIT" }, - "node_modules/nock": { - "version": "13.5.6", - "resolved": "https://registry.npmjs.org/nock/-/nock-13.5.6.tgz", - "integrity": "sha512-o2zOYiCpzRqSzPj0Zt/dQ/DqZeYoaQ7TUonc/xUPjCGl9WeHpNbxgVvOquXYAaJzI0M9BXV3HTzG0p8IUAbBTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.0", - "json-stringify-safe": "^5.0.1", - "propagate": "^2.0.0" - }, - "engines": { - "node": ">= 10.13" - } - }, "node_modules/node-abi": { "version": "3.89.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", @@ -12212,26 +12138,6 @@ "node": ">= 8.0.0" } }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -12261,24 +12167,6 @@ "semver": "bin/semver.js" } }, - "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -13005,16 +12893,6 @@ "dev": true, "license": "MIT" }, - "node_modules/propagate": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", - "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", @@ -14805,6 +14683,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.2.0.tgz", + "integrity": "sha512-Z+4Hx9GE26Lh9Upwfnc8C7SsrpBPGaM/Gm6kMFtiG7c+5IvQKlXi/t+9x9DrrCh29cww5TSP9YdVaBcnLDs5fQ==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -15083,15 +14970,6 @@ "makeerror": "1.0.12" } }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/when-exit": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", diff --git a/package.json b/package.json index d63697b43..061b17d28 100644 --- a/package.json +++ b/package.json @@ -134,7 +134,6 @@ "@types/xml2js": "^0.4.14", "dockerode": "^5.0.0", "jest": "^30.0.0", - "nock": "13.5.6", "postject": "^1.0.0-alpha.6", "prettier": "npm:wp-prettier@3.0.3", "typescript": "^6.0.2" @@ -154,14 +153,12 @@ "ejs": "^5.0.1", "enquirer": "2.4.1", "esbuild": "^0.28.0", - "fetch-retry": "^6.0.0", "graphql": "16.14.0", "graphql-tag": "2.12.6", "https-proxy-agent": "^9.0.0", "js-yaml": "^4.1.0", "jwt-decode": "4.0.0", "lando": "github:automattic/lando-cli#78d382fcf40357c4a4cebe4a3cfaef032eab743a", - "node-fetch": "^3.3.2", "node-stream-zip": "1.15.0", "open": "^11.0.0", "proxy-from-env": "^2.0.0", @@ -172,6 +169,7 @@ "socks-proxy-agent": "^10.0.0", "ssh2": "1.17.0", "tar": "^7.4.0", + "undici": "^8.2.0", "update-notifier": "7.3.1", "xml2js": "^0.6.2" }, diff --git a/src/lib/analytics/clients/client.ts b/src/lib/analytics/clients/client.ts index 818627eee..1c9108c69 100644 --- a/src/lib/analytics/clients/client.ts +++ b/src/lib/analytics/clients/client.ts @@ -1,5 +1,3 @@ -import type { Response } from 'node-fetch'; - export interface AnalyticsClient { trackEvent( name: string, props?: Record< string, unknown > ): Promise< Response | false >; } diff --git a/src/lib/analytics/clients/pendo.ts b/src/lib/analytics/clients/pendo.ts index abca7c709..1e2ac8102 100644 --- a/src/lib/analytics/clients/pendo.ts +++ b/src/lib/analytics/clients/pendo.ts @@ -1,5 +1,4 @@ import debugLib from 'debug'; -import { Response } from 'node-fetch'; import http from '../../../lib/api/http'; import { type Env } from '../../env'; diff --git a/src/lib/analytics/clients/tracks.ts b/src/lib/analytics/clients/tracks.ts index 804669a9d..47cf1772d 100644 --- a/src/lib/analytics/clients/tracks.ts +++ b/src/lib/analytics/clients/tracks.ts @@ -1,6 +1,6 @@ import debugLib from 'debug'; -import fetch, { type Response } from 'node-fetch'; import querystring from 'querystring'; +import { fetch, type Response } from 'undici'; import { checkIfUserIsVip } from '../../cli/apiConfig'; diff --git a/src/lib/analytics/index.ts b/src/lib/analytics/index.ts index 18cdef405..0686db0aa 100644 --- a/src/lib/analytics/index.ts +++ b/src/lib/analytics/index.ts @@ -3,7 +3,6 @@ import debugLib from 'debug'; import env from '../env'; import type { AnalyticsClient } from './clients/client'; -import type { Response } from 'node-fetch'; const debug = debugLib( '@automattic/vip:analytics' ); diff --git a/src/lib/api.ts b/src/lib/api.ts index 3ce4d1636..a5e41e9c6 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -11,7 +11,6 @@ import { RetryLink } from '@apollo/client/link/retry'; import chalk from 'chalk'; import debugLib from 'debug'; import { Kind, OperationTypeNode } from 'graphql'; -import { FetchError } from 'node-fetch'; import http from './api/http'; @@ -61,7 +60,7 @@ export function shouldRetryRequest( return false; } - if ( error instanceof FetchError && error.code === 'ECONNREFUSED' ) { + if ( ( error as { code?: string } )?.code === 'ECONNREFUSED' ) { debug( `Request failed. Retrying request due to connection refused error. ${ debugSuffix }` ); return true; diff --git a/src/lib/api/http.ts b/src/lib/api/http.ts index e7dda33eb..d1aff0462 100644 --- a/src/lib/api/http.ts +++ b/src/lib/api/http.ts @@ -1,14 +1,9 @@ import debugLib from 'debug'; -import fetch, { - type BodyInit, - type Response, - type RequestInit, - type HeadersInit, -} from 'node-fetch'; +import { fetch, Headers, type BodyInit, type HeadersInit, type RequestInit } from 'undici'; import { API_HOST } from '../../lib/api'; import env from '../../lib/env'; -import { createProxyAgent } from '../../lib/http/proxy-agent'; +import { createProxyDispatcher } from '../../lib/http/proxy-dispatcher'; import Token from '../../lib/token'; const debug = debugLib( '@automattic/vip:http' ); @@ -22,8 +17,6 @@ export type FetchOptions = Omit< RequestInit, 'body' > & { * Call the Public API with an arbitrary path (e.g. to connect to REST endpoints). * This will include the token in an Authorization header so requests are "logged-in." * - * This is simply a wrapper around node-fetch - * * @param {string} path API path to pass to `fetch` -- will be prefixed by the API_HOST * @param {Object} options options to pass to `fetch` * @return {Promise} Return value of the `fetch` call @@ -38,8 +31,7 @@ export default async ( path: string, options: FetchOptions = {} ): Promise< Resp } const authToken = await Token.get(); - - const proxyAgent = createProxyAgent( url ); + const proxyDispatcher = createProxyDispatcher( url ); debug( 'running fetch', url ); @@ -56,12 +48,10 @@ export default async ( path: string, options: FetchOptions = {} ): Promise< Resp headers.set( 'Content-Type', 'application/json' ); } - const opts = { + return fetch( url, { ...options, - agent: proxyAgent ?? undefined, + dispatcher: proxyDispatcher ?? undefined, headers, body: typeof options.body === 'object' ? JSON.stringify( options.body ) : options.body, - }; - - return fetch( url, opts ); + } ); }; diff --git a/src/lib/client-file-uploader.ts b/src/lib/client-file-uploader.ts index 390bc67ae..e5ec6105e 100644 --- a/src/lib/client-file-uploader.ts +++ b/src/lib/client-file-uploader.ts @@ -2,29 +2,38 @@ import chalk from 'chalk'; import { createHash } from 'crypto'; import debugLib from 'debug'; import { constants, createReadStream, createWriteStream, type ReadStream } from 'fs'; -import fetch, { HeadersInit, RequestInfo, RequestInit, Response } from 'node-fetch'; import { access, mkdtemp, open, stat } from 'node:fs/promises'; import { pipeline } from 'node:stream/promises'; +import { setTimeout } from 'node:timers/promises'; import os from 'os'; import path from 'path'; import { PassThrough } from 'stream'; +import { fetch, type HeadersInit, type RequestInit, type Response } from 'undici'; import { Parser as XmlParser } from 'xml2js'; import { createGunzip, createGzip, Gunzip, ZlibOptions } from 'zlib'; import http, { type FetchOptions } from '../lib/api/http'; import { MB_IN_BYTES } from '../lib/constants/file-size'; -// Need to use CommonJS imports here as the `fetch-retry` typedefs are messed up and throwing TypeJS errors when using `import` -// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -const fetchWithRetry: ( input: RequestInfo | URL, init?: RequestInit ) => Promise< Response > = - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-require-imports - require( 'fetch-retry' )( fetch, { - // Set default retry options - retries: 3, - retryDelay: ( attempt: number ) => { - return Math.pow( 2, attempt ) * 1000; // 1000, 2000, 4000 - }, - } ); +async function fetchWithRetry( + input: string | URL, + init?: RequestInit, + retries = 3 +): Promise< Response > { + for ( let attempt = 0; attempt <= retries; attempt++ ) { + try { + // eslint-disable-next-line no-await-in-loop + return await fetch( input, init ); + } catch ( err ) { + if ( attempt === retries ) { + throw err; + } + // eslint-disable-next-line no-await-in-loop + await setTimeout( Math.pow( 2, attempt ) * 1000 ); // 1000, 2000, 4000 + } + } + throw new Error( 'unreachable' ); +} const debug = debugLib( 'vip:lib/client-file-uploader' ); @@ -641,8 +650,12 @@ async function uploadPart( { const fetchResponse = await fetchWithRetry( partUploadRequestData.url, fetchOptions ); if ( fetchResponse.status === 200 ) { - const responseHeaders = fetchResponse.headers.raw(); - const [ etag ] = responseHeaders.etag; + const etag = fetchResponse.headers.get( 'etag' ); + + if ( ! etag ) { + throw new Error( 'Unable to upload file part. Missing ETag response header.' ); + } + return JSON.parse( etag ) as string; } diff --git a/src/lib/dev-environment/dev-environment-core.ts b/src/lib/dev-environment/dev-environment-core.ts index 25d46564e..577a49595 100644 --- a/src/lib/dev-environment/dev-environment-core.ts +++ b/src/lib/dev-environment/dev-environment-core.ts @@ -3,12 +3,12 @@ import debugLib from 'debug'; import ejs from 'ejs'; import { prompt } from 'enquirer'; import { print } from 'graphql'; -import fetch from 'node-fetch'; import { randomUUID } from 'node:crypto'; import fs from 'node:fs'; import { cp, readdir } from 'node:fs/promises'; import path from 'node:path'; import semver from 'semver'; +import { fetch } from 'undici'; import { handleCLIException, @@ -44,7 +44,7 @@ import { DEV_ENVIRONMENT_PHP_VERSIONS, DEV_ENVIRONMENT_VERSION, } from '../constants/dev-environment'; -import { createProxyAgent } from '../http/proxy-agent'; +import { createProxyDispatcher } from '../http/proxy-dispatcher'; import { searchAndReplace } from '../search-and-replace'; import UserError from '../user-error'; import { xdgData } from '../xdg-data'; @@ -1041,8 +1041,8 @@ async function maybeUpdateVersion( lando: Lando, slug: string ): Promise< boolea */ export function fetchVersionList(): Promise< WordPressTag[] > { const url = `https://${ DEV_ENVIRONMENT_RAW_GITHUB_HOST }${ DEV_ENVIRONMENT_WORDPRESS_VERSIONS_URI }`; - const proxyAgent = createProxyAgent( url ); - return fetch( url, { agent: proxyAgent ?? undefined } ).then( + const proxyDispatcher = createProxyDispatcher( url ); + return fetch( url, { dispatcher: proxyDispatcher ?? undefined } ).then( res => res.json() as Promise< WordPressTag[] > ); } diff --git a/src/lib/http/proxy-dispatcher.ts b/src/lib/http/proxy-dispatcher.ts new file mode 100644 index 000000000..cad7a55f8 --- /dev/null +++ b/src/lib/http/proxy-dispatcher.ts @@ -0,0 +1,60 @@ +import debugLib from 'debug'; +import { getProxyForUrl } from 'proxy-from-env'; +import { ProxyAgent, type Dispatcher } from 'undici'; + +const debug = debugLib( 'vip:proxy-dispatcher' ); + +const proxyDispatchers = new Map< string, Dispatcher >(); + +function isCoveredByNoProxy( url: string ): boolean { + const NO_PROXY = process.env.NO_PROXY || process.env.no_proxy || null; // NOSONAR + + if ( ! NO_PROXY ) { + return false; + } + + return getProxyForUrl( url ) === ''; +} + +function resolveProxyUrl( url: string ): string | null { + const VIP_PROXY = process.env.VIP_PROXY || process.env.vip_proxy || null; // NOSONAR + const SOCKS_PROXY = process.env.SOCKS_PROXY || process.env.socks_proxy || null; // NOSONAR + const HTTPS_PROXY = process.env.HTTPS_PROXY || process.env.https_proxy || null; // NOSONAR + + if ( VIP_PROXY ) { + return VIP_PROXY; + } + + if ( process.env.VIP_USE_SYSTEM_PROXY && ! isCoveredByNoProxy( url ) ) { + if ( SOCKS_PROXY ) { + return SOCKS_PROXY; + } + + if ( HTTPS_PROXY ) { + return HTTPS_PROXY; + } + } + + return null; +} + +/** + * Build an undici dispatcher from the existing proxy resolution logic. + * + * This keeps env precedence in one place while allowing fetch callers to + * provide `dispatcher` instead of `agent`. + */ +export function createProxyDispatcher( url: string ): Dispatcher | null { + const proxyUrl = resolveProxyUrl( url ); + + if ( ! proxyUrl ) { + return null; + } + + if ( ! proxyDispatchers.has( proxyUrl ) ) { + debug( `Enabling fetch dispatcher proxy support using config: ${ proxyUrl }` ); + proxyDispatchers.set( proxyUrl, new ProxyAgent( proxyUrl ) ); + } + + return proxyDispatchers.get( proxyUrl ) ?? null; +} diff --git a/src/lib/retry.ts b/src/lib/retry.ts index a9ba3bcc0..3ffb2e0ef 100644 --- a/src/lib/retry.ts +++ b/src/lib/retry.ts @@ -1,4 +1,5 @@ // copied over from our internal lib +import { setTimeout } from 'node:timers/promises'; export const EXPONENTIAL_BACKOFF_STARTING_IN_50_MS = exponentialBackoff( 50 ); export const EXPONENTIAL_BACKOFF_STARTING_IN_100_MS = exponentialBackoff( 100 ); @@ -148,7 +149,7 @@ function isValidInterval( interval: Interval ): boolean { ); } -async function awaitInterval( interval: Interval, attemptNumber: number ): Promise< void > { +function awaitInterval( interval: Interval, attemptNumber: number ): Promise< void > { let newInterval: number; if ( typeof interval === 'function' ) { @@ -163,7 +164,7 @@ async function awaitInterval( interval: Interval, attemptNumber: number ): Promi newInterval = interval; } - return new Promise( resolve => setTimeout( resolve, newInterval ) ); + return setTimeout( newInterval ); } function validateTask< T >( task: T ): asserts task is NonNullable< T > { diff --git a/src/lib/tracker.ts b/src/lib/tracker.ts index 500bfe0dc..0e661c111 100644 --- a/src/lib/tracker.ts +++ b/src/lib/tracker.ts @@ -8,7 +8,6 @@ import config from '../lib/cli/config'; import Token from '../lib/token'; import type { AnalyticsClient } from './analytics/clients/client'; -import type { Response } from 'node-fetch'; const debug = debugLib( '@automattic/vip:analytics' ); diff --git a/test-utils/undici-mock.js b/test-utils/undici-mock.js new file mode 100644 index 000000000..00b504b03 --- /dev/null +++ b/test-utils/undici-mock.js @@ -0,0 +1,25 @@ +import { getGlobalDispatcher } from 'undici'; + +const asMockAgent = () => { + const dispatcher = getGlobalDispatcher(); + + if ( typeof dispatcher.get !== 'function' ) { + throw new Error( 'Expected global dispatcher to be an undici MockAgent.' ); + } + + return dispatcher; +}; + +export const getUndiciMockPool = origin => asMockAgent().get( origin ); + +export const resetUndiciMockAgent = ( { assertNoPending = true } = {} ) => { + const mockAgent = asMockAgent(); + + if ( assertNoPending && typeof mockAgent.assertNoPendingInterceptors === 'function' ) { + mockAgent.assertNoPendingInterceptors(); + } + + if ( typeof mockAgent.clearIntercepts === 'function' ) { + mockAgent.clearIntercepts(); + } +}; From 19424da0e72dd8833fdac790973a3c0f8ea0153c Mon Sep 17 00:00:00 2001 From: Volodymyr Kolesnykov Date: Tue, 12 May 2026 03:27:20 +0300 Subject: [PATCH 2/4] fix: suppress ExperimentalWarning --- src/lib/http/proxy-dispatcher.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/lib/http/proxy-dispatcher.ts b/src/lib/http/proxy-dispatcher.ts index cad7a55f8..5b00902ec 100644 --- a/src/lib/http/proxy-dispatcher.ts +++ b/src/lib/http/proxy-dispatcher.ts @@ -38,6 +38,17 @@ function resolveProxyUrl( url: string ): string | null { return null; } +let listeners: ReturnType< typeof process.rawListeners< 'warning' > > | null = null; + +// Suppress ExperimentalWarning: SOCKS5 proxy support is experimental and subject to change +function suppressExperimentalSocksWarning( warning: Error ): void { + listeners?.forEach( listener => process.on( 'warning', listener as NodeJS.WarningListener ) ); + + if ( warning.name !== 'ExperimentalWarning' || ! /Socks5ProxyAgent/u.test( warning.message ) ) { + process.emitWarning( warning ); + } +} + /** * Build an undici dispatcher from the existing proxy resolution logic. * @@ -56,5 +67,11 @@ export function createProxyDispatcher( url: string ): Dispatcher | null { proxyDispatchers.set( proxyUrl, new ProxyAgent( proxyUrl ) ); } + if ( listeners === null ) { + listeners = process.rawListeners( 'warning' ); + process.removeAllListeners( 'warning' ); + process.once( 'warning', suppressExperimentalSocksWarning ); + } + return proxyDispatchers.get( proxyUrl ) ?? null; } From 62f1eae693f56634f86f7607817725c04272c2c9 Mon Sep 17 00:00:00 2001 From: Volodymyr Kolesnykov Date: Tue, 12 May 2026 03:01:50 +0300 Subject: [PATCH 3/4] fix: address code review comments --- __tests__/lib/client-file-uploader.js | 21 ++- __tests__/lib/http/proxy-dispatcher.js | 204 +++++++++++++++++++++++++ jest.setup.js | 3 +- npm-shrinkwrap.json | 10 +- package.json | 2 +- src/lib/analytics/clients/client.ts | 2 + src/lib/analytics/clients/pendo.ts | 1 + src/lib/analytics/index.ts | 1 + src/lib/api/http.ts | 29 +++- src/lib/client-file-uploader.ts | 18 ++- src/lib/http/download-file.ts | 6 +- src/lib/http/proxy-dispatcher.ts | 48 +++++- src/lib/media-import/status.ts | 7 +- src/lib/tracker.ts | 1 + 14 files changed, 334 insertions(+), 19 deletions(-) create mode 100644 __tests__/lib/http/proxy-dispatcher.js diff --git a/__tests__/lib/client-file-uploader.js b/__tests__/lib/client-file-uploader.js index 3ebe55d3f..e185573ec 100644 --- a/__tests__/lib/client-file-uploader.js +++ b/__tests__/lib/client-file-uploader.js @@ -2,7 +2,12 @@ * @format */ -import { getFileHash, getFileMeta, getPartBoundaries } from '../../src/lib/client-file-uploader'; +import { + getFileHash, + getFileMeta, + getPartBoundaries, + parseEtagHeader, +} from '../../src/lib/client-file-uploader'; describe( 'client-file-uploader', () => { describe( 'getFileMeta()', () => { @@ -90,4 +95,18 @@ describe( 'client-file-uploader', () => { ] ); } ); } ); + + describe( 'parseEtagHeader()', () => { + it( 'should parse a quoted ETag header', () => { + expect( parseEtagHeader( '"abc123"' ) ).toBe( 'abc123' ); + } ); + + it( 'should strip a weak ETag prefix', () => { + expect( parseEtagHeader( 'W/"abc123"' ) ).toBe( 'abc123' ); + } ); + + it( 'should return an unquoted ETag value as-is', () => { + expect( parseEtagHeader( 'abc123' ) ).toBe( 'abc123' ); + } ); + } ); } ); diff --git a/__tests__/lib/http/proxy-dispatcher.js b/__tests__/lib/http/proxy-dispatcher.js new file mode 100644 index 000000000..cb6b8d8a2 --- /dev/null +++ b/__tests__/lib/http/proxy-dispatcher.js @@ -0,0 +1,204 @@ +import { ProxyAgent } from 'undici'; + +import { createProxyDispatcher } from '../../../src/lib/http/proxy-dispatcher'; + +describe( 'createProxyDispatcher', () => { + let savedEnv; + + beforeAll( () => { + savedEnv = { ...process.env }; + } ); + + beforeEach( () => { + // Clear all applicable environment variables before each test so each + // test starts "clean" even if the runner has proxies set in the shell. + const envVarsToClear = [ + 'VIP_PROXY', + 'vip_proxy', + 'HTTPS_PROXY', + 'https_proxy', + 'SOCKS_PROXY', + 'socks_proxy', + 'NO_PROXY', + 'no_proxy', + 'VIP_USE_SYSTEM_PROXY', + ]; + for ( const envVar of envVarsToClear ) { + delete process.env[ envVar ]; + } + } ); + + afterAll( () => { + process.env = { ...savedEnv }; + } ); + + // Tests that expect null (no proxy) + it.each( [ + { + label: 'no proxies set', + env: { + VIP_USE_SYSTEM_PROXY: '', + VIP_PROXY: '', + HTTPS_PROXY: '', + SOCKS_PROXY: '', + NO_PROXY: '', + }, + url: 'https://wpapi.org/api', + }, + { + label: 'HTTPS_PROXY set but VIP_USE_SYSTEM_PROXY not set', + env: { + VIP_USE_SYSTEM_PROXY: '', + VIP_PROXY: '', + HTTPS_PROXY: 'https://myproxy.com', + SOCKS_PROXY: '', + NO_PROXY: '', + }, + url: 'https://wpapi.org/api', + }, + { + label: 'SOCKS_PROXY set but VIP_USE_SYSTEM_PROXY not set', + env: { + VIP_USE_SYSTEM_PROXY: '', + VIP_PROXY: '', + HTTPS_PROXY: '', + SOCKS_PROXY: 'socks5://myproxy.com:4022', + NO_PROXY: '', + }, + url: 'https://wpapi.org/api', + }, + { + label: 'VIP_USE_SYSTEM_PROXY set but NO_PROXY covers all hosts', + env: { + VIP_USE_SYSTEM_PROXY: '1', + VIP_PROXY: '', + HTTPS_PROXY: 'https://myproxy.com', + SOCKS_PROXY: '', + NO_PROXY: '*', + }, + url: 'https://wpapi.org/api', + }, + { + label: 'VIP_USE_SYSTEM_PROXY set but NO_PROXY covers the target host', + env: { + VIP_USE_SYSTEM_PROXY: '1', + VIP_PROXY: '', + HTTPS_PROXY: '', + SOCKS_PROXY: 'socks5://myproxy.com:4022', + NO_PROXY: 'wpapi.org', + }, + url: 'https://wpapi.org/api', + }, + { + label: 'only NO_PROXY is set', + env: { + VIP_USE_SYSTEM_PROXY: '1', + VIP_PROXY: '', + HTTPS_PROXY: '', + SOCKS_PROXY: '', + NO_PROXY: 'wpapi.org,.lndo.site,foo.bar.org', + }, + url: 'https://wpapi.org/api', + }, + ] )( 'should return null when $label', ( { env, url } ) => { + for ( const [ key, value ] of Object.entries( env ) ) { + process.env[ key ] = value; + } + expect( createProxyDispatcher( url ) ).toBeNull(); + } ); + + // Tests that expect a ProxyAgent + it.each( [ + { + label: 'VIP_PROXY set (no feature flag required)', + env: { + VIP_USE_SYSTEM_PROXY: '', + VIP_PROXY: 'http://myproxy.com:8080', + HTTPS_PROXY: '', + SOCKS_PROXY: '', + NO_PROXY: '', + }, + url: 'https://wpapi.org/api', + }, + { + label: 'VIP_PROXY takes precedence over other proxies', + env: { + VIP_USE_SYSTEM_PROXY: '1', + VIP_PROXY: 'http://myproxy.com:8080', + HTTPS_PROXY: 'https://other.com', + SOCKS_PROXY: '', + NO_PROXY: '*', + }, + url: 'https://wpapi.org/api', + }, + { + label: 'SOCKS_PROXY checked first when VIP_USE_SYSTEM_PROXY is set', + env: { + VIP_USE_SYSTEM_PROXY: '1', + VIP_PROXY: '', + HTTPS_PROXY: 'https://myproxy.com', + SOCKS_PROXY: 'socks5://myproxy.com:4022', + NO_PROXY: '', + }, + url: 'https://wpapi.org/api', + }, + { + label: 'HTTPS_PROXY used when VIP_USE_SYSTEM_PROXY is set and no SOCKS_PROXY', + env: { + VIP_USE_SYSTEM_PROXY: '1', + VIP_PROXY: '', + HTTPS_PROXY: 'https://myproxy.com', + SOCKS_PROXY: '', + NO_PROXY: '', + }, + url: 'https://wpapi.org/api', + }, + { + label: 'proxied when NO_PROXY does not cover the target host', + env: { + VIP_USE_SYSTEM_PROXY: '1', + VIP_PROXY: '', + HTTPS_PROXY: 'https://myproxy.com', + SOCKS_PROXY: '', + NO_PROXY: 'wpapi.org,.lndo.site', + }, + url: 'https://wpapi2.org/api', + }, + { + label: 'SOCKS proxy still used when NO_PROXY does not match host', + env: { + VIP_USE_SYSTEM_PROXY: '1', + VIP_PROXY: '', + HTTPS_PROXY: '', + SOCKS_PROXY: 'socks5://myproxy.com:4022', + NO_PROXY: 'example.com', + }, + url: 'https://wpapi.org/api', + }, + { + label: 'port-specific NO_PROXY does not block different ports', + env: { + VIP_USE_SYSTEM_PROXY: '1', + VIP_PROXY: '', + HTTPS_PROXY: 'https://myproxy.com', + SOCKS_PROXY: '', + NO_PROXY: 'wpapi.org:8443', + }, + url: 'https://wpapi.org/api', + }, + ] )( 'should return a ProxyAgent when $label', ( { env, url } ) => { + for ( const [ key, value ] of Object.entries( env ) ) { + process.env[ key ] = value; + } + const dispatcher = createProxyDispatcher( url ); + expect( dispatcher ).not.toBeNull(); + expect( dispatcher ).toBeInstanceOf( ProxyAgent ); + } ); + + it( 'returns the same ProxyAgent instance for the same proxy URL (caching)', () => { + process.env.VIP_PROXY = 'http://myproxy.com:8080'; + const first = createProxyDispatcher( 'https://wpapi.org/api' ); + const second = createProxyDispatcher( 'https://wpapi2.org/api' ); + expect( first ).toBe( second ); + } ); +} ); diff --git a/jest.setup.js b/jest.setup.js index 75415be35..69b54cac4 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -1,4 +1,4 @@ -import { MockAgent, setGlobalDispatcher } from 'undici'; +import { fetch, MockAgent, setGlobalDispatcher } from 'undici'; process.env.API_HOST = 'http://localhost:4000'; @@ -20,5 +20,6 @@ delete process.env.VIP_USE_SYSTEM_PROXY; const mockAgent = new MockAgent(); mockAgent.disableNetConnect(); setGlobalDispatcher( mockAgent ); +globalThis.fetch = fetch; global.__UNDICI_MOCK_AGENT__ = mockAgent; diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index f19c8a713..adbb946c2 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -40,7 +40,7 @@ "socks-proxy-agent": "^10.0.0", "ssh2": "1.17.0", "tar": "^7.4.0", - "undici": "^8.2.0", + "undici": "^7.0.0", "update-notifier": "7.3.1", "xml2js": "^0.6.2" }, @@ -14684,12 +14684,12 @@ } }, "node_modules/undici": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.2.0.tgz", - "integrity": "sha512-Z+4Hx9GE26Lh9Upwfnc8C7SsrpBPGaM/Gm6kMFtiG7c+5IvQKlXi/t+9x9DrrCh29cww5TSP9YdVaBcnLDs5fQ==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", "license": "MIT", "engines": { - "node": ">=22.19.0" + "node": ">=20.18.1" } }, "node_modules/undici-types": { diff --git a/package.json b/package.json index 061b17d28..9b460a64a 100644 --- a/package.json +++ b/package.json @@ -169,7 +169,7 @@ "socks-proxy-agent": "^10.0.0", "ssh2": "1.17.0", "tar": "^7.4.0", - "undici": "^8.2.0", + "undici": "^7.0.0", "update-notifier": "7.3.1", "xml2js": "^0.6.2" }, diff --git a/src/lib/analytics/clients/client.ts b/src/lib/analytics/clients/client.ts index 1c9108c69..cd4a4c5f6 100644 --- a/src/lib/analytics/clients/client.ts +++ b/src/lib/analytics/clients/client.ts @@ -1,3 +1,5 @@ +import type { Response } from 'undici'; + export interface AnalyticsClient { trackEvent( name: string, props?: Record< string, unknown > ): Promise< Response | false >; } diff --git a/src/lib/analytics/clients/pendo.ts b/src/lib/analytics/clients/pendo.ts index 1e2ac8102..d86d588d0 100644 --- a/src/lib/analytics/clients/pendo.ts +++ b/src/lib/analytics/clients/pendo.ts @@ -1,4 +1,5 @@ import debugLib from 'debug'; +import { type Response } from 'undici'; import http from '../../../lib/api/http'; import { type Env } from '../../env'; diff --git a/src/lib/analytics/index.ts b/src/lib/analytics/index.ts index 0686db0aa..b76e330e1 100644 --- a/src/lib/analytics/index.ts +++ b/src/lib/analytics/index.ts @@ -3,6 +3,7 @@ import debugLib from 'debug'; import env from '../env'; import type { AnalyticsClient } from './clients/client'; +import type { Response } from 'undici'; const debug = debugLib( '@automattic/vip:analytics' ); diff --git a/src/lib/api/http.ts b/src/lib/api/http.ts index d1aff0462..28f46aa74 100644 --- a/src/lib/api/http.ts +++ b/src/lib/api/http.ts @@ -1,5 +1,12 @@ import debugLib from 'debug'; -import { fetch, Headers, type BodyInit, type HeadersInit, type RequestInit } from 'undici'; +import { + fetch, + Headers, + type BodyInit, + type HeadersInit, + type RequestInit, + type Response, +} from 'undici'; import { API_HOST } from '../../lib/api'; import env from '../../lib/env'; @@ -13,6 +20,15 @@ export type FetchOptions = Omit< RequestInit, 'body' > & { headers?: HeadersInit; }; +const isPlainObjectBody = ( value: FetchOptions[ 'body' ] ): value is Record< string, unknown > => { + if ( ! value || typeof value !== 'object' ) { + return false; + } + + const prototype = Object.getPrototypeOf( value ) as unknown; + return prototype === Object.prototype || prototype === null; +}; + /** * Call the Public API with an arbitrary path (e.g. to connect to REST endpoints). * This will include the token in an Authorization header so requests are "logged-in." @@ -32,10 +48,11 @@ export default async ( path: string, options: FetchOptions = {} ): Promise< Resp const authToken = await Token.get(); const proxyDispatcher = createProxyDispatcher( url ); + const shouldSerializeJsonBody = isPlainObjectBody( options.body ); debug( 'running fetch', url ); - const headers = new Headers( { ...options.headers } ); + const headers = new Headers( options.headers ); if ( ! headers.has( 'Authorization' ) ) { headers.set( 'Authorization', `Bearer ${ authToken.raw }` ); } @@ -44,14 +61,18 @@ export default async ( path: string, options: FetchOptions = {} ): Promise< Resp headers.set( 'User-Agent', env.userAgent ); } - if ( ! headers.has( 'Content-Type' ) && options.method !== 'GET' ) { + if ( ! headers.has( 'Content-Type' ) && options.method !== 'GET' && shouldSerializeJsonBody ) { headers.set( 'Content-Type', 'application/json' ); } + const requestBody: BodyInit | undefined = shouldSerializeJsonBody + ? JSON.stringify( options.body ) + : ( options.body as BodyInit | undefined ); + return fetch( url, { ...options, dispatcher: proxyDispatcher ?? undefined, headers, - body: typeof options.body === 'object' ? JSON.stringify( options.body ) : options.body, + body: requestBody, } ); }; diff --git a/src/lib/client-file-uploader.ts b/src/lib/client-file-uploader.ts index e5ec6105e..ca1f87cb5 100644 --- a/src/lib/client-file-uploader.ts +++ b/src/lib/client-file-uploader.ts @@ -15,6 +15,22 @@ import { createGunzip, createGzip, Gunzip, ZlibOptions } from 'zlib'; import http, { type FetchOptions } from '../lib/api/http'; import { MB_IN_BYTES } from '../lib/constants/file-size'; +export function parseEtagHeader( etag: string ): string { + const normalizedEtag = etag.replace( /^W\//u, '' ).trim(); + + if ( normalizedEtag.startsWith( '"' ) && normalizedEtag.endsWith( '"' ) ) { + try { + return JSON.parse( normalizedEtag ) as string; + } catch ( err ) { + debug( + `Unable to JSON.parse ETag header, falling back to raw value: ${ ( err as Error ).message }` + ); + } + } + + return normalizedEtag.replace( /^"(.*)"$/u, '$1' ); +} + async function fetchWithRetry( input: string | URL, init?: RequestInit, @@ -656,7 +672,7 @@ async function uploadPart( { throw new Error( 'Unable to upload file part. Missing ETag response header.' ); } - return JSON.parse( etag ) as string; + return parseEtagHeader( etag ); } const result = await fetchResponse.text(); diff --git a/src/lib/http/download-file.ts b/src/lib/http/download-file.ts index f1a7908bf..a56d7575c 100644 --- a/src/lib/http/download-file.ts +++ b/src/lib/http/download-file.ts @@ -1,6 +1,9 @@ import * as fs from 'fs'; import { Readable } from 'stream'; import { finished } from 'stream/promises'; +import { fetch } from 'undici'; + +import { createProxyDispatcher } from './proxy-dispatcher'; export type OnProgressCallback = ( bytesDownloaded: number, totalBytes: number | null ) => void; @@ -10,8 +13,9 @@ export const downloadFile = async ( onProgress?: OnProgressCallback ): Promise< void > => { let response; + const proxyDispatcher = createProxyDispatcher( fileUrl ); try { - response = await fetch( fileUrl ); + response = await fetch( fileUrl, { dispatcher: proxyDispatcher ?? undefined } ); } catch ( error ) { throw new Error( `Request to ${ fileUrl } failed: ${ diff --git a/src/lib/http/proxy-dispatcher.ts b/src/lib/http/proxy-dispatcher.ts index 5b00902ec..ece940b10 100644 --- a/src/lib/http/proxy-dispatcher.ts +++ b/src/lib/http/proxy-dispatcher.ts @@ -1,11 +1,34 @@ import debugLib from 'debug'; -import { getProxyForUrl } from 'proxy-from-env'; import { ProxyAgent, type Dispatcher } from 'undici'; const debug = debugLib( 'vip:proxy-dispatcher' ); const proxyDispatchers = new Map< string, Dispatcher >(); +function normalizeNoProxyToken( token: string ): string { + return token.trim().toLowerCase(); +} + +function isNoProxyMatch( hostname: string, port: string, token: string ): boolean { + // eslint-disable-next-line security/detect-possible-timing-attacks + if ( token === '*' ) { + return true; + } + + const [ tokenHostRaw, tokenPort ] = token.split( ':' ); + const tokenHost = tokenHostRaw.replace( /^\./u, '' ); + + if ( tokenPort && tokenPort !== port ) { + return false; + } + + if ( hostname === tokenHost ) { + return true; + } + + return hostname.endsWith( `.${ tokenHost }` ); +} + function isCoveredByNoProxy( url: string ): boolean { const NO_PROXY = process.env.NO_PROXY || process.env.no_proxy || null; // NOSONAR @@ -13,7 +36,13 @@ function isCoveredByNoProxy( url: string ): boolean { return false; } - return getProxyForUrl( url ) === ''; + const parsedUrl = new URL( url ); + const hostname = parsedUrl.hostname.toLowerCase(); + const port = parsedUrl.port || ( parsedUrl.protocol === 'http:' ? '80' : '443' ); + + const noProxyTokens = NO_PROXY.split( ',' ).map( normalizeNoProxyToken ).filter( Boolean ); + + return noProxyTokens.some( token => isNoProxyMatch( hostname, port, token ) ); } function resolveProxyUrl( url: string ): string | null { @@ -38,6 +67,15 @@ function resolveProxyUrl( url: string ): string | null { return null; } +function redactProxyUrl( proxyUrl: string ): string { + try { + const parsed = new URL( proxyUrl ); + return `${ parsed.protocol }//${ parsed.host }`; + } catch { + return 'invalid-proxy-url'; + } +} + let listeners: ReturnType< typeof process.rawListeners< 'warning' > > | null = null; // Suppress ExperimentalWarning: SOCKS5 proxy support is experimental and subject to change @@ -45,7 +83,7 @@ function suppressExperimentalSocksWarning( warning: Error ): void { listeners?.forEach( listener => process.on( 'warning', listener as NodeJS.WarningListener ) ); if ( warning.name !== 'ExperimentalWarning' || ! /Socks5ProxyAgent/u.test( warning.message ) ) { - process.emitWarning( warning ); + listeners?.forEach( listener => ( listener as NodeJS.WarningListener )( warning ) ); } } @@ -63,7 +101,9 @@ export function createProxyDispatcher( url: string ): Dispatcher | null { } if ( ! proxyDispatchers.has( proxyUrl ) ) { - debug( `Enabling fetch dispatcher proxy support using config: ${ proxyUrl }` ); + debug( + `Enabling fetch dispatcher proxy support using config: ${ redactProxyUrl( proxyUrl ) }` + ); proxyDispatchers.set( proxyUrl, new ProxyAgent( proxyUrl ) ); } diff --git a/src/lib/media-import/status.ts b/src/lib/media-import/status.ts index fb0b10c76..48819ec92 100644 --- a/src/lib/media-import/status.ts +++ b/src/lib/media-import/status.ts @@ -4,6 +4,7 @@ import { prompt } from 'enquirer'; import gql from 'graphql-tag'; import { writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; +import { fetch } from 'undici'; import { AppQuery, AppQueryVariables } from './status.generated'; import { @@ -20,6 +21,7 @@ import { currentUserCanImportForApp, } from '../../lib/media-import/media-file-import'; import { MediaImportProgressTracker } from '../../lib/media-import/progress'; +import { createProxyDispatcher } from '../http/proxy-dispatcher'; const IMPORT_MEDIA_PROGRESS_POLL_INTERVAL = 1000; const ONE_MINUTE_IN_MILLISECONDS = 1000 * 60; @@ -300,7 +302,10 @@ Downloading errors details from ${ fileErrorsUrl } \n`; progressTracker.print( { clearAfter: true } ); try { - const response = await fetch( fileErrorsUrl ); + const proxyDispatcher = createProxyDispatcher( fileErrorsUrl ); + const response = await fetch( fileErrorsUrl, { + dispatcher: proxyDispatcher ?? undefined, + } ); return ( await response.json() ) as AppEnvironmentMediaImportStatusFailureDetailsFileErrors[]; } catch ( err ) { progressTracker.suffix += `${ chalk.red( diff --git a/src/lib/tracker.ts b/src/lib/tracker.ts index 0e661c111..525dd2414 100644 --- a/src/lib/tracker.ts +++ b/src/lib/tracker.ts @@ -8,6 +8,7 @@ import config from '../lib/cli/config'; import Token from '../lib/token'; import type { AnalyticsClient } from './analytics/clients/client'; +import type { Response } from 'undici'; const debug = debugLib( '@automattic/vip:analytics' ); From 0f15044798a730cfb17fe06e91f11371ab12d3ed Mon Sep 17 00:00:00 2001 From: Volodymyr Kolesnykov Date: Tue, 12 May 2026 19:54:12 +0300 Subject: [PATCH 4/4] fix: regexp --- src/lib/http/proxy-dispatcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/http/proxy-dispatcher.ts b/src/lib/http/proxy-dispatcher.ts index ece940b10..d8574171c 100644 --- a/src/lib/http/proxy-dispatcher.ts +++ b/src/lib/http/proxy-dispatcher.ts @@ -82,7 +82,7 @@ let listeners: ReturnType< typeof process.rawListeners< 'warning' > > | null = n function suppressExperimentalSocksWarning( warning: Error ): void { listeners?.forEach( listener => process.on( 'warning', listener as NodeJS.WarningListener ) ); - if ( warning.name !== 'ExperimentalWarning' || ! /Socks5ProxyAgent/u.test( warning.message ) ) { + if ( warning.name !== 'ExperimentalWarning' || ! /SOCKS5 proxy/u.test( warning.message ) ) { listeners?.forEach( listener => ( listener as NodeJS.WarningListener )( warning ) ); } }