From 759e6a42a1091357fb6cc6e3f2d243a6ed467b04 Mon Sep 17 00:00:00 2001
From: Alan Poulain <contact@alanpoulain.eu>
Date: Fri, 23 Sep 2022 17:48:24 +0200
Subject: [PATCH] feat: make the lib isomorphic

---
 .eslintrc.yml                |   2 +
 .github/workflows/CI-CD.yaml |  22 ++---
 .mocharc.yml                 |   1 +
 README.md                    |   7 ++
 lib/resolvers/http.js        | 113 ++++++++++---------------
 lib/util/url.js              |   8 +-
 package-lock.json            | 155 +++++++++++++++++++++++++++++++++++
 package.json                 |  13 ++-
 test/fixtures/polyfill.js    |  10 +++
 9 files changed, 246 insertions(+), 85 deletions(-)

diff --git a/.eslintrc.yml b/.eslintrc.yml
index c11ebb19..8ce416a4 100644
--- a/.eslintrc.yml
+++ b/.eslintrc.yml
@@ -7,3 +7,5 @@ extends: "@jsdevtools"
 env:
   node: true
   browser: true
+rules:
+  "@typescript-eslint/no-explicit-any": ["off"]
diff --git a/.github/workflows/CI-CD.yaml b/.github/workflows/CI-CD.yaml
index 2f712974..89d28538 100644
--- a/.github/workflows/CI-CD.yaml
+++ b/.github/workflows/CI-CD.yaml
@@ -8,6 +8,8 @@ name: CI-CD
 
 on:
   push:
+    branches:
+      - main
   pull_request:
   schedule:
     - cron: "0 0 1 * *"
@@ -25,16 +27,16 @@ jobs:
           - macos-latest
           - windows-latest
         node:
-          - 10
-          - 12
           - 14
+          - lts/*
+          - current
 
     steps:
       - name: Checkout source
-        uses: actions/checkout@v2
+        uses: actions/checkout@v3
 
       - name: Install Node ${{ matrix.node }}
-        uses: actions/setup-node@v1
+        uses: actions/setup-node@v3
         with:
           node-version: ${{ matrix.node }}
 
@@ -69,12 +71,12 @@ jobs:
 
     steps:
       - name: Checkout source
-        uses: actions/checkout@v2
+        uses: actions/checkout@v3
 
       - name: Install Node
-        uses: actions/setup-node@v1
+        uses: actions/setup-node@v3
         with:
-          node-version: 12
+          node-version: lts/*
 
       - name: Install dependencies
         run: npm ci
@@ -120,10 +122,10 @@ jobs:
       - node_tests
       - browser_tests
     steps:
-      - uses: actions/checkout@v2
-      - uses: actions/setup-node@v1
+      - uses: actions/checkout@v3
+      - uses: actions/setup-node@v3
         with:
-          node-version: 12
+          node-version: lts/*
 
       - name: Install dependencies
         run: npm ci
diff --git a/.mocharc.yml b/.mocharc.yml
index 163c4caa..157e6bc5 100644
--- a/.mocharc.yml
+++ b/.mocharc.yml
@@ -7,3 +7,4 @@ spec: test/specs/**/*.spec.js
 bail: true
 recursive: true
 async-only: true
+require: ./test/fixtures/polyfill.js
diff --git a/README.md b/README.md
index d090bc76..4e0f0bcc 100644
--- a/README.md
+++ b/README.md
@@ -111,6 +111,13 @@ When using a transpiler such as [Babel](https://babeljs.io/) or [TypeScript](htt
 import $RefParser from "@apidevtools/json-schema-ref-parser";
 ```
 
+If you are using Node.js < 18, you'll need a polyfill for `fetch`, like [node-fetch](https://github.com/node-fetch/node-fetch):
+```javascript
+import fetch from "node-fetch";
+
+globalThis.fetch = fetch;
+```
+
 
 
 Browser support
diff --git a/lib/resolvers/http.js b/lib/resolvers/http.js
index eaadd2de..7e5f10d8 100644
--- a/lib/resolvers/http.js
+++ b/lib/resolvers/http.js
@@ -1,7 +1,5 @@
 "use strict";
 
-const http = require("http");
-const https = require("https");
 const { ono } = require("@jsdevtools/ono");
 const url = require("../util/url");
 const { ResolverError } = require("../util/errors");
@@ -70,12 +68,12 @@ module.exports = {
    * @param {object} file           - An object containing information about the referenced file
    * @param {string} file.url       - The full URL of the referenced file
    * @param {string} file.extension - The lowercased file extension (e.g. ".txt", ".html", etc.)
-   * @returns {Promise<Buffer>}
+   * @returns {Promise<string>}
    */
   read (file) {
     let u = url.parse(file.url);
 
-    if (process.browser && !u.protocol) {
+    if (typeof window !== "undefined" && !u.protocol) {
       // Use the protocol of the current page
       u.protocol = url.parse(location.href).protocol;
     }
@@ -91,42 +89,40 @@ module.exports = {
  * @param {object} httpOptions  - The `options.resolve.http` object
  * @param {number} [redirects]  - The redirect URLs that have already been followed
  *
- * @returns {Promise<Buffer>}
+ * @returns {Promise<string>}
  * The promise resolves with the raw downloaded data, or rejects if there is an HTTP error.
  */
 function download (u, httpOptions, redirects) {
-  return new Promise(((resolve, reject) => {
-    u = url.parse(u);
-    redirects = redirects || [];
-    redirects.push(u.href);
-
-    get(u, httpOptions)
-      .then((res) => {
-        if (res.statusCode >= 400) {
-          throw ono({ status: res.statusCode }, `HTTP ERROR ${res.statusCode}`);
+  u = url.parse(u);
+  redirects = redirects || [];
+  redirects.push(u.href);
+
+  return get(u, httpOptions)
+    .then((res) => {
+      if (res.statusCode >= 400) {
+        throw ono({ status: res.statusCode }, `HTTP ERROR ${res.statusCode}`);
+      }
+      else if (res.statusCode >= 300) {
+        if (redirects.length > httpOptions.redirects) {
+          throw new ResolverError(ono({ status: res.statusCode },
+            `Error downloading ${redirects[0]}. \nToo many redirects: \n  ${redirects.join(" \n  ")}`));
         }
-        else if (res.statusCode >= 300) {
-          if (redirects.length > httpOptions.redirects) {
-            reject(new ResolverError(ono({ status: res.statusCode },
-              `Error downloading ${redirects[0]}. \nToo many redirects: \n  ${redirects.join(" \n  ")}`)));
-          }
-          else if (!res.headers.location) {
-            throw ono({ status: res.statusCode }, `HTTP ${res.statusCode} redirect with no location header`);
-          }
-          else {
-            // console.log('HTTP %d redirect %s -> %s', res.statusCode, u.href, res.headers.location);
-            let redirectTo = url.resolve(u, res.headers.location);
-            download(redirectTo, httpOptions, redirects).then(resolve, reject);
-          }
+        else if (!res.headers.location) {
+          throw ono({ status: res.statusCode }, `HTTP ${res.statusCode} redirect with no location header`);
         }
         else {
-          resolve(res.body || Buffer.alloc(0));
+          // console.log('HTTP %d redirect %s -> %s', res.statusCode, u.href, res.headers.location);
+          let redirectTo = url.resolve(u, res.headers.location);
+          return download(redirectTo, httpOptions, redirects);
         }
-      })
-      .catch((err) => {
-        reject(new ResolverError(ono(err, `Error downloading ${u.href}`), u.href));
-      });
-  }));
+      }
+      else {
+        return res.text();
+      }
+    })
+    .catch((err) => {
+      throw new ResolverError(ono(err, `Error downloading ${u.href}`), u.href);
+    });
 }
 
 /**
@@ -139,42 +135,23 @@ function download (u, httpOptions, redirects) {
  * The promise resolves with the HTTP Response object.
  */
 function get (u, httpOptions) {
-  return new Promise(((resolve, reject) => {
-    // console.log('GET', u.href);
-
-    let protocol = u.protocol === "https:" ? https : http;
-    let req = protocol.get({
-      hostname: u.hostname,
-      port: u.port,
-      path: u.path,
-      auth: u.auth,
-      protocol: u.protocol,
-      headers: httpOptions.headers || {},
-      withCredentials: httpOptions.withCredentials
-    });
+  let controller;
+  let timeoutId;
+  if (httpOptions.timeout) {
+    controller = new AbortController();
+    timeoutId = setTimeout(() => controller.abort(), httpOptions.timeout);
+  }
 
-    if (typeof req.setTimeout === "function") {
-      req.setTimeout(httpOptions.timeout);
+  return fetch(u, {
+    method: "GET",
+    headers: httpOptions.headers || {},
+    credentials: httpOptions.withCredentials ? "include" : "same-origin",
+    signal: controller ? controller.signal : null,
+  }).then(response => {
+    if (timeoutId) {
+      clearTimeout(timeoutId);
     }
 
-    req.on("timeout", () => {
-      req.abort();
-    });
-
-    req.on("error", reject);
-
-    req.once("response", (res) => {
-      res.body = Buffer.alloc(0);
-
-      res.on("data", (data) => {
-        res.body = Buffer.concat([res.body, Buffer.from(data)]);
-      });
-
-      res.on("error", reject);
-
-      res.on("end", () => {
-        resolve(res);
-      });
-    });
-  }));
+    return response;
+  });
 }
diff --git a/lib/util/url.js b/lib/util/url.js
index 210be5f5..a8bfbcd8 100644
--- a/lib/util/url.js
+++ b/lib/util/url.js
@@ -1,6 +1,6 @@
 "use strict";
 
-let isWindows = /^win/.test(process.platform),
+let isWindows = /^win/.test(globalThis.process?.platform),
     forwardSlashPattern = /\//g,
     protocolPattern = /^(\w{2,}):\/\//i,
     url = module.exports,
@@ -22,7 +22,7 @@ let urlDecodePatterns = [
   /\%40/g, "@"
 ];
 
-exports.parse = require("url").parse;
+exports.parse = (u) => new URL(u);
 
 /**
  * Returns resolved target URL relative to a base URL in a manner similar to that of a Web browser resolving an anchor tag HREF.
@@ -45,7 +45,7 @@ exports.resolve = function resolve (from, to) {
  * @returns {string}
  */
 exports.cwd = function cwd () {
-  if (process.browser) {
+  if (typeof window !== "undefined") {
     return location.href;
   }
 
@@ -144,7 +144,7 @@ exports.isHttp = function isHttp (path) {
   }
   else if (protocol === undefined) {
     // There is no protocol.  If we're running in a browser, then assume it's HTTP.
-    return process.browser;
+    return typeof window !== "undefined";
   }
   else {
     // It's some other protocol, such as "ftp://", "mongodb://", etc.
diff --git a/package-lock.json b/package-lock.json
index 775a8975..cc3019f1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -27,11 +27,16 @@
         "karma": "^5.0.2",
         "karma-cli": "^2.0.0",
         "mocha": "^8.2.1",
+        "node-abort-controller": "^3.0.1",
+        "node-fetch": "^3.2.10",
         "npm-check": "^5.9.0",
         "nyc": "^15.0.1",
         "semantic-release-plugin-update-version-in-files": "^1.1.0",
         "shx": "^0.3.2",
         "typescript": "^4.0.5"
+      },
+      "engines": {
+        "node": ">= 14"
       }
     },
     "node_modules/@amanda-mitchell/semantic-release-npm-multiple": {
@@ -4451,6 +4456,15 @@
       "integrity": "sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk=",
       "dev": true
     },
+    "node_modules/data-uri-to-buffer": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.0.tgz",
+      "integrity": "sha512-Vr3mLBA8qWmcuschSLAOogKgQ/Jwxulv3RNE4FXnYWRGujzrRWQI4m12fQqRkwX06C0KanhLr4hK+GydchZsaA==",
+      "dev": true,
+      "engines": {
+        "node": ">= 12"
+      }
+    },
     "node_modules/date-format": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/date-format/-/date-format-3.0.0.tgz",
@@ -6349,6 +6363,29 @@
         "pend": "~1.2.0"
       }
     },
+    "node_modules/fetch-blob": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
+      "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/jimmywarting"
+        },
+        {
+          "type": "paypal",
+          "url": "https://paypal.me/jimmywarting"
+        }
+      ],
+      "dependencies": {
+        "node-domexception": "^1.0.0",
+        "web-streams-polyfill": "^3.0.3"
+      },
+      "engines": {
+        "node": "^12.20 || >= 14.13"
+      }
+    },
     "node_modules/figgy-pudding": {
       "version": "3.5.2",
       "resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.2.tgz",
@@ -6664,6 +6701,18 @@
         "node": ">= 6"
       }
     },
+    "node_modules/formdata-polyfill": {
+      "version": "4.0.10",
+      "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
+      "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
+      "dev": true,
+      "dependencies": {
+        "fetch-blob": "^3.1.2"
+      },
+      "engines": {
+        "node": ">=12.20.0"
+      }
+    },
     "node_modules/fragment-cache": {
       "version": "0.2.1",
       "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz",
@@ -10066,6 +10115,31 @@
         "tslib": "^1.10.0"
       }
     },
+    "node_modules/node-abort-controller": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.0.1.tgz",
+      "integrity": "sha512-/ujIVxthRs+7q6hsdjHMaj8hRG9NuWmwrz+JdRwZ14jdFoKSkm+vDsCbF9PLpnSqjaWQJuTmVtcWHNLr+vrOFw==",
+      "dev": true
+    },
+    "node_modules/node-domexception": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
+      "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/jimmywarting"
+        },
+        {
+          "type": "github",
+          "url": "https://paypal.me/jimmywarting"
+        }
+      ],
+      "engines": {
+        "node": ">=10.5.0"
+      }
+    },
     "node_modules/node-emoji": {
       "version": "1.10.0",
       "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.10.0.tgz",
@@ -10075,6 +10149,24 @@
         "lodash.toarray": "^4.4.0"
       }
     },
+    "node_modules/node-fetch": {
+      "version": "3.2.10",
+      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.2.10.tgz",
+      "integrity": "sha512-MhuzNwdURnZ1Cp4XTazr69K0BTizsBroX7Zx3UgDSVcZYKF/6p0CBe4EUb/hLqmzVhl0UpYfgRljQ4yxE+iCxA==",
+      "dev": true,
+      "dependencies": {
+        "data-uri-to-buffer": "^4.0.0",
+        "fetch-blob": "^3.1.4",
+        "formdata-polyfill": "^4.0.10"
+      },
+      "engines": {
+        "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/node-fetch"
+      }
+    },
     "node_modules/node-libs-browser": {
       "version": "2.2.1",
       "resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.2.1.tgz",
@@ -19200,6 +19292,15 @@
         "node": ">=0.10"
       }
     },
+    "node_modules/web-streams-polyfill": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz",
+      "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==",
+      "dev": true,
+      "engines": {
+        "node": ">= 8"
+      }
+    },
     "node_modules/webdriver": {
       "version": "6.3.0",
       "resolved": "https://registry.npmjs.org/webdriver/-/webdriver-6.3.0.tgz",
@@ -23765,6 +23866,12 @@
       "integrity": "sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk=",
       "dev": true
     },
+    "data-uri-to-buffer": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.0.tgz",
+      "integrity": "sha512-Vr3mLBA8qWmcuschSLAOogKgQ/Jwxulv3RNE4FXnYWRGujzrRWQI4m12fQqRkwX06C0KanhLr4hK+GydchZsaA==",
+      "dev": true
+    },
     "date-format": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/date-format/-/date-format-3.0.0.tgz",
@@ -25337,6 +25444,16 @@
         "pend": "~1.2.0"
       }
     },
+    "fetch-blob": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
+      "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
+      "dev": true,
+      "requires": {
+        "node-domexception": "^1.0.0",
+        "web-streams-polyfill": "^3.0.3"
+      }
+    },
     "figgy-pudding": {
       "version": "3.5.2",
       "resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.2.tgz",
@@ -25591,6 +25708,15 @@
         "mime-types": "^2.1.12"
       }
     },
+    "formdata-polyfill": {
+      "version": "4.0.10",
+      "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
+      "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
+      "dev": true,
+      "requires": {
+        "fetch-blob": "^3.1.2"
+      }
+    },
     "fragment-cache": {
       "version": "0.2.1",
       "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz",
@@ -28360,6 +28486,18 @@
         "tslib": "^1.10.0"
       }
     },
+    "node-abort-controller": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.0.1.tgz",
+      "integrity": "sha512-/ujIVxthRs+7q6hsdjHMaj8hRG9NuWmwrz+JdRwZ14jdFoKSkm+vDsCbF9PLpnSqjaWQJuTmVtcWHNLr+vrOFw==",
+      "dev": true
+    },
+    "node-domexception": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
+      "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
+      "dev": true
+    },
     "node-emoji": {
       "version": "1.10.0",
       "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.10.0.tgz",
@@ -28369,6 +28507,17 @@
         "lodash.toarray": "^4.4.0"
       }
     },
+    "node-fetch": {
+      "version": "3.2.10",
+      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.2.10.tgz",
+      "integrity": "sha512-MhuzNwdURnZ1Cp4XTazr69K0BTizsBroX7Zx3UgDSVcZYKF/6p0CBe4EUb/hLqmzVhl0UpYfgRljQ4yxE+iCxA==",
+      "dev": true,
+      "requires": {
+        "data-uri-to-buffer": "^4.0.0",
+        "fetch-blob": "^3.1.4",
+        "formdata-polyfill": "^4.0.10"
+      }
+    },
     "node-libs-browser": {
       "version": "2.2.1",
       "resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.2.1.tgz",
@@ -35624,6 +35773,12 @@
         }
       }
     },
+    "web-streams-polyfill": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz",
+      "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==",
+      "dev": true
+    },
     "webdriver": {
       "version": "6.3.0",
       "resolved": "https://registry.npmjs.org/webdriver/-/webdriver-6.3.0.tgz",
diff --git a/package.json b/package.json
index 4cea85a6..716e811a 100644
--- a/package.json
+++ b/package.json
@@ -37,6 +37,9 @@
   "browser": {
     "fs": false
   },
+  "engines": {
+    "node": ">= 14"
+  },
   "files": [
     "lib"
   ],
@@ -60,12 +63,14 @@
     "@jsdevtools/host-environment": "^2.1.2",
     "@jsdevtools/karma-config": "^3.1.7",
     "@types/node": "^14.14.21",
-    "chai-subset": "^1.6.0",
     "chai": "^4.2.0",
+    "chai-subset": "^1.6.0",
     "eslint": "^7.18.0",
-    "karma-cli": "^2.0.0",
     "karma": "^5.0.2",
+    "karma-cli": "^2.0.0",
     "mocha": "^8.2.1",
+    "node-abort-controller": "^3.0.1",
+    "node-fetch": "^3.2.10",
     "npm-check": "^5.9.0",
     "nyc": "^15.0.1",
     "semantic-release-plugin-update-version-in-files": "^1.1.0",
@@ -79,7 +84,9 @@
     "js-yaml": "^4.1.0"
   },
   "release": {
-    "branches": ["main"],
+    "branches": [
+      "main"
+    ],
     "plugins": [
       "@semantic-release/commit-analyzer",
       "@semantic-release/release-notes-generator",
diff --git a/test/fixtures/polyfill.js b/test/fixtures/polyfill.js
index 7add5abd..5c126415 100644
--- a/test/fixtures/polyfill.js
+++ b/test/fixtures/polyfill.js
@@ -8,3 +8,13 @@ const { host } = require("@jsdevtools/host-environment");
 if (host.browser.IE) {
   require("@babel/polyfill");
 }
+
+import("node-fetch").then(({ default: fetch }) => {
+  if (!globalThis.fetch) {
+    globalThis.fetch = fetch;
+  }
+});
+
+if (!globalThis.AbortController) {
+  globalThis.AbortController = require("node-abort-controller").AbortController;
+}