Repository: Redocly/redocly-cli
Pull request #1848 (2025-01-17 10:25:34)

feat: migrate from node-fetch to native fetch
## What/Why/How?

- bump required node version
- migrate from node-fetch to fetch
- fix punycode Deprecation warning message in Spot but not in CLI itself due to its inner dependencies

## Reference

## Testing

- Monorepo run => https://github.com/Redocly/redocly/pull/12932
- push to workflows
- push to github

## Screenshots (optional)

## Check yourself

- [x] Code changed? - Tested with redoc/reference-docs/workflows (internal)
- [x] All new/updated code is covered with tests
- [ ] New package installed? - Tested in different environments (browser/node)

## Security

- [x] Security impact of change has been considered
- [x] Code follows company security practices and guidelines


changeset-bot[bot]: ###  🦋  Changeset detected

Latest commit: 24ddb6de5ea26220c884ac64289864d37824062d

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 2 packages</summary>

  | Name                  | Type  |
| --------------------- | ----- |
| @redocly/openapi-core | Minor |
| @redocly/cli          | Minor |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/Redocly/redocly-cli/new/feat/remove-node-fetch-from-core?filename=.changeset/khaki-ants-hear.md&value=---%0A%22%40redocly%2Fcli%22%3A%20patch%0A%22%40redocly%2Fopenapi-core%22%3A%20patch%0A---%0A%0Afeat%3A%20migrate%20from%20node-fetch%20to%20native%20fetch%0A)


github-actions[bot]: | Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|:---|---:|---:|---:|---:|
| `redocly lint packages/core/src/benchmark/benches/rebilly.yaml` | 976.3 ± 17.5 | 960.4 | 1013.0 | 1.01 ± 0.02 |
| `redocly-next lint packages/core/src/benchmark/benches/rebilly.yaml` | 969.9 ± 13.7 | 952.3 | 992.8 | 1.00 |

<!-- thollander/actions-comment-pull-request "latest-vs-next-comparison" -->
github-actions[bot]: <!-- jest coverage report action for options with hash 3339ae53a38c44382c0fb4491bdb282d -->

## Coverage report 



| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | Category   | Percentage | Covered / Total |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :--------- | :--------- | :-------------: |
|                                                                           🟡                                                                           | Statements | 78.59%     |    5055/6432    |
|                                                                           🟡                                                                           | Branches   | 67.24%     |    2059/3062    |
|                                                                           🟡                                                                           | Functions  | 73.09%     |     834/1141    |
|                                                                           🟡                                                                           | Lines      | 78.88%     |    4769/6046    |


<details><summary>Show files with reduced coverage 🔻</summary>
<br/>

| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | File                                                                   | Statements                                                     | Branches                                                      | Functions                                                      | Lines                                                          |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------- | :------------------------------------------------------------- | :------------------------------------------------------------ | :------------------------------------------------------------- | :------------------------------------------------------------- |
|                                                                           🟡                                                                           | core/src/utils.ts                                                      | 78.7%                                                          | 67.03%                                                        | 76.47%                                                         | 79.47%                                                         |
|                                                                           🟡                                                                           | <div title="cli/src/commands/push.ts">`...` / push.ts</div>            | 74.01%                                                         | <div title="Base coverage is: 70.41%">69.31% (-1.1% 🔻)</div> | <div title="Base coverage is: 54.55%">56.52% (+1.98% 🔼)</div> | 76.33%                                                         |
|                                                                           🟡                                                                           | <div title="cli/src/cms/api/api-client.ts">`...` / api-client.ts</div> | <div title="Base coverage is: 81.82%">78.64% (-3.18% 🔻)</div> | 65.79%                                                        | <div title="Base coverage is: 86.67%">81.25% (-5.42% 🔻)</div> | <div title="Base coverage is: 81.44%">78.22% (-3.23% 🔻)</div> |

</details>

## Test suite run success
833 tests passing in 120 suites.

<p align="right">Report generated by <a href="https://github.com/ArtiomTr/jest-coverage-report-action">🧪jest coverage report action</a> from 24ddb6de5ea26220c884ac64289864d37824062d</p>

DmitryAnansky: > @DmitryAnansky if we drop NodeJS prior to v16, we can remove the smoke tests for those [here](https://github.com/Redocly/redocly-cli/blob/main/.github/workflows/smoke.yaml) and [here](https://github.com/Redocly/redocly-cli/blob/main/.github/workflows/smoke-plugins.yaml).

sure, it is also strange that they are passing :) with the updated package.json.
tatomyr: > strange that they are passing

The smokes don't cover anything specific to fetching data, only basic operations in different environment.
DmitryAnansky: > @DmitryAnansky could you also update the README with the new required versions of Node/NPM? Also, does this resolve #1332 ? If yes, please mention that in the description.

ReadMe is updated now,
If this change applied, I will test if the issue fixed and update the issue.

Patch:
diff --git a/.changeset/warm-tips-sit.md b/.changeset/warm-tips-sit.md
new file mode 100644
index 00000000..1e7fbd5c
--- /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 b70f1032..f4fc05fd 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/CONTRIBUTING.md b/CONTRIBUTING.md
index f221052f..bf97767b 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -36,7 +36,7 @@ Before submitting a pull request, please make sure the following is done:
 
 ## Development setup
 
-[Node.js](http://nodejs.org) at v14.19.0+ and NPM v7.0.0+ are required.
+[Node.js](http://nodejs.org) at v18.17.0+ and NPM v10.8.2+ are required.
 
 After forking the repo, run:
 
diff --git a/README.md b/README.md
index 28f06247..0fdf6fa6 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,7 @@ Then you can use it as `redocly [command] [options]`, for example:
 redocly lint path-to-root-file.yaml
 ```
 
-The minimum required versions of Node.js and NPM are 14.19.0 and 7.0.0 respectively.
+The minimum required versions of Node.js and NPM are 18.17.0 and 10.8.2 respectively.
 
 ### Docker
 
diff --git a/package-lock.json b/package-lock.json
index 2f854040..caa40fda 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 be58d171..b2593916 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 3beb5253..5327d4d6 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/cms/api/api-client.ts b/packages/cli/src/cms/api/api-client.ts
index 3cd0fa56..4578ae28 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<PushResponse> {
-    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<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 3a6255bc..bfb92866 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 5bfcba4d..fe538cdc 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 f3cc99f1..f3871d67 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/registry-api.ts b/packages/core/src/redocly/registry-api.ts
index ff6abafd..c3686a51 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 16d5940b..0d4bd2a1 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';


Test patch:
diff --git a/packages/cli/src/__tests__/commands/push-region.test.ts b/packages/cli/src/__tests__/commands/push-region.test.ts
index a0e4bb18..a3b5839e 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<typeof promptClientToken>;
 
 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 2fe8c36d..bbea0dd9 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 20c5e844..95e0aa50 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 e1f85779..709fad6e 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 77d4e5a3..39e4f853 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<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,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/core/src/redocly/__tests__/redocly-client.test.ts b/packages/core/src/redocly/__tests__/redocly-client.test.ts
index 0e19752d..28a94a8f 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('');