From 308a13c81bd72ca718c00bb5a7b1088948688e80 Mon Sep 17 00:00:00 2001 From: DmitryAnansky Date: Fri, 17 Jan 2025 12:24:15 +0200 Subject: [PATCH] feat: migrate from node-fetch to native fetch --- .changeset/warm-tips-sit.md | 6 + .github/workflows/smoke.yaml | 80 ++++++------- package-lock.json | 105 ++++-------------- package.json | 4 +- packages/cli/package.json | 5 +- .../__tests__/commands/push-region.test.ts | 64 +++++++++-- .../cli/src/__tests__/commands/push.test.ts | 54 +++++++-- .../src/__tests__/fetch-with-timeout.test.ts | 54 ++++++--- packages/cli/src/__tests__/wrapper.test.ts | 14 ++- .../src/cms/api/__tests__/api.client.test.ts | 34 +++--- packages/cli/src/cms/api/api-client.ts | 18 ++- packages/cli/src/commands/push.ts | 26 ++++- packages/cli/src/utils/fetch-with-timeout.ts | 17 ++- packages/core/package.json | 10 +- .../redocly/__tests__/redocly-client.test.ts | 20 +++- packages/core/src/redocly/registry-api.ts | 13 ++- packages/core/src/utils.ts | 1 - 17 files changed, 314 insertions(+), 211 deletions(-) create mode 100644 .changeset/warm-tips-sit.md diff --git a/.changeset/warm-tips-sit.md b/.changeset/warm-tips-sit.md new file mode 100644 index 0000000000..1e7fbd5c99 --- /dev/null +++ b/.changeset/warm-tips-sit.md @@ -0,0 +1,6 @@ +--- +"@redocly/openapi-core": minor +"@redocly/cli": minor +--- + +Switched to using native `fetch` API instead of `node-fetch` dependency, improving performance and reducing bundle size. diff --git a/.github/workflows/smoke.yaml b/.github/workflows/smoke.yaml index b70f1032f1..f4fc05fd40 100644 --- a/.github/workflows/smoke.yaml +++ b/.github/workflows/smoke.yaml @@ -104,7 +104,7 @@ jobs: node-version: 18 - run: bash ./__tests__/smoke/run-smoke.sh "npm i redoc redocly-cli.tgz" "npm run" - run-smoke--npm--node-16: + run-smoke--yarn--node-22: needs: prepare-smoke runs-on: ubuntu-latest steps: @@ -114,10 +114,10 @@ jobs: key: cache-${{ github.run_id }}-${{ github.run_attempt }} - uses: actions/setup-node@v3 with: - node-version: 16 - - run: bash ./__tests__/smoke/run-smoke.sh "npm i redocly-cli.tgz" "npm run" + node-version: 22 + - run: bash ./__tests__/smoke/run-smoke.sh "yarn add ./redocly-cli.tgz" "yarn" - run-smoke--npm--node-16--redoc: + run-smoke--yarn--node-22--redoc: needs: prepare-smoke runs-on: ubuntu-latest steps: @@ -127,10 +127,10 @@ jobs: key: cache-${{ github.run_id }}-${{ github.run_attempt }} - uses: actions/setup-node@v3 with: - node-version: 16 - - run: bash ./__tests__/smoke/run-smoke.sh "npm i redoc redocly-cli.tgz" "npm run" + node-version: 22 + - run: bash ./__tests__/smoke/run-smoke.sh "yarn add redoc ./redocly-cli.tgz" "yarn" - run-smoke--npm--node-14: + run-smoke--yarn--node-20: needs: prepare-smoke runs-on: ubuntu-latest steps: @@ -140,10 +140,10 @@ jobs: key: cache-${{ github.run_id }}-${{ github.run_attempt }} - uses: actions/setup-node@v3 with: - node-version: 14 - - run: bash ./__tests__/smoke/run-smoke.sh "npm i redocly-cli.tgz" "npm run" + node-version: 20 + - run: bash ./__tests__/smoke/run-smoke.sh "yarn add ./redocly-cli.tgz" "yarn" - run-smoke--npm--node-14--redoc: + run-smoke--yarn--node-20--redoc: needs: prepare-smoke runs-on: ubuntu-latest steps: @@ -153,10 +153,10 @@ jobs: key: cache-${{ github.run_id }}-${{ github.run_attempt }} - uses: actions/setup-node@v3 with: - node-version: 14 - - run: bash ./__tests__/smoke/run-smoke.sh "npm i redoc redocly-cli.tgz" "npm run" + node-version: 20 + - run: bash ./__tests__/smoke/run-smoke.sh "yarn add redoc ./redocly-cli.tgz" "yarn" - run-smoke--yarn--node-22: + run-smoke--yarn--node-18: needs: prepare-smoke runs-on: ubuntu-latest steps: @@ -166,10 +166,10 @@ jobs: key: cache-${{ github.run_id }}-${{ github.run_attempt }} - uses: actions/setup-node@v3 with: - node-version: 22 + node-version: 18 - run: bash ./__tests__/smoke/run-smoke.sh "yarn add ./redocly-cli.tgz" "yarn" - run-smoke--yarn--node-22--redoc: + run-smoke--yarn--node-18--redoc: needs: prepare-smoke runs-on: ubuntu-latest steps: @@ -179,10 +179,10 @@ jobs: key: cache-${{ github.run_id }}-${{ github.run_attempt }} - uses: actions/setup-node@v3 with: - node-version: 22 + node-version: 18 - run: bash ./__tests__/smoke/run-smoke.sh "yarn add redoc ./redocly-cli.tgz" "yarn" - run-smoke--yarn--node-20: + run-smoke--webpack--node-22: needs: prepare-smoke runs-on: ubuntu-latest steps: @@ -192,64 +192,68 @@ jobs: key: cache-${{ github.run_id }}-${{ github.run_attempt }} - uses: actions/setup-node@v3 with: - node-version: 20 - - run: bash ./__tests__/smoke/run-smoke.sh "yarn add ./redocly-cli.tgz" "yarn" + node-version: 22 + - run: | + cd __tests__/smoke/ + node bundle.js --version + node bundle.js lint openapi.yaml --extends minimal + node bundle.js bundle openapi.yaml - run-smoke--yarn--node-20--redoc: + run-smoke--npm--node-22--windows: needs: prepare-smoke - runs-on: ubuntu-latest + runs-on: windows-latest steps: - uses: actions/cache@v3 with: path: __tests__/smoke/ key: cache-${{ github.run_id }}-${{ github.run_attempt }} + enableCrossOsArchive: true - uses: actions/setup-node@v3 with: - node-version: 20 - - run: bash ./__tests__/smoke/run-smoke.sh "yarn add redoc ./redocly-cli.tgz" "yarn" + node-version: 22 + - run: bash ./__tests__/smoke/run-smoke.sh "npm i redocly-cli.tgz" "npm run" - run-smoke--yarn--node-18: + run-smoke--yarn--node-22--windows: needs: prepare-smoke - runs-on: ubuntu-latest + runs-on: windows-latest steps: - uses: actions/cache@v3 with: path: __tests__/smoke/ key: cache-${{ github.run_id }}-${{ github.run_attempt }} + enableCrossOsArchive: true - uses: actions/setup-node@v3 with: - node-version: 18 + node-version: 22 - run: bash ./__tests__/smoke/run-smoke.sh "yarn add ./redocly-cli.tgz" "yarn" - run-smoke--yarn--node-18--redoc: + run-smoke--npm--node-20--windows: needs: prepare-smoke - runs-on: ubuntu-latest + runs-on: windows-latest steps: - uses: actions/cache@v3 with: path: __tests__/smoke/ key: cache-${{ github.run_id }}-${{ github.run_attempt }} + enableCrossOsArchive: true - uses: actions/setup-node@v3 with: - node-version: 18 - - run: bash ./__tests__/smoke/run-smoke.sh "yarn add redoc ./redocly-cli.tgz" "yarn" + node-version: 20 + - run: bash ./__tests__/smoke/run-smoke.sh "npm i redocly-cli.tgz" "npm run" - run-smoke--webpack--node-14: + run-smoke--yarn--node-20--windows: needs: prepare-smoke - runs-on: ubuntu-latest + runs-on: windows-latest steps: - uses: actions/cache@v3 with: path: __tests__/smoke/ key: cache-${{ github.run_id }}-${{ github.run_attempt }} + enableCrossOsArchive: true - uses: actions/setup-node@v3 with: - node-version: 14 - - run: | - cd __tests__/smoke/ - node bundle.js --version - node bundle.js lint openapi.yaml --extends minimal - node bundle.js bundle openapi.yaml + node-version: 20 + - run: bash ./__tests__/smoke/run-smoke.sh "yarn add ./redocly-cli.tgz" "yarn" run-smoke--npm--node-18--windows: needs: prepare-smoke diff --git a/package-lock.json b/package-lock.json index 2f85404098..caa40fda2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,8 +36,8 @@ "webpack-cli": "^4.10.0" }, "engines": { - "node": ">=15.0.0", - "npm": ">=7.0.0" + "node": ">=18.17.0", + "npm": ">=10.8.2" } }, "node_modules/@ampproject/remapping": { @@ -3457,16 +3457,6 @@ "undici-types": "~5.26.4" } }, - "node_modules/@types/node-fetch": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.2.tgz", - "integrity": "sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A==", - "dev": true, - "dependencies": { - "@types/node": "*", - "form-data": "^3.0.0" - } - }, "node_modules/@types/pluralize": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/pluralize/-/pluralize-0.0.29.tgz", @@ -6367,20 +6357,6 @@ "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.6.tgz", "integrity": "sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg==" }, - "node_modules/form-data": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", - "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", - "dev": true, - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fs-extra": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", @@ -12588,7 +12564,6 @@ "glob": "^7.1.6", "handlebars": "^4.7.6", "mobx": "^6.0.4", - "node-fetch": "^2.6.1", "pluralize": "^8.0.0", "react": "^17.0.0 || ^18.2.0", "react-dom": "^17.0.0 || ^18.2.0", @@ -12613,8 +12588,8 @@ "typescript": "5.5.3" }, "engines": { - "node": ">=14.19.0", - "npm": ">=7.0.0" + "node": ">=18.17.0", + "npm": ">=10.8.2" } }, "packages/cli/node_modules/form-data": { @@ -12638,11 +12613,10 @@ "@redocly/ajv": "^8.11.2", "@redocly/config": "^0.20.1", "colorette": "^1.2.0", - "https-proxy-agent": "^7.0.4", + "https-proxy-agent": "^7.0.5", "js-levenshtein": "^1.1.6", "js-yaml": "^4.1.0", "minimatch": "^5.0.1", - "node-fetch": "^2.6.1", "pluralize": "^8.0.0", "yaml-ast-parser": "0.0.43" }, @@ -12650,34 +12624,31 @@ "@types/js-levenshtein": "^1.1.0", "@types/js-yaml": "^4.0.3", "@types/minimatch": "^3.0.5", - "@types/node": "^20.11.5", - "@types/node-fetch": "^2.5.7", "@types/pluralize": "^0.0.29", "json-schema-to-ts": "^3.1.0", "typescript": "5.5.3" }, "engines": { - "node": ">=14.19.0", - "npm": ">=7.0.0" + "node": ">=18.17.0", + "npm": ">=10.8.2" } }, "packages/core/node_modules/agent-base": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", - "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", - "dependencies": { - "debug": "^4.3.4" - }, + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "license": "MIT", "engines": { "node": ">= 14" } }, "packages/core/node_modules/https-proxy-agent": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", - "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", "dependencies": { - "agent-base": "^7.0.2", + "agent-base": "^7.1.2", "debug": "4" }, "engines": { @@ -15064,7 +15035,6 @@ "glob": "^7.1.6", "handlebars": "^4.7.6", "mobx": "^6.0.4", - "node-fetch": "^2.6.1", "pluralize": "^8.0.0", "react": "^17.0.0 || ^18.2.0", "react-dom": "^17.0.0 || ^18.2.0", @@ -15101,35 +15071,29 @@ "@types/js-levenshtein": "^1.1.0", "@types/js-yaml": "^4.0.3", "@types/minimatch": "^3.0.5", - "@types/node": "^20.11.5", - "@types/node-fetch": "^2.5.7", "@types/pluralize": "^0.0.29", "colorette": "^1.2.0", - "https-proxy-agent": "^7.0.4", + "https-proxy-agent": "^7.0.5", "js-levenshtein": "^1.1.6", "js-yaml": "^4.1.0", "json-schema-to-ts": "^3.1.0", "minimatch": "^5.0.1", - "node-fetch": "^2.6.1", "pluralize": "^8.0.0", "typescript": "5.5.3", "yaml-ast-parser": "0.0.43" }, "dependencies": { "agent-base": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", - "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", - "requires": { - "debug": "^4.3.4" - } + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==" }, "https-proxy-agent": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", - "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "requires": { - "agent-base": "^7.0.2", + "agent-base": "^7.1.2", "debug": "4" } } @@ -15361,16 +15325,6 @@ "undici-types": "~5.26.4" } }, - "@types/node-fetch": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.2.tgz", - "integrity": "sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A==", - "dev": true, - "requires": { - "@types/node": "*", - "form-data": "^3.0.0" - } - }, "@types/pluralize": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/pluralize/-/pluralize-0.0.29.tgz", @@ -17559,17 +17513,6 @@ "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.6.tgz", "integrity": "sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg==" }, - "form-data": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", - "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", - "dev": true, - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - } - }, "fs-extra": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", diff --git a/package.json b/package.json index be58d171f5..b25939166d 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "description": "", "private": true, "engines": { - "node": ">=15.0.0", - "npm": ">=7.0.0" + "node": ">=18.17.0", + "npm": ">=10.8.2" }, "engineStrict": true, "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 3beb525310..5327d4d68f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -8,8 +8,8 @@ "redocly": "bin/cli.js" }, "engines": { - "node": ">=14.19.0", - "npm": ">=7.0.0" + "node": ">=18.17.0", + "npm": ">=10.8.2" }, "engineStrict": true, "scripts": { @@ -46,7 +46,6 @@ "glob": "^7.1.6", "handlebars": "^4.7.6", "mobx": "^6.0.4", - "node-fetch": "^2.6.1", "pluralize": "^8.0.0", "react": "^17.0.0 || ^18.2.0", "react-dom": "^17.0.0 || ^18.2.0", diff --git a/packages/cli/src/__tests__/commands/push-region.test.ts b/packages/cli/src/__tests__/commands/push-region.test.ts index a0e4bb1881..a3b5839e05 100644 --- a/packages/cli/src/__tests__/commands/push-region.test.ts +++ b/packages/cli/src/__tests__/commands/push-region.test.ts @@ -2,32 +2,71 @@ import { getMergedConfig } from '@redocly/openapi-core'; import { handlePush } from '../../commands/push'; import { promptClientToken } from '../../commands/login'; import { ConfigFixture } from '../fixtures/config'; +import { Readable } from 'node:stream'; -jest.mock('fs'); -jest.mock('node-fetch', () => ({ - default: jest.fn(() => ({ - ok: true, - json: jest.fn().mockResolvedValue({}), - })), +// Mock fs operations +jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + createReadStream: () => { + const readable = new Readable(); + readable.push('test data'); + readable.push(null); + return readable; + }, + statSync: () => ({ size: 9 }), + readFileSync: () => Buffer.from('test data'), + existsSync: () => false, + readdirSync: () => [], })); + +(getMergedConfig as jest.Mock).mockImplementation((config) => config); + +// Mock OpenAPI core jest.mock('@redocly/openapi-core'); jest.mock('../../commands/login'); jest.mock('../../utils/miscellaneous'); -(getMergedConfig as jest.Mock).mockImplementation((config) => config); - const mockPromptClientToken = promptClientToken as jest.MockedFunction; describe('push-with-region', () => { const redoclyClient = require('@redocly/openapi-core').__redoclyClient; redoclyClient.isAuthorizedWithRedoclyByRegion = jest.fn().mockResolvedValue(false); + const originalFetch = fetch; + beforeAll(() => { + // Mock global fetch + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({}), + headers: new Headers(), + statusText: 'OK', + redirected: false, + type: 'default', + url: '', + clone: () => ({} as Response), + body: new ReadableStream(), + bodyUsed: false, + arrayBuffer: async () => new ArrayBuffer(0), + blob: async () => new Blob(), + formData: async () => new FormData(), + text: async () => '', + } as Response) + ); + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + beforeEach(() => { jest.spyOn(process.stdout, 'write').mockImplementation(() => true); }); it('should call login with default domain when region is US', async () => { - redoclyClient.domain = 'redoc.ly'; + redoclyClient.domain = 'redocly.com'; await handlePush({ argv: { upsert: true, @@ -38,12 +77,16 @@ describe('push-with-region', () => { config: ConfigFixture as any, version: 'cli-version', }); + expect(mockPromptClientToken).toBeCalledTimes(1); expect(mockPromptClientToken).toHaveBeenCalledWith(redoclyClient.domain); }); it('should call login with EU domain when region is EU', async () => { redoclyClient.domain = 'eu.redocly.com'; + // Update config for EU region + const euConfig = { ...ConfigFixture, region: 'eu' }; + await handlePush({ argv: { upsert: true, @@ -51,9 +94,10 @@ describe('push-with-region', () => { destination: '@org/my-api@1.0.0', branchName: 'test', }, - config: ConfigFixture as any, + config: euConfig as any, version: 'cli-version', }); + expect(mockPromptClientToken).toBeCalledTimes(1); expect(mockPromptClientToken).toHaveBeenCalledWith(redoclyClient.domain); }); diff --git a/packages/cli/src/__tests__/commands/push.test.ts b/packages/cli/src/__tests__/commands/push.test.ts index 2fe8c36d68..bbea0dd9d2 100644 --- a/packages/cli/src/__tests__/commands/push.test.ts +++ b/packages/cli/src/__tests__/commands/push.test.ts @@ -4,26 +4,66 @@ import { exitWithError } from '../../utils/miscellaneous'; import { getApiRoot, getDestinationProps, handlePush, transformPush } from '../../commands/push'; import { ConfigFixture } from '../fixtures/config'; import { yellow } from 'colorette'; - -jest.mock('fs'); -jest.mock('node-fetch', () => ({ - default: jest.fn(() => ({ - ok: true, - json: jest.fn().mockResolvedValue({}), - })), +import { Readable } from 'node:stream'; + +// Mock fs operations +jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + createReadStream: jest.fn(() => { + const readable = new Readable(); + readable.push('test data'); + readable.push(null); + return readable; + }), + statSync: jest.fn(() => ({ isDirectory: () => false, size: 10 })), + readFileSync: jest.fn(() => Buffer.from('test data')), + existsSync: jest.fn(() => false), + readdirSync: jest.fn(() => []), })); + jest.mock('@redocly/openapi-core'); jest.mock('../../utils/miscellaneous'); +// Mock fetch +const mockFetch = jest.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({}), + headers: new Headers(), + statusText: 'OK', + redirected: false, + type: 'default', + url: '', + clone: () => ({} as Response), + body: null, + bodyUsed: false, + arrayBuffer: async () => new ArrayBuffer(0), + blob: async () => new Blob(), + formData: async () => new FormData(), + text: async () => '', + } as Response) +); + +const originalFetch = global.fetch; + (getMergedConfig as jest.Mock).mockImplementation((config) => config); describe('push', () => { const redoclyClient = require('@redocly/openapi-core').__redoclyClient; + beforeAll(() => { + global.fetch = mockFetch; + }); + beforeEach(() => { jest.spyOn(process.stdout, 'write').mockImplementation(() => true); }); + afterAll(() => { + global.fetch = originalFetch; + }); + it('pushes definition', async () => { await handlePush({ argv: { diff --git a/packages/cli/src/__tests__/fetch-with-timeout.test.ts b/packages/cli/src/__tests__/fetch-with-timeout.test.ts index 20c5e84461..95e0aa5067 100644 --- a/packages/cli/src/__tests__/fetch-with-timeout.test.ts +++ b/packages/cli/src/__tests__/fetch-with-timeout.test.ts @@ -1,12 +1,37 @@ import AbortController from 'abort-controller'; import fetchWithTimeout from '../utils/fetch-with-timeout'; -import nodeFetch from 'node-fetch'; import { getProxyAgent } from '@redocly/openapi-core'; import { HttpsProxyAgent } from 'https-proxy-agent'; -jest.mock('node-fetch'); jest.mock('@redocly/openapi-core'); +const signalInstance = new AbortController().signal; + +const mockFetch = jest.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({}), + headers: new Headers(), + statusText: 'OK', + redirected: false, + type: 'default', + url: '', + clone: () => ({} as Response), + body: null, + bodyUsed: false, + arrayBuffer: async () => new ArrayBuffer(0), + blob: async () => new Blob(), + formData: async () => new FormData(), + text: async () => '', + signal: signalInstance, + dispatcher: undefined, + } as Response) +); + +const originalFetch = global.fetch; +global.fetch = mockFetch; + describe('fetchWithTimeout', () => { beforeAll(() => { // @ts-ignore @@ -18,36 +43,39 @@ describe('fetchWithTimeout', () => { (getProxyAgent as jest.Mock).mockReturnValueOnce(undefined); }); - afterEach(() => { - jest.clearAllMocks(); + afterAll(() => { + global.fetch = originalFetch; }); - it('should call node-fetch with signal', async () => { + it('should call fetch with signal', async () => { await fetchWithTimeout('url', { timeout: 1000 }); expect(global.setTimeout).toHaveBeenCalledTimes(1); - expect(nodeFetch).toHaveBeenCalledWith('url', { - signal: new AbortController().signal, - agent: undefined, - }); + expect(global.fetch).toHaveBeenCalledWith( + 'url', + expect.objectContaining({ + signal: expect.any(AbortSignal), + dispatcher: undefined, + }) + ); expect(global.clearTimeout).toHaveBeenCalledTimes(1); }); - it('should call node-fetch with proxy agent', async () => { + it('should call fetch with proxy agent', async () => { (getProxyAgent as jest.Mock).mockRestore(); const proxyAgent = new HttpsProxyAgent('http://localhost'); (getProxyAgent as jest.Mock).mockReturnValueOnce(proxyAgent); await fetchWithTimeout('url'); - expect(nodeFetch).toHaveBeenCalledWith('url', { agent: proxyAgent }); + expect(global.fetch).toHaveBeenCalledWith('url', { dispatcher: proxyAgent }); }); - it('should call node-fetch without signal when timeout is not passed', async () => { + it('should call fetch without signal when timeout is not passed', async () => { await fetchWithTimeout('url'); expect(global.setTimeout).not.toHaveBeenCalled(); - expect(nodeFetch).toHaveBeenCalledWith('url', { agent: undefined }); + expect(global.fetch).toHaveBeenCalledWith('url', { agent: undefined }); expect(global.clearTimeout).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/__tests__/wrapper.test.ts b/packages/cli/src/__tests__/wrapper.test.ts index e1f8577989..709fad6e35 100644 --- a/packages/cli/src/__tests__/wrapper.test.ts +++ b/packages/cli/src/__tests__/wrapper.test.ts @@ -6,11 +6,23 @@ import { Arguments } from 'yargs'; import { handlePush, PushOptions } from '../commands/push'; import { detectSpec } from '@redocly/openapi-core'; -jest.mock('node-fetch'); +const mockFetch = jest.fn(); +const originalFetch = global.fetch; + jest.mock('../utils/miscellaneous', () => ({ sendTelemetry: jest.fn(), loadConfigAndHandleErrors: jest.fn(), })); + +beforeAll(() => { + global.fetch = mockFetch; +}); + +afterAll(() => { + jest.resetAllMocks(); + global.fetch = originalFetch; +}); + jest.mock('../commands/lint', () => ({ handleLint: jest.fn().mockImplementation(({ collectSpecData }) => { collectSpecData({ openapi: '3.1.0' }); diff --git a/packages/cli/src/cms/api/__tests__/api.client.test.ts b/packages/cli/src/cms/api/__tests__/api.client.test.ts index 77d4e5a3d7..39e4f85366 100644 --- a/packages/cli/src/cms/api/__tests__/api.client.test.ts +++ b/packages/cli/src/cms/api/__tests__/api.client.test.ts @@ -1,15 +1,21 @@ -import fetch, { Response } from 'node-fetch'; -import * as FormData from 'form-data'; import { red, yellow } from 'colorette'; import { ReuniteApi, PushPayload, ReuniteApiError } from '../api-client'; -jest.mock('node-fetch', () => ({ - default: jest.fn(), -})); +const originalFetch = global.fetch; + +beforeAll(() => { + // Reset fetch mock before each test + global.fetch = jest.fn(); +}); + +afterAll(() => { + // Restore original fetch after each test + global.fetch = originalFetch; +}); function mockFetchResponse(response: any) { - (fetch as jest.MockedFunction).mockResolvedValue(response as unknown as Response); + (global.fetch as jest.Mock).mockResolvedValue(response); } describe('ApiClient', () => { @@ -38,7 +44,7 @@ describe('ApiClient', () => { const result = await apiClient.remotes.getDefaultBranch(testOrg, testProject); - expect(fetch).toHaveBeenCalledWith( + expect(global.fetch).toHaveBeenCalledWith( `${testDomain}/api/orgs/${testOrg}/projects/${testProject}/source`, { method: 'GET', @@ -115,7 +121,7 @@ describe('ApiClient', () => { const result = await apiClient.remotes.upsert(testOrg, testProject, remotePayload); - expect(fetch).toHaveBeenCalledWith( + expect(global.fetch).toHaveBeenCalledWith( `${testDomain}/api/orgs/${testOrg}/projects/${testProject}/remotes`, { method: 'POST', @@ -213,12 +219,11 @@ describe('ApiClient', () => { }); it('should push to remote', async () => { - let passedFormData = new FormData(); + let passedFormData: FormData = new FormData(); (fetch as jest.MockedFunction).mockImplementationOnce( async (_: any, options: any): Promise => { passedFormData = options.body as FormData; - return { ok: true, json: jest.fn().mockResolvedValue(responseMock), @@ -226,14 +231,14 @@ describe('ApiClient', () => { } ); - const formData = new FormData(); + const formData = new globalThis.FormData(); formData.append('remoteId', testRemoteId); formData.append('commit[message]', pushPayload.commit.message); formData.append('commit[author][name]', pushPayload.commit.author.name); formData.append('commit[author][email]', pushPayload.commit.author.email); formData.append('commit[branchName]', pushPayload.commit.branchName); - formData.append('files[some-file.yaml]', filesMock[0].stream); + formData.append('files[some-file.yaml]', new Blob([filesMock[0].stream])); const result = await apiClient.remotes.push(testOrg, testProject, pushPayload, filesMock); @@ -247,10 +252,7 @@ describe('ApiClient', () => { }, }) ); - - expect( - JSON.stringify(passedFormData).replace(new RegExp(passedFormData.getBoundary(), 'g'), '') - ).toEqual(JSON.stringify(formData).replace(new RegExp(formData.getBoundary(), 'g'), '')); + expect(passedFormData).toEqual(formData); expect(result).toEqual(responseMock); }); diff --git a/packages/cli/src/cms/api/api-client.ts b/packages/cli/src/cms/api/api-client.ts index 3cd0fa5679..4578ae287a 100644 --- a/packages/cli/src/cms/api/api-client.ts +++ b/packages/cli/src/cms/api/api-client.ts @@ -1,12 +1,11 @@ import { yellow, red } from 'colorette'; -import * as FormData from 'form-data'; import fetchWithTimeout, { type FetchWithTimeoutOptions, DEFAULT_FETCH_TIMEOUT, } from '../../utils/fetch-with-timeout'; -import type { Response } from 'node-fetch'; import type { ReadStream } from 'fs'; +import type { Readable } from 'node:stream'; import type { ListRemotesResponse, ProjectSourceResponse, @@ -178,7 +177,7 @@ class RemotesApi { payload: PushPayload, files: { path: string; stream: ReadStream | Buffer }[] ): Promise { - const formData = new FormData(); + const formData = new globalThis.FormData(); formData.append('remoteId', payload.remoteId); formData.append('commit[message]', payload.commit.message); @@ -192,7 +191,10 @@ class RemotesApi { payload.commit.createdAt && formData.append('commit[createdAt]', payload.commit.createdAt); for (const file of files) { - formData.append(`files[${file.path}]`, file.stream); + const blob = Buffer.isBuffer(file.stream) + ? new Blob([file.stream]) + : new Blob([await streamToBuffer(file.stream)]); + formData.append(`files[${file.path}]`, blob, file.path); } payload.isMainBranch && formData.append('isMainBranch', 'true'); @@ -369,3 +371,11 @@ export type PushPayload = { }; isMainBranch?: boolean; }; + +export async function streamToBuffer(stream: ReadStream | Readable): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + return Buffer.concat(chunks); +} diff --git a/packages/cli/src/commands/push.ts b/packages/cli/src/commands/push.ts index 3a6255bc8b..bfb928669e 100644 --- a/packages/cli/src/commands/push.ts +++ b/packages/cli/src/commands/push.ts @@ -1,6 +1,5 @@ import * as fs from 'fs'; import * as path from 'path'; -import fetch from 'node-fetch'; import { performance } from 'perf_hooks'; import { yellow, green, blue, red } from 'colorette'; import { createHash } from 'crypto'; @@ -22,7 +21,10 @@ import { } from '../utils/miscellaneous'; import { promptClientToken } from './login'; import { handlePush as handleCMSPush } from '../cms/commands/push'; +import { streamToBuffer } from '../cms/api/api-client'; +import type { Readable } from 'node:stream'; +import type { Agent } from 'node:http'; import type { Config, BundleOutputFormat, Region } from '@redocly/openapi-core'; import type { CommandArgs } from '../wrapper'; import type { VerifyConfigOptions } from '../types'; @@ -436,7 +438,7 @@ export function getApiRoot({ return api?.root; } -function uploadFileToS3(url: string, filePathOrBuffer: string | Buffer) { +async function uploadFileToS3(url: string, filePathOrBuffer: string | Buffer) { const fileSizeInBytes = typeof filePathOrBuffer === 'string' ? fs.statSync(filePathOrBuffer).size @@ -445,12 +447,24 @@ function uploadFileToS3(url: string, filePathOrBuffer: string | Buffer) { const readStream = typeof filePathOrBuffer === 'string' ? fs.createReadStream(filePathOrBuffer) : filePathOrBuffer; - return fetch(url, { + type NodeFetchRequestInit = RequestInit & { + dispatcher?: Agent; + }; + + const requestOptions: NodeFetchRequestInit = { method: 'PUT', headers: { 'Content-Length': fileSizeInBytes.toString(), }, - body: readStream, - agent: getProxyAgent(), - }); + body: Buffer.isBuffer(readStream) + ? new Blob([readStream]) + : new Blob([await streamToBuffer(readStream as Readable)]), + }; + + const proxyAgent = getProxyAgent(); + if (proxyAgent) { + requestOptions.dispatcher = proxyAgent; + } + + return fetch(url, requestOptions); } diff --git a/packages/cli/src/utils/fetch-with-timeout.ts b/packages/cli/src/utils/fetch-with-timeout.ts index 5bfcba4d41..fe538cdc59 100644 --- a/packages/cli/src/utils/fetch-with-timeout.ts +++ b/packages/cli/src/utils/fetch-with-timeout.ts @@ -1,5 +1,3 @@ -import nodeFetch, { type RequestInit } from 'node-fetch'; -import AbortController from 'abort-controller'; import { getProxyAgent } from '@redocly/openapi-core'; export const DEFAULT_FETCH_TIMEOUT = 3000; @@ -10,24 +8,23 @@ export type FetchWithTimeoutOptions = RequestInit & { export default async (url: string, { timeout, ...options }: FetchWithTimeoutOptions = {}) => { if (!timeout) { - return nodeFetch(url, { + return fetch(url, { ...options, - agent: getProxyAgent(), - }); + dispatcher: getProxyAgent(), + } as RequestInit); } - const controller = new AbortController(); + const controller = new globalThis.AbortController(); const timeoutId = setTimeout(() => { controller.abort(); }, timeout); - const res = await nodeFetch(url, { + const res = await fetch(url, { signal: controller.signal, ...options, - agent: getProxyAgent(), - }); + dispatcher: getProxyAgent(), + } as RequestInit); clearTimeout(timeoutId); - return res; }; diff --git a/packages/core/package.json b/packages/core/package.json index f3cc99f103..f3871d67ef 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -4,8 +4,8 @@ "description": "", "main": "lib/index.js", "engines": { - "node": ">=14.19.0", - "npm": ">=7.0.0" + "node": ">=18.17.0", + "npm": ">=10.8.2" }, "engineStrict": true, "license": "MIT", @@ -17,7 +17,6 @@ "fs": false, "path": "path-browserify", "os": false, - "node-fetch": false, "colorette": false, "https-proxy-agent": false }, @@ -38,11 +37,10 @@ "@redocly/ajv": "^8.11.2", "@redocly/config": "^0.20.1", "colorette": "^1.2.0", - "https-proxy-agent": "^7.0.4", + "https-proxy-agent": "^7.0.5", "js-levenshtein": "^1.1.6", "js-yaml": "^4.1.0", "minimatch": "^5.0.1", - "node-fetch": "^2.6.1", "pluralize": "^8.0.0", "yaml-ast-parser": "0.0.43" }, @@ -50,8 +48,6 @@ "@types/js-levenshtein": "^1.1.0", "@types/js-yaml": "^4.0.3", "@types/minimatch": "^3.0.5", - "@types/node": "^20.11.5", - "@types/node-fetch": "^2.5.7", "@types/pluralize": "^0.0.29", "json-schema-to-ts": "^3.1.0", "typescript": "5.5.3" diff --git a/packages/core/src/redocly/__tests__/redocly-client.test.ts b/packages/core/src/redocly/__tests__/redocly-client.test.ts index 0e19752d6c..28a94a8f1e 100644 --- a/packages/core/src/redocly/__tests__/redocly-client.test.ts +++ b/packages/core/src/redocly/__tests__/redocly-client.test.ts @@ -1,12 +1,7 @@ import { setRedoclyDomain } from '../domains'; import { RedoclyClient } from '../index'; -jest.mock('node-fetch', () => ({ - default: jest.fn(() => ({ - ok: true, - json: jest.fn().mockResolvedValue({}), - })), -})); +const originalFetch = global.fetch; describe('RedoclyClient', () => { const REDOCLY_DOMAIN_US = 'redocly.com'; @@ -15,6 +10,19 @@ describe('RedoclyClient', () => { const testRedoclyDomain = 'redoclyDomain.com'; const testToken = 'test-token'; + beforeAll(() => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: jest.fn().mockResolvedValue({}), + } as any) + ); + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + afterEach(() => { delete process.env.REDOCLY_DOMAIN; setRedoclyDomain(''); diff --git a/packages/core/src/redocly/registry-api.ts b/packages/core/src/redocly/registry-api.ts index ff6abafde7..c3686a5116 100644 --- a/packages/core/src/redocly/registry-api.ts +++ b/packages/core/src/redocly/registry-api.ts @@ -1,8 +1,6 @@ -import fetch from 'node-fetch'; import { getProxyAgent, isNotEmptyObject } from '../utils'; import { getRedoclyDomain } from './domains'; -import type { RequestInit, HeadersInit } from 'node-fetch'; import type { NotFoundProblemResponse, PrepareFileuploadOKResponse, @@ -43,10 +41,13 @@ export class RegistryApi { throw new Error('Unauthorized'); } - const response = await fetch( - `${this.getBaseUrl()}${path}`, - Object.assign({}, options, { headers, agent: getProxyAgent() }) - ); + const requestOptions = { + ...options, + headers, + agent: getProxyAgent(), + }; + + const response = await fetch(`${this.getBaseUrl()}${path}`, requestOptions); if (response.status === 401) { throw new Error('Unauthorized'); diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 16d5940bb2..0d4bd2a116 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -1,7 +1,6 @@ import * as fs from 'fs'; import { extname } from 'path'; import * as minimatch from 'minimatch'; -import fetch from 'node-fetch'; import { parseYaml } from './js-yaml'; import { env } from './env'; import { logger, colorize } from './logger';