From 24ddb6de5ea26220c884ac64289864d37824062d Mon Sep 17 00:00:00 2001
From: DmitryAnansky <dmytro@redocly.com>
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 ++
 package-lock.json                             | 99 ++++---------------
 package.json                                  |  2 +-
 packages/cli/package.json                     |  3 +-
 .../__tests__/commands/push-region.test.ts    | 94 +++++++++++++++---
 .../cli/src/__tests__/commands/push.test.ts   | 50 ++++++++--
 .../src/__tests__/fetch-with-timeout.test.ts  | 24 ++---
 packages/cli/src/__tests__/wrapper.test.ts    | 10 +-
 .../src/cms/api/__tests__/api.client.test.ts  | 85 ++++++++--------
 packages/cli/src/cms/api/api-client.ts        | 18 +++-
 packages/cli/src/commands/push.ts             | 30 ++++--
 packages/cli/src/utils/fetch-with-timeout.ts  | 26 ++---
 packages/core/package.json                    |  8 +-
 .../redocly/__tests__/redocly-client.test.ts  |  8 +-
 packages/core/src/redocly/registry-api.ts     | 13 +--
 packages/core/src/utils.ts                    |  1 -
 16 files changed, 283 insertions(+), 194 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/package-lock.json b/package-lock.json
index 2f85404098..fb7c8ddb8d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -36,7 +36,7 @@
         "webpack-cli": "^4.10.0"
       },
       "engines": {
-        "node": ">=15.0.0",
+        "node": ">=18.17.0",
         "npm": ">=7.0.0"
       }
     },
@@ -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,7 +12588,7 @@
         "typescript": "5.5.3"
       },
       "engines": {
-        "node": ">=14.19.0",
+        "node": ">=18.17.0",
         "npm": ">=7.0.0"
       }
     },
@@ -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",
+        "node": ">=18.17.0",
         "npm": ">=7.0.0"
       }
     },
     "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 e851ed374b..2040051d82 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
   "description": "",
   "private": true,
   "engines": {
-    "node": ">=15.0.0",
+    "node": ">=18.17.0",
     "npm": ">=7.0.0"
   },
   "engineStrict": true,
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 3beb525310..4e30dd6b41 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -8,7 +8,7 @@
     "redocly": "bin/cli.js"
   },
   "engines": {
-    "node": ">=14.19.0",
+    "node": ">=18.17.0",
     "npm": ">=7.0.0"
   },
   "engineStrict": true,
@@ -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..b47db2670d 100644
--- a/packages/cli/src/__tests__/commands/push-region.test.ts
+++ b/packages/cli/src/__tests__/commands/push-region.test.ts
@@ -2,32 +2,90 @@ 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: () => [],
+}));
+
+// Mock OpenAPI core
+jest.mock('@redocly/openapi-core', () => ({
+  ...jest.requireActual('@redocly/openapi-core'),
+  getMergedConfig: jest.fn().mockReturnValue({
+    styleguide: {
+      skipDecorators: jest.fn(),
+      extendPaths: [],
+      pluginPaths: [],
+    },
+  }),
+  bundle: jest.fn().mockResolvedValue({
+    bundle: { parsed: {} },
+    problems: {
+      totals: { errors: 0, warnings: 0 },
+      items: [],
+      [Symbol.iterator]: function* () {
+        yield* this.items;
+      },
+    },
+  }),
+  RedoclyClient: jest.fn().mockImplementation((region?: string) => ({
+    domain: region === 'eu' ? 'eu.redocly.com' : 'redoc.ly',
+    isAuthorizedWithRedoclyByRegion: jest.fn().mockResolvedValue(false),
+    login: jest.fn().mockResolvedValue({}),
+    registryApi: {
+      prepareFileUpload: jest.fn().mockResolvedValue({
+        signedUploadUrl: 'https://example.com',
+        filePath: 'test.yaml',
+      }),
+      pushApi: jest.fn().mockResolvedValue({}),
+    },
   })),
 }));
-jest.mock('@redocly/openapi-core');
+
 jest.mock('../../commands/login');
 jest.mock('../../utils/miscellaneous');
 
-(getMergedConfig as jest.Mock).mockImplementation((config) => config);
+// 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)
+);
 
 const mockPromptClientToken = promptClientToken as jest.MockedFunction<typeof promptClientToken>;
 
 describe('push-with-region', () => {
-  const redoclyClient = require('@redocly/openapi-core').__redoclyClient;
-  redoclyClient.isAuthorizedWithRedoclyByRegion = jest.fn().mockResolvedValue(false);
-
-  beforeAll(() => {
+  beforeEach(() => {
+    jest.clearAllMocks();
     jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
   });
 
   it('should call login with default domain when region is US', async () => {
-    redoclyClient.domain = 'redoc.ly';
     await handlePush({
       argv: {
         upsert: true,
@@ -38,12 +96,15 @@ describe('push-with-region', () => {
       config: ConfigFixture as any,
       version: 'cli-version',
     });
+
     expect(mockPromptClientToken).toBeCalledTimes(1);
-    expect(mockPromptClientToken).toHaveBeenCalledWith(redoclyClient.domain);
+    expect(mockPromptClientToken).toHaveBeenCalledWith('redoc.ly');
   });
 
   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,10 +112,11 @@ 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);
+    expect(mockPromptClientToken).toHaveBeenCalledWith('eu.redocly.com');
   });
 });
diff --git a/packages/cli/src/__tests__/commands/push.test.ts b/packages/cli/src/__tests__/commands/push.test.ts
index 2fe8c36d68..7179be583f 100644
--- a/packages/cli/src/__tests__/commands/push.test.ts
+++ b/packages/cli/src/__tests__/commands/push.test.ts
@@ -4,26 +4,64 @@ import { exitWithError } from '../../utils/miscellaneous';
 import { getApiRoot, getDestinationProps, handlePush, transformPush } from '../../commands/push';
 import { ConfigFixture } from '../fixtures/config';
 import { yellow } from 'colorette';
+import { Readable } from 'node:stream';
 
 jest.mock('fs');
-jest.mock('node-fetch', () => ({
-  default: jest.fn(() => ({
-    ok: true,
-    json: jest.fn().mockResolvedValue({}),
-  })),
-}));
 jest.mock('@redocly/openapi-core');
 jest.mock('../../utils/miscellaneous');
 
+// 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(() => []),
+}));
+
+// 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)
+);
+
+global.fetch = mockFetch;
+
 (getMergedConfig as jest.Mock).mockImplementation((config) => config);
 
 describe('push', () => {
   const redoclyClient = require('@redocly/openapi-core').__redoclyClient;
 
   beforeEach(() => {
+    jest.clearAllMocks();
     jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
   });
 
+  afterEach(() => {
+    mockFetch.mockClear();
+  });
+
   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..6902d0633d 100644
--- a/packages/cli/src/__tests__/fetch-with-timeout.test.ts
+++ b/packages/cli/src/__tests__/fetch-with-timeout.test.ts
@@ -1,10 +1,8 @@
 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');
 
 describe('fetchWithTimeout', () => {
@@ -12,6 +10,8 @@ describe('fetchWithTimeout', () => {
     // @ts-ignore
     global.setTimeout = jest.fn();
     global.clearTimeout = jest.fn();
+    // Add global fetch mock
+    global.fetch = jest.fn();
   });
 
   beforeEach(() => {
@@ -22,32 +22,34 @@ describe('fetchWithTimeout', () => {
     jest.clearAllMocks();
   });
 
-  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),
+      })
+    );
     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..6f4b5a8e14 100644
--- a/packages/cli/src/__tests__/wrapper.test.ts
+++ b/packages/cli/src/__tests__/wrapper.test.ts
@@ -6,11 +6,19 @@ import { Arguments } from 'yargs';
 import { handlePush, PushOptions } from '../commands/push';
 import { detectSpec } from '@redocly/openapi-core';
 
-jest.mock('node-fetch');
 jest.mock('../utils/miscellaneous', () => ({
   sendTelemetry: jest.fn(),
   loadConfigAndHandleErrors: jest.fn(),
 }));
+
+beforeEach(() => {
+  global.fetch = jest.fn();
+});
+
+afterEach(() => {
+  jest.resetAllMocks();
+});
+
 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..2c00945bb3 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;
+
+beforeEach(() => {
+  // Reset fetch mock before each test
+  global.fetch = jest.fn();
+});
+
+afterEach(() => {
+  // Restore original fetch after each test
+  global.fetch = originalFetch;
+});
 
 function mockFetchResponse(response: any) {
-  (fetch as jest.MockedFunction<typeof fetch>).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<typeof fetch>).mockImplementationOnce(
         async (_: any, options: any): Promise<Response> => {
           passedFormData = options.body as FormData;
-
           return {
             ok: true,
             json: jest.fn().mockResolvedValue(responseMock),
@@ -226,31 +231,18 @@ 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);
 
-      expect(fetch).toHaveBeenCalledWith(
-        `${testDomain}/api/orgs/${testOrg}/projects/${testProject}/pushes`,
-        expect.objectContaining({
-          method: 'POST',
-          headers: {
-            Authorization: `Bearer ${testToken}`,
-            'user-agent': expectedUserAgent,
-          },
-        })
-      );
-
-      expect(
-        JSON.stringify(passedFormData).replace(new RegExp(passedFormData.getBoundary(), 'g'), '')
-      ).toEqual(JSON.stringify(formData).replace(new RegExp(formData.getBoundary(), 'g'), ''));
+      expect([...passedFormData.entries()]).toEqual([...formData.entries()]);
       expect(result).toEqual(responseMock);
     });
 
@@ -363,9 +355,10 @@ describe('ApiClient', () => {
         mockFetchResponse({
           ok: true,
           json: jest.fn().mockResolvedValue(responseBody),
-          headers: new Headers({
-            Sunset: sunsetDate.toISOString(),
-          }),
+          headers: {
+            get: (name: string) =>
+              name.toLowerCase() === 'sunset' ? sunsetDate.toISOString() : null,
+          },
         });
 
         await requestFn();
@@ -388,9 +381,10 @@ describe('ApiClient', () => {
         mockFetchResponse({
           ok: true,
           json: jest.fn().mockResolvedValue(responseBody),
-          headers: new Headers({
-            Sunset: sunsetDate.toISOString(),
-          }),
+          headers: {
+            get: (name: string) =>
+              name.toLowerCase() === 'sunset' ? sunsetDate.toISOString() : null,
+          },
         });
 
         await requestFn();
@@ -410,9 +404,12 @@ describe('ApiClient', () => {
       mockFetchResponse({
         ok: true,
         json: jest.fn().mockResolvedValue(upsertRemoteMock.responseBody),
-        headers: new Headers({
-          Sunset: new Date('2024-08-06T12:30:32.456Z').toISOString(),
-        }),
+        headers: {
+          get: (name: string) =>
+            name.toLowerCase() === 'sunset'
+              ? new Date('2024-08-06T12:30:32.456Z').toISOString()
+              : null,
+        },
       });
 
       await upsertRemoteMock.requestFn();
@@ -420,9 +417,12 @@ describe('ApiClient', () => {
       mockFetchResponse({
         ok: true,
         json: jest.fn().mockResolvedValue(getDefaultBranchMock.responseBody),
-        headers: new Headers({
-          Sunset: new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(),
-        }),
+        headers: {
+          get: (name: string) =>
+            name.toLowerCase() === 'sunset'
+              ? new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString()
+              : null,
+        },
       });
 
       await getDefaultBranchMock.requestFn();
@@ -430,9 +430,12 @@ describe('ApiClient', () => {
       mockFetchResponse({
         ok: true,
         json: jest.fn().mockResolvedValue(pushMock.responseBody),
-        headers: new Headers({
-          Sunset: new Date('2024-08-06T12:30:32.456Z').toISOString(),
-        }),
+        headers: {
+          get: (name: string) =>
+            name.toLowerCase() === 'sunset'
+              ? new Date('2024-08-06T12:30:32.456Z').toISOString()
+              : null,
+        },
       });
 
       await pushMock.requestFn();
diff --git a/packages/cli/src/cms/api/api-client.ts b/packages/cli/src/cms/api/api-client.ts
index 3cd0fa5679..a65627252d 100644
--- a/packages/cli/src/cms/api/api-client.ts
+++ b/packages/cli/src/cms/api/api-client.ts
@@ -1,11 +1,9 @@
 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 {
   ListRemotesResponse,
@@ -178,7 +176,7 @@ class RemotesApi {
     payload: PushPayload,
     files: { path: string; stream: ReadStream | Buffer }[]
   ): Promise<PushResponse> {
-    const formData = new FormData();
+    const formData = new globalThis.FormData();
 
     formData.append('remoteId', payload.remoteId);
     formData.append('commit[message]', payload.commit.message);
@@ -192,7 +190,11 @@ 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 =
+        file.stream instanceof Buffer
+          ? 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;
 };
+
+async function streamToBuffer(stream: ReadStream): Promise<Buffer> {
+  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..b336cfec24 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';
@@ -26,6 +25,7 @@ import { handlePush as handleCMSPush } from '../cms/commands/push';
 import type { Config, BundleOutputFormat, Region } from '@redocly/openapi-core';
 import type { CommandArgs } from '../wrapper';
 import type { VerifyConfigOptions } from '../types';
+import type { Readable } from 'node:stream';
 
 const DEFAULT_VERSION = 'latest';
 
@@ -62,6 +62,7 @@ export function commonPushHandler({
 
 export async function handlePush({ argv, config }: CommandArgs<PushOptions>): Promise<void> {
   const client = new RedoclyClient(config.region);
+  console.log('config.region', config.region);
   const isAuthorized = await client.isAuthorizedWithRedoclyByRegion();
   if (!isAuthorized) {
     try {
@@ -436,7 +437,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 +446,29 @@ function uploadFileToS3(url: string, filePathOrBuffer: string | Buffer) {
   const readStream =
     typeof filePathOrBuffer === 'string' ? fs.createReadStream(filePathOrBuffer) : filePathOrBuffer;
 
-  return fetch(url, {
+  const requestOptions = {
     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)]),
+  } as RequestInit;
+
+  const proxyAgent = getProxyAgent();
+  if (proxyAgent) {
+    // @ts-expect-error Node.js fetch has different type for agent
+    requestOptions.dispatcher = proxyAgent;
+  }
+
+  return fetch(url, requestOptions);
+}
+
+async function streamToBuffer(stream: Readable): Promise<Buffer> {
+  const chunks: Buffer[] = [];
+  for await (const chunk of stream) {
+    chunks.push(chunk);
+  }
+  return Buffer.concat(chunks);
 }
diff --git a/packages/cli/src/utils/fetch-with-timeout.ts b/packages/cli/src/utils/fetch-with-timeout.ts
index 5bfcba4d41..6ba710fe3a 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;
@@ -9,25 +7,31 @@ export type FetchWithTimeoutOptions = RequestInit & {
 };
 
 export default async (url: string, { timeout, ...options }: FetchWithTimeoutOptions = {}) => {
+  const requestOptions = {
+    ...options,
+  } as RequestInit;
+
+  // Only set agent if proxy is configured
+  const proxyAgent = getProxyAgent();
+  if (proxyAgent) {
+    // @ts-expect-error Node.js fetch has different type for agent
+    requestOptions.dispatcher = proxyAgent;
+  }
+
   if (!timeout) {
-    return nodeFetch(url, {
-      ...options,
-      agent: getProxyAgent(),
-    });
+    return fetch(url, requestOptions);
   }
 
-  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, {
+    ...requestOptions,
     signal: controller.signal,
-    ...options,
-    agent: getProxyAgent(),
   });
 
   clearTimeout(timeoutId);
-
   return res;
 };
diff --git a/packages/core/package.json b/packages/core/package.json
index f3cc99f103..f307753019 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -4,7 +4,7 @@
   "description": "",
   "main": "lib/index.js",
   "engines": {
-    "node": ">=14.19.0",
+    "node": ">=18.17.0",
     "npm": ">=7.0.0"
   },
   "engineStrict": true,
@@ -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..06973581c6 100644
--- a/packages/core/src/redocly/__tests__/redocly-client.test.ts
+++ b/packages/core/src/redocly/__tests__/redocly-client.test.ts
@@ -1,12 +1,12 @@
 import { setRedoclyDomain } from '../domains';
 import { RedoclyClient } from '../index';
 
-jest.mock('node-fetch', () => ({
-  default: jest.fn(() => ({
+global.fetch = jest.fn(() =>
+  Promise.resolve({
     ok: true,
     json: jest.fn().mockResolvedValue({}),
-  })),
-}));
+  } as any)
+);
 
 describe('RedoclyClient', () => {
   const REDOCLY_DOMAIN_US = 'redocly.com';
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 6b6d0476e3..0eba1a4e10 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';