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; +}