diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..c838486 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,92 @@ +name: Test Suite + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + build: + name: 'Run Tests' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + name: Checkout Code + + - name: List Directory Contents (for Troubleshooting) + run: | + pwd + ls -l + + - uses: actions/setup-node@v1 + name: Setup Node.js + with: + node-version: '14' + + - uses: actions/cache@v2 + name: Establish npm Cache + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - uses: actions/cache@v2 + name: Establish Docker Cache + id: cache + with: + path: docker-cache + key: ${{ runner.os }}-docker-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-docker- + + - name: Load cached Docker layers + run: | + if [ -d "docker-cache" ]; then + cat docker-cache/x* > my-image.tar + docker load < my-image.tar + rm -rf docker-cache + fi + + - name: Download Dev Tooling + id: setup + run: | + echo ${{ secrets.GH_DOCKER_TOKEN }} | docker login https://docker.pkg.github.com -u ${{ secrets.GH_DOCKER_USER }} --password-stdin + base=$(curl -L -s 'https://registry.hub.docker.com/v2/repositories/author/dev-base/tags?page_size=1'|jq '."results"[]["name"]') + base=$(sed -e 's/^"//' -e 's/"$//' <<<"$base") + echo Retrieving author/dev/dev-base:$base + docker pull author/dev-base:$base + # docker pull docker.pkg.github.com/author/dev/dev-base:$base + + deno=$(curl -L -s 'https://registry.hub.docker.com/v2/repositories/author/dev-deno/tags?page_size=1'|jq '."results"[]["name"]') + deno=$(sed -e 's/^"//' -e 's/"$//' <<<"$deno") + echo Retrieving author/dev/dev-deno:$deno + # docker pull docker.pkg.github.com/author/dev/dev-deno:$deno + docker pull author/dev-deno:$deno + + browser=$(curl -L -s 'https://registry.hub.docker.com/v2/repositories/author/dev-browser/tags?page_size=1'|jq '."results"[]["name"]') + browser=$(sed -e 's/^"//' -e 's/"$//' <<<"$browser") + echo Retrieving author/dev/dev-browser:$browser + # docker pull docker.pkg.github.com/author/dev/dev-browser:$browser + docker pull author/dev-browser:$browser + + node=$(curl -L -s 'https://registry.hub.docker.com/v2/repositories/author/dev-node/tags?page_size=1'|jq '."results"[]["name"]') + node=$(sed -e 's/^"//' -e 's/"$//' <<<"$node") + echo Retrieving author/dev/dev-node:$node + # docker pull docker.pkg.github.com/author/dev/dev-node:$node + docker pull author/dev-node:$node + + version=$(npm show @author.io/dev version) + echo $version + npm i -g @author.io/dev@$version + dev -v + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Test + if: success() + run: | + npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a2b6c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +*.log +*.old +.* +!.github +!.*ignore +!.*.yml +!.*rc +!.npmrc +env.json +/**/env.json +node_modules diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..871d6cf --- /dev/null +++ b/.npmignore @@ -0,0 +1,6 @@ +build +test +examples +.* +*.log +*.md diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..6535491 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2020 Corey Butler + + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d6b3d88 --- /dev/null +++ b/README.md @@ -0,0 +1,187 @@ +# Network NGN Plugin + +The NGN network plugin provides two major building blocks: + +1. HTTP `Client` +1. HTTP `Resource` + +The following features are also included: + +1. `URL` +1. `Fetch` +1. `INTERFACES` +1. `HOSTNAME` + +## HTTP Client + +The HTTP client provides an intuitive way to execute HTTP requests. All major HTTP methods are supported (`GET`, `POST`, `PUT`, `DELETE`, `HEAD`, `OPTIONS`, & `TRACE`). Two additional "helper" methods exist for accessing JSON data: `JSON` and `JSONP` (only used in browser runtimes). + +```javascript +import { Client } from '@ngnjs/net' + +const client = new Client() + +client.GET('https://domain.com').then(response => console.log(response.body)).catch(console.error) + +client.GET('https://domain.com', function (response) { + console.log(response) +}) + +client.JSON('https://domain.com/data.json', data => { + console.log(data) +}) +``` + +This client is intentionally simple. It behaves more like an API tool, similar to Postman. Issuing a request will return a response. This differs from some request libraries, which throw errors for certain HTTP status codes, such as `404`. These are _valid_ responses. The client treats them as such. The client will only throw an error when a code problem occurs. Developers are free to handle network responses however they see fit. + +The client natively supports headers, query strings/parameters, promises, and callbacks. This differs from other libraries, which rely on dependencies. The native capability allows this client to run consistently across runtimes, such as browsers, Node.js, and Deno. + +Some runtimes do not support all of the features developers are used to in the browser. For example, the browser Fetch API supports [caching](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy), CORS mode, [referral policies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy), [subresource integrity](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity), and [abort controllers](https://developer.mozilla.org/en-US/docs/Web/API/AbortController). Node.js is does not natively support most of these, while Deno is missing a few. These features have been stubbed in the client. Using them will not break an application, but they also won't do anything by default. If these features are important in your application, use the `@ngnjs/libnet-node` library, which polyfills these features for Node.js. + +## HTTP Resource + +An HTTP resource is a special HTTP client. It applies common values to all requests. For example: + +```javascript +const API = new Resource({ + baseUrl: 'https://api.domain.com', + headers: { + 'x-version': 'v1' + }, + token: 'mytoken' +}) + +API.JSON('/endpoint').then(data => { ... }).catch(console.error) +``` + +In the example above, a request is made to `https://api.domain.com/endpoint`. Two headers are applied automatically: + +1. `x-version: v1` +1. `Authorization: Bearer mytoken`. + +Any option that can be applied to a Request can be applied to a resource (including query parameters and URL hashes). + +### Primary Purpose + +Resources make it easy to organize communications with remote services. They can be used to create custom API clients, including multiple clients per application. + +### Extended Configuration Options + +Resources also have a few special configuration attributes: + +- `nocache` - set this to `true` to avoid caching. This overrides the `Cache-Control` header. +- `unique` - this this to `true` to automatically append a unique query parameter to all requests (cache busting). +- `useragent` - Specify a custom user agent name. This can be helpful when interacting with API's which require an identifying user agent. +- `uniqueagent` - set this to `true` to generate a unique ID to append to the `useragent` name. This helps guarantee every request comes from a unique user agent name. +- `tokenRenewalNotice` - optionally set this to trigger a notification event before an auth token expires (see Auth Tokens). + +### Auth Tokens + +When interacting with API's, it is common for auth tokens to expire after a period of time. The `setToken` method will accept the expiration date/time, automatically removing the token when it expires. + +By default, a token expiration warning event (`token.pending.expiration`) is triggered 10 seconds before a token expires. The lead time can be modified using the `tokenRenewalNotice` configuration option. + +Token renewal notices were not designed to auto-renew auth tokens. They were designed to notify the application before a token is expired/removed. Applications can use this feature to renew tokens before they expire (or choose not to). + +## URL + +The `URL` class, often aliases as `Address` is an enhanced version of the [URL API](https://developer.mozilla.org/en-US/docs/Web/API/URL). + +### Query Parameters + +The `searchParams` feature of the URL API is still available, but sometimes it is just simpler to reference a query object (instead of a map-like object). For this purpose, a `query` attribute is available, providing a common key/value structure of all the query parameters (readable and writable). + +The `querystring` attribute also provides a convenient way to read/write the full query string. + +### Ports & Protocols + +In the process of manipulating URL's, it is common to need to change the port. This URL class automatically recognizes default ports for several known protocols (`http`, `https`, `ssh`, `ldap`, `sldap`, `ftp`, `ftps`, `sftp`). Custom default ports can also be specified using the `setDefaultProtocolPort()` method. When the protocol is changed, it does not update the port automatically, but the `update.protocol` event is triggered, allowing applications to determine if the port should be updated. + +Setting `port` to `'default'` will always use the default port of the specified protocol. The `resetPort()` method is a shortcut for doing this. + +### Cross Origin Features + +The `local` attribute determines whether a URL is local to the system or not. In browsers, this detects whether the URL's origin is the same as the page. In backend runtimes, this is compared to the system `hostname` (server name), as well as the local IP addresses and any other local interfaces. + +The `isSameOrigin()` method provides a quick and convenient way to determine if a different URL shares the same origin (i.e. not a cross-origin URL). This method optionally supports strict protocol matching. + +These features can help assess and minimize CORS errors. + +### URL Formatting & Output + +The URL class has two methods, `toString()` and `formatString`. + +`toString()` differs from the URL API. It accepts a configuration argument, allowing certain aspects of the URI to be ignored or forced. For example: + +```javascript +const myUrl = 'http://locahost' + +console.log(myUrl.toString({ port: true })) +// Outputs http://localhost:80 +``` + +The `formatString` accepts a template, which will be populated with the attributes of the URL. For example: + +```javascript +const myUrl = 'http://jdoe:secret@localhost/path.html' + +console.log(myUrl.formatString('{{username}} accessing {{path}}')) +// Outputs jdoe acessing path.html +``` + +## Additional Features + +The Client, Resource, and URL classes all implement an `NGN.EventEmitter`. This means they'll fire change events. + +## Known Issues + +### Fetch API: ReferrerPolicy & Cache + +Deno doesn't support fetch ReferrerPolicy & Cache. Deno is working on cache support, but will likely not implement ReferrerPolicy. Node.js doesn't support these either, but the `@ngnjs/libnet-node` plugin can polyfill such features. + +ReferrerPolicy is less likely to be necessary in non-browser environments, with the exception of a limited set of proxy and API applications. + +The ReferrerPolicy polyfill in `@ngnjs/libnet-node` currently uses the `os` module to identify the server hostname & IP address. This is used to help determine when a referrer is on the same host or not. The hostname feature does not yet exist in Deno, but is on the Deno roadmap. When support for this is available, a ReferrerPolicy polyfill will be made for Deno (if there is enough interest). + +-- + +Everything below is boilerplate from the plugin generation tool (can be removed when ready) +-- +A plugin module for NGN. + +## Usage + +### Via CDN (Browser/Deno) + +```javascript +import NGN from 'https://cdn.skypack.dev/ngn' +import Network from 'https://cdn.skypack.dev/@ngnjs/network' + +const feature = new Network() + +console.log(feature) +``` + +### Node + +`npm i ngn @ngnjs/network -S` + +```javascript +import NGN from 'ngn' +import Network from '@ngnjs/network' + +const feature = new Network() + +console.log(feature) +``` + +## Plugin Authoring Instructions (Remove these) + +This project template supports creation of NGN plugins. This template can be used directly, but was designed to be implemented with [NGND (NGN Dev) CLI tool](https"//github/com/ngnjs/cli). + +The template contains: + +1. Starter Source Code +1. Unit Test Boilerplate +1. CI/CD Runner +1. Autopublishing (triggered on new `package.json` versions in master branch) diff --git a/package.json b/package.json new file mode 100644 index 0000000..91418c0 --- /dev/null +++ b/package.json @@ -0,0 +1,97 @@ +{ + "name": "@ngnjs/net", + "version": "1.0.0-alpha", + "description": "A network communications plugin for NGN.", + "type": "module", + "author": "Corey Butler\n", + "private": false, + "license": "MIT", + "homepage": "https://github.com/ngnjs/net", + "repository": { + "type": "git", + "url": "https://github.com/ngnjs/net.git" + }, + "bugs": { + "url": "https://github.com/ngnjs/net/issues" + }, + "main": "src/index.js", + "peerDependencies": { + "ngn": "^2.0.0", + "@author.io/dev": "^1.0.15" + }, + "scripts": { + "test": "npm run test:node && npm run test:deno && npm run test:browser && npm run report:syntax && npm run report:size", + "start": "dev workspace", + "build": "dev build", + "test:node": "dev test -rt node tests/*.js", + "test:node:fetch": "dev test -rt node tests/*-fetch.js", + "test:request:node": "dev test -rt node tests/*-request.js", + "test:resource:node": "dev test -rt node tests/*-resource.js", + "test:client:node": "dev test -rt node tests/*-client.js", + "test:deno": "dev test -rt deno tests/*.js", + "test:request:deno": "dev test -rt deno tests/*-request.js", + "test:client:deno": "dev test -rt deno tests/*-client.js", + "test:resource:deno": "dev test -rt deno tests/*-resource.js", + "test:browser": "dev test -rt browser tests/*.js", + "test:request:browser": "dev test -rt browser tests/*-request.js", + "test:client:browser": "dev test -rt browser tests/*-client.js", + "test:resource:browser": "dev test -rt browser tests/*-resource.js", + "manually": "dev test -rt manual tests/*.js", + "report:syntax": "dev report syntax --pretty", + "report:size": "dev report size ./.dist/**/*.js ./.dist/**/*.js.map", + "report:compat": "dev report compatibility ./src/**/*.js", + "report:preview": "npm pack --dry-run && echo \"==============================\" && echo \"This report shows what will be published to the module registry. Pay attention to the tarball contents and assure no sensitive files will be published.\"", + "update": "npm update --save --save-dev --save-optional" + }, + "dev": { + "mode": "source", + "volume": [ + "/Users/cbutler/Workspace/OSS/js/@author.io/dev/image/app/test/TapParser.js:/utility/test/TapParser.js", + "/Users/cbutler/Workspace/OSS/js/@author.io/dev/image/runners/size.js:/utility/test/size.js", + "/Users/cbutler/Workspace/OSS/js/@butlerlogic/common-api/index.js:/node_modules/@butlerlogic/common-api/index.js", + "/Users/cbutler/Workspace/OSS/js/@author.io/dev/image/app/test/replace.js:/utility/test/replace.js", + "/Users/cbutler/Workspace/OSS/js/@author.io/dev/image/app/test/assets:/utility/test/assets", + "/Users/cbutler/Workspace/OSS/js/@author.io/dev/image/app/test/httpserver.js:/utility/test/httpserver.js", + "/Users/cbutler/Workspace/OSS/js/@author.io/dev/image/runners/chromium.js:/utility/test/run-browser.js", + "/Users/cbutler/Workspace/OSS/js/@author.io/dev/image/runners/manual.js:/utility/test/run-manual.js", + "/Users/cbutler/Workspace/OSS/js/coreybutler/tappedout:/node_modules/tappedout", + "/Users/cbutler/Workspace/OSS/js/@author.io/dev/image/app/test/utilities.js:/utility/test/utilities.js", + "/Users/cbutler/Workspace/OSS/js/@author.io/dev/image/runners/node.js:/utility/test/run-node.js", + "/Users/cbutler/Workspace/OSS/js/@author.io/dev/image/runners/size.js:/utility/test/size.js", + "/Users/cbutler/Workspace/OSS/js/@author.io/dev/image/app/build/lib/package.js:/utility/build/lib/package.js", + "/Users/cbutler/Workspace/OSS/js/@author.io/dev/image/app/build/rollup.config.js:/utility/build/rollup.config.js", + "/Users/cbutler/Workspace/OSS/js/@author.io/dev/image/app/test/utilities.js:/utility/test/utilities.js", + "../core/.dist/ngn:/node_modules/ngn", + "../core/src:/source/ngn", + "../plugin/.dist/plugin:/node_modules/@ngnjs/plugin", + "../plugin/src:/source/@ngnjs/plugin", + "../libdata/.dist/libdata:/node_modules/@ngnjs/libdata", + "../libdata/src:/source/@ngnjs/libdata", + "../libnet-node/.dist/libnet-node:/node_modules/@ngnjs/libnet-node", + "../libnet-node/src:/source/@ngnjs/libnet-node" + ], + "http_server": "./tests/assets/server.js", + "source": { + "buildoption": { + "preserveEntrySignatures": true + }, + "autoimport": [ + "import NGN from 'ngn'" + ], + "alias": { + "ngn": "/node_modules/ngn/index.js", + "@ngnjs/plugin": "/source/@ngnjs/plugin/index.js", + "@ngnjs/libdata": "/source/@ngnjs/libdata/index.js", + "@ngnjs/libnet-node": "/source/@ngnjs/libnet-node/index.js", + "@ngnjs/net": "/app/src/index.js" + } + } + }, + "standard": { + "globals": [ + "globalThis", + "window", + "global" + ] + } +} \ No newline at end of file diff --git a/src/Client.js b/src/Client.js new file mode 100644 index 0000000..836085c --- /dev/null +++ b/src/Client.js @@ -0,0 +1,324 @@ +import Reference from '@ngnjs/plugin' +import Address from './lib/URL.js' +import NgnRequest from './lib/Request.js' +import { HOSTNAME } from './lib/constants.js' +import { coalesceb } from '@ngnjs/libdata' + +const NGN = new Reference('^2.0.0').requires('EventEmitter', 'WARN') +const { WARN } = NGN + +/** + * @class HttpClient + * Represents an HTTP client capable of making requests to remote + * servers. This automatically configures and processes NGN net + * Request objects using the most common HTTP methods. + */ +export default class HttpClient extends NGN.EventEmitter { + constructor () { + super() + this.name = 'HTTP Client' + + Object.defineProperties(this, { + /** + * @method normalizeUrl + * Normalize a URL by removing extraneous characters, + * applying protocol, and resolving relative links. + * @param {string} URI + * The URI to normalize. + * @return {string} + * The normalized URL. + */ + normalizeUrl: NGN.privateconstant(url => (new Address(url)).toString({ username: true, password: true, urlencode: false })), + + parseRequestConfig: NGN.privateconstant((cfg = {}, method = 'GET') => { + cfg = typeof cfg === 'string' ? { url: cfg } : cfg + cfg.method = method + cfg.url = coalesceb(cfg.url, HOSTNAME) + return cfg + }), + + send: NGN.privateconstant((method, argv) => { + const args = argv ? Array.from(argv) : [] + const callback = typeof args[args.length - 1] === 'function' ? args.pop() : null + const request = new NgnRequest(this.parseRequestConfig(...args, method.toUpperCase())) + + // This is a no-op by default, unless the preflight method + // is overridden by an extension class. + this.preflight(request) + + return request.send(callback) + }) + }) + + // Helper aliases (undocumented) + this.alias('OPTIONS', this.options) + this.alias('HEAD', this.head) + this.alias('GET', this.get) + this.alias('POST', this.post) + this.alias('PUT', this.put) + this.alias('DELETE', this.delete) + this.alias('TRACE', this.trace) + this.alias('JSON', this.json) + this.alias('JSONP', this.jsonp) + + this.register('HttpClient', this) + } + + /** + * @property {Request} + * Returns an NGN network Request. + */ + get Request () { + return NgnRequest + } + + /** + * @method request + * Send a request. In most cases, it is easier to use one of the built-in + * request functions (#get, #post, #put, #delete, #json, etc). This method + * is available for creating custom requests. + * @param {Object} configuration + * Provide a NGN Request configuration. + * @param {Function} [callback] + * The callback to execute when the request is complete. Necessary + * when not using the returned Promise. + * @returns {Promise} + * A promise representing the network request. + */ + request (cfg = {}, callback) { + const method = coalesceb(cfg.method, 'GET') + delete cfg.method + return this.send(method, [cfg, callback]) + } + + /** + * @method options + * Issue a `OPTIONS` request. + * @param {string|object} url + * The URL to issue the request to, or a configuration object. + * The configuration object accepts all of the NGN Request + * configuration options (except method, which is defined automatically). + * @param {Function} callback + * A callback method to run when the request is complete. + * This receives the response object as the only argument. + * @returns {Promise} + * A promise representing the network request. + */ + options () { + return this.send('OPTIONS', arguments) + } + + /** + * @method head + * Issue a `HEAD` request. + * @param {string|object} url + * The URL to issue the request to, or a configuration object. + * The configuration object accepts all of the NGN Request + * configuration options (except method, which is defined automatically). + * @param {Function} callback + * A callback method to run when the request is complete. + * This receives the response object as the only argument. + * @returns {Promise} + * A promise representing the network request. + */ + head () { + return this.send('HEAD', arguments) + } + + /** + * @method get + * Issue a `GET` request. + * @param {string|object} url + * The URL to issue the request to. + * The configuration object accepts all of the NGN Request + * configuration options (except method, which is defined automatically). + * @param {Function} callback + * A callback method to run when the request is complete. + * This receives the response object as the only argument. + * @returns {Promise} + * A promise representing the network request. + */ + get () { + return this.send('GET', arguments) + } + + /** + * @method post + * Issue a `POST` request. + * @param {string|object} url + * The URL to issue the request to. + * The configuration object accepts all of the NGN Request + * configuration options (except method, which is defined automatically). + * @param {Function} callback + * A callback method to run when the request is complete. + * This receives the response object as the only argument. + * @returns {Promise} + * A promise representing the network request. + */ + post () { + return this.send('POST', arguments) + } + + /** + * @method put + * Issue a `PUT` request. + * @param {string|object} url + * The URL to issue the request to. + * The configuration object accepts all of the NGN Request + * configuration options (except method, which is defined automatically). + * @param {Function} callback + * A callback method to run when the request is complete. + * This receives the response object as the only argument. + * @returns {Promise} + * A promise representing the network request. + */ + put () { + return this.send('PUT', arguments) + } + + /** + * @method delete + * Issue a `DELETE` request. + * @param {string|object} url + * The URL to issue the request to. + * The configuration object accepts all of the NGN Request + * configuration options (except method, which is defined automatically). + * @param {Function} callback + * A callback method to run when the request is complete. + * This receives the response object as the only argument. + * @returns {Promise} + * A promise representing the network request. + */ + delete () { + return this.send('DELETE', arguments) + } + + /** + * @method trace + * Issue a `TRACE` request. This is a debugging method, which + * echoes input back to the user. It is a standard HTTP method, + * but considered a security risk by many practioners and may + * not be supported by remote hosts. + * @param {string|object} url + * The URL to issue the request to. + * The configuration object accepts all of the NGN Request + * configuration options (except method, which is defined automatically). + * @param {Function} callback + * A callback method to run when the request is complete. + * This receives the response object as the only argument. + * @returns {Promise} + * A promise representing the network request. + */ + trace () { + WARN('NGN.Request.method', 'An HTTP TRACE request was made.') + return this.send('TRACE', arguments) + } + + /** + * @method json + * This is a shortcut method for creating a `GET` request and + * auto-parsing the response into a JSON object. + * @param {string} url + * The URL to issue the request to. + * @param {Function} callback + * This receives a JSON response object from the server. + * @param {Error} callback.error + * If the request cannot be completed for any reason, this argument will be + * populated with the error. If the request is successful, this will be `null`. + * @param {Object} callback.data + * The JSON response from the remote URL. + * @returns {Promise} + * A promise representing the network request. + */ + json (url, callback) { + const request = new NgnRequest(url) + + request.setHeader('Accept', 'application/json, application/ld+json, application/vnd.api+json, */json, */*json;q=0.8') + + this.preflight(request) + + const response = request.send() + + if (callback) { + response.then(r => callback(null, r.JSON)).catch(callback) + } + + return new Promise((resolve, reject) => response.then(r => resolve(r.JSON)).catch(reject)) + } + + /** + * @method jsonp + * Execute a request via JSONP. JSONP is only available in browser + * environments, since it's operation is dependent on the existance of + * the DOM. However; this may work with some headless browsers. + * @param {string} url + * The URL of the JSONP endpoint. + * @param {function} [callback] + * Handles the response. Optional when using the returned promise. + * @param {Error} callback.error + * If an error occurred, this will be populated. If no error occurred, this will + * be null. + * @param {object|array} callback.response + * The response. + * @param {string} [callbackParameter=callback] + * Optionally specify an alternative callback parameter name. This will be + * appended to the URL query parameters when the request is made. + * For example: + * `https://domain.com?[callbackParameter]=generated_function_name` + * @returns {Promise} + * A promise representing the network request. + * @environment browser + */ + jsonp (url, callback, callbackParameter = 'callback') { + return new Promise((resolve, reject) => { + if (NGN.runtime !== 'browser') { + const err = new Error('JSONP is not available in non-browser runtimes.') + + if (callback) { + callback(err) + } else { + reject(err) + } + + return + } + + const fn = 'jsonp_callback_' + Math.round(100000 * Math.random()) + + window[fn] = data => { + delete window[fn] + document.querySelector('head').removeChild(script) + + if (callback) { + callback(null, data) + } + + resolve(data) + } + + const script = document.createElement('script') + script.src = `${url}${url.indexOf('?') >= 0 ? '&' : '?'}${callbackParameter}=${fn}` + script.addEventListener('error', e => { + delete window[fn] + const err = new Error('The JSONP request was blocked. This may be the result of an invalid URL, cross origin restrictions, or the remote server may not be responding.') + if (callback) { + callback(err) + resolve() + } else { + reject(err) + } + }) + + document.querySelector('head').appendChild(script) + }) + } + + /** + * @method preflight + * This is a no-op method that runs before a request is sent. + * This exists specicially to be overridden by class extensions. + * @param {Request} request + * The request to process. + */ + preflight (request) { } +} diff --git a/src/NOTES.md b/src/NOTES.md new file mode 100644 index 0000000..90896ec --- /dev/null +++ b/src/NOTES.md @@ -0,0 +1,228 @@ +TODO: Convert the normalizer to use the standard URL API (browser and Node will be a little different) +https://developer.mozilla.org/en-US/docs/Web/API/URL + +======================== + +# Request.js send method (Old Node) + +```javascript +/* node-only */ +import libhttp from 'http' +import libhttps from 'https' +import fs from 'fs' +import SRI from './polyfills/SRI.js' +import NodeReferralPolicy from './polyfills/ReferrerPolicy.js' +import NodeHttpCache from './polyfills/Cache.js' +import { Transport } from 'stream' +/* end-node-only */ +/* node-only */ +// Run request in Node-like environments +// Support local file system retrieval in node-like environments. +// This short-circuits the request and reads the file system instead. +if (this.protocol === 'file') { + if (!NGN.isFn(callback)) { + throw new Error('A callback is required when retrieving system files in a node-like environment.') + } + + const filepath = this.#uri.toString().replace('file://', '') + const response = { + status: fs.existsSync(filepath) ? 200 : 400 + } + + response.responseText = response.status === 200 ? fs.readFileSync(filepath).toString() : 'File does not exist or could not be found.' + + if (this.sri) { + const integrity = SRI.verify(this.sri, response.responseText) + if (!integrity.valid) { + return callback(new Error(integrity.reason)) + } + } + + return callback(response) +} + +const http = this.protocol === 'https' ? libhttps : libhttp + +// const agent = new http.Agent() +const reqOptions = { + hostname: this.hostname, + port: this.port, + method: this.method, + headers: this.#headers, + path: this.#uri.formatString('{{path}}{{querystring}}{{hash}}') +} + +const req = http.request(reqOptions, response => { + response.setEncoding('utf8') + + let resbody = '' + response.on('data', chunk => { resbody = resbody + chunk }) + + response.on('end', () => { + switch (response.statusCode) { + case 412: + case 304: + // Respond from cache (no modifications) + const res = this.#cache.get(request) + if (res) { + return callback(res) + } else { + return callback({ + headers: response.headers, + status: 500, + statusText: 'Internal Server Error', + responseText: 'Failed to retrieve cached response.' + }) + } + case 301: + case 302: + case 307: + case 308: + if (this.redirectAttempts > this.maxRedirects) { + this.redirectAttempts = 0 + + this.stopMonitor() + + return callback(this.#cache.put(req, { // eslint-disable-line standard/no-callback-literal + headers: response.headers, + status: 500, + statusText: 'Too many redirects', + responseText: 'Too many redirects' + }, this.#cacheMode)) + } + + if (response.headers.location === undefined) { + this.stopMonitor() + + return callback(this.#cache.put(req, { // eslint-disable-line standard/no-callback-literal + headers: response.headers, + status: 502, + statusText: 'Bad Gateway', + responseText: 'Bad Gateway' + }, this.#cacheMode, response)) + } + + this.redirectAttempts++ + this.url = response.headers.location + + return this.send(res => callback(this.#cache.put(req, res, this.#cacheMode, response))) + + default: + this.stopMonitor() + + if (this.sri) { + const integrity = SRI.verify(this.sri, resbody) + if (!integrity.valid) { + throw new Error(integrity.reason) + } + } + + return callback(this.#cache.put(req, { // eslint-disable-line standard/no-callback-literal + headers: response.headers, + status: response.statusCode, + statusText: coalesce(response.statusMessage, response.statusText), + responseText: resbody, + }, this.#cacheMode, response)) + } + }) +}) + +// Check the cache +let cached = this.#cache.get(req, this.#cacheMode) +if (cached) { + req.abort() // This prevents creating an orphan request object. + return callback(cached.response) +} + +req.on('error', (err) => { + this.stopMonitor() + + if (NGN.isFn(callback)) { + callback({ // eslint-disable-line standard/no-callback-literal + status: 400, + statusText: err.message, + responseText: err.message, + // responseXML: err.message, + // readyState: 0 + }) + } else { + throw err + } +}) + +this.startMonitor() + +if (body) { + req.write(body) +} + +this.#cache.capture(req, this.#cacheMode) + +req.setNoDelay(true) +req.end() +/* end-node-only */ +``` + +### Old Browser Code + +```javascript +// Execute the request + let result + fetch(this.#uri.toString(), init) + .then(response => { + result = { + ok: response.ok, + status: response.status, + statusText: response.statusText, + headers: response.headers, + redirected: response.redirected, + type: response.type, + url: response.url, + body, + responseText: '' + } + + switch (this.responseType) { + case 'arraybuffer': + return response.arrayBuffer() + case 'document': + case 'blob': + return response.blob() + // case 'json': + // return response.json() + } + + return response.text() + }) + .then(responseBody => { + switch (this.responseType) { + case 'document': + if (/^text\/.*/.test(responseBody.type)) { + responseBody.text() + .then(data => { + request.responseText = data + callback(request) + }) + .catch(callback) + return + } + case 'arraybuffer': + case 'blob': + request.body = new Blob(responseBody.slice(), { type: coalesce(result.headers['content-type']) }) + break + + default: + result.responseText = responseBody + } + + callback(result) + }) + .catch(e => { + if (e.name === 'AbortError') { + callback(new Error(`Timed out after ${this.timeout}ms.`)) + } else { + callback(e) + } + }) + /* end-browser-only */ +``` \ No newline at end of file diff --git a/src/Resource.js b/src/Resource.js new file mode 100644 index 0000000..334117c --- /dev/null +++ b/src/Resource.js @@ -0,0 +1,632 @@ +import Reference from '@ngnjs/plugin' +import Client from './Client.js' +import Address from './lib/URL.js' +import Request from './lib/Request.js' +import { HOSTNAME, HTTP_METHODS } from './lib/constants.js' +import { coalesce, coalesceb } from '@ngnjs/libdata' + +const NGN = new Reference('>=2.0.0').requires('WARN') +const { WARN } = NGN + +/** + * @class Resource + * Represents a remote web resource, such as a backend web server or + * an API server. This class inherits everything from the NGN net Client, extending + * it with customizable options for working with specific remote resources. + * + * This class was designed for use in applications where multiple requests + * are made to multiple backends. For example, a common single page application + * may make multiple requests for resources (media, templates, CSS, etc) + * as well as multiple requests to an API server. + * + * For example: + * + * ```js + * let server = new Resource({ + * credentials: { + * username: 'username', + * password: 'password' + * }, + * headers: { + * 'x-source': 'mydomain.com' + * } + * }) + * + * let API = new Resource({ + * credentials: { + * token: 'secret_token' + * }, + * headers: { + * 'user-agent': 'mobile' + * }, + * baseUrl: 'https://api.mydomain.com' + * }) + * + * server.get('./templates/home.html', (response) => { ... }) + * API.json('/user', (data) => { ... }) + * ``` + * + * Both `server` and `API` in the example above are instances of + * the Resource class. They each use different credentials to + * access the remote endpoint, using different global headers and + * a different base URL. + * + * This can be incredibly useful anytime a migration is required, + * such as running code in dev ==> staging ==> production or + * switching servers. It is also useful for creating connections + * to different remote services, creating custom API clients, + * and generally organizing/standardizing how an application connects + * to remote resources. + * @extends Client + */ +export default class Resource extends Client { + #baseUrl + #request + #user + #secret + #accessToken + #accessTokenTimer + #accessTokenExpiration + #accessTokenType + #accessTokenRenewalDuration = 0 + #accessTokenRenewalTimer + #nocache + #unique + #tlsonly + #useragent + #uniqueagent + + constructor (cfg = {}) { + super() + this.name = 'HTTP Resource' + + cfg = typeof cfg === 'string' ? { baseUrl: cfg } : cfg + + /** + * @cfg {string} [baseUrl=window.loction.origin] + * The root domain/base URL to apply to all requests to relative URL's. + * This was designed for uses where a backend API may be served on + * another domain (such as api.mydomain.com instead of www.mydomain.com). + * The root will only be applied to relative paths that do not begin + * with a protocol. For example, `./path/to/endpoint` **will** have + * the root applied (`{root}/path/to/endpoint`) whereas `https://domain.com/endpoint` + * will **not** have the root applied. + */ + this.#baseUrl = new Address(coalesce(cfg.baseURL, cfg.baseUrl, cfg.baseurl, globalThis.location ? globalThis.location.origin : `http://${HOSTNAME}/`)) + this.#request = new Request({ + url: this.#baseUrl.href, + headers: coalesceb(cfg.headers, {}), + username: cfg.username, + password: cfg.password, + accessToken: coalesceb(cfg.token, cfg.accessToken, cfg.accesstoken) + }) + + this.#secret = coalesceb(cfg.password) + + // Optionally enforce encrypted protocol + if (coalesce(cfg.httpsOnly, cfg.httpsonly, false)) { + this.#baseUrl.protocol = 'https' + this.#request.url = this.#baseUrl.href + this.#tlsonly = true + } + + // Apply query parameters + for (const [key, value] of Object.entries(coalesceb(cfg.query, {}))) { + this.#request.setQueryParameter(key, value, true) + } + + /** + * @cfg {object} headers + * Common headers (key/value) applied to all requests. + */ + + /** + * @cfg {string} username + * Username to be applied to all requests. + */ + + /** + * @cfg {string} password + * Password to be applied to all requests. + */ + + /** + * @cfg {string} accessToken + * Access token to be applied to all requests. + * Setting this overrides any existing username/password credentials. + */ + + /** + * @cfg {object} query + * Contains common query parameters to be applied to all requests. All values + * are automatically url-encoded. + */ + + /** + * @cfg {boolean} [httpsOnly=false] + * Set this to true to rewrite all URL's to use HTTPS. + */ + + /** + * @cfg {boolean} [nocache=false] + * This sets the `Cache-Control` header to `no-cache`. + * Servers are _supposed_ to honor these headers and serve non-cached + * content, but is not guaranteed. Some servers + * perform caching by matching on URL's. In this case, the #unique + * attribute can be set to `true` to apply unique characters to the URL. + */ + this.#nocache = coalesce(cfg.nocache, false) + + /** + * @cfg {boolean} [unique=false] + * Set this to `true` to add a unique URL parameter to all requests. + * This guarantees a unique request. This can be used for + * cache-busting, though setting #nocache to `true` is + * recommended for these purposes. Only use this feature + * for cache busting when the server does not honor `Cache-Control` + * headers. + */ + this.#unique = coalesce(cfg.unique, false) + + /** + * @cfg {string} [useragent] + * Specify a custom user agent to identify each request made + * with the resource. + * @nodeonly + */ + this.#useragent = coalesce(cfg.useragent) + + /** + * @cfg {boolean} [uniqueagent=false] + * Guarantees each user agent is unique by appending a unique ID to the + * user agent string. + * @nodeonly + */ + this.#uniqueagent = coalesce(cfg.uniqueagent, false) + + /** + * @cfg {numeric} [tokenRenewalNotice=10000] + * Trigger an event, `token.pending.expiration`, before it expires. + * The tokenRenewalNotice is the lead time, i.e. the number of + * milliseconds before the token actually expires. Use this + * feature to be alerted when a token needs to be renewed. + * Must be an integer greater than 0 (anything else prevents + * this feature from triggering the event). Defaults to 10 seconds + * before token expiration. + */ + this.#accessTokenRenewalDuration = Math.floor(coalesce(cfg.tokenRenewalNotice, cfg.tokenrenewalNotice, cfg.tokenRenewalnotice, cfg.tokenrenewalnotice, 0)) + + this.on('token.expired', () => { + clearTimeout(this.#accessTokenTimer) + clearTimeout(this.#accessTokenRenewalTimer) + this.removeHeader('Authorization') + WARN(`${this.name} HTTP client access token expired.`) + }) + + this.#request.relay('*', this) + + this.register('HttpResource', this) + } + + get baseUrl () { + return this.#request.href + } + + get username () { + return this.#request.username + } + + set username (value) { + this.#request.username = value + } + + set password (value) { + this.#request.password = value + } + + /** + * @property {Headers} headers + * Represents the current common headers. + * + * This is commonly used when a remote resource requires a specific + * header on every call. + * + * **Example** + * + * ```js + * let resource = new Resource(...) + * + * resource.headers = { + * 'user-agent': 'my custom agent name' + * } + * ``` + */ + get headers () { + return this.#request.headers + } + + set headers (value) { + this.#request.headers = value + } + + /** + * @property {string} token + * The token value. Setting this value is the same as executing + * `setToken(token, 'bearer', null)` (a bearer token with no + * expiration). + * @writeonly + */ + set accessToken (value) { + this.setAccessToken(value) + } + + /** + * @param {string} [token=null] + * The token value. + * @param {string} [type=bearer] + * The type of token. This is passed to the `Authorization` HTTP header. + * The most common type of token is the `bearer` token. + * @param {date|number} [expiration] + * Specify a date/time or the number of milliseconds until the + * token expires/invalidates. Setting this will expire the request + * at this time. Requests made after this will not have the token + * applied to them. + */ + setAccessToken (token = null, type = 'Bearer', expiration = null) { + if (token === this.#accessToken && + (token === null ? true : this.getHeader('Authorization') === `${type} ${token}`) && + this.#accessTokenExpiration === expiration) { + return + } + + this.#request.accessToken = token + this.#accessTokenType = type + + clearTimeout(this.#accessTokenTimer) + clearTimeout(this.#accessTokenRenewalTimer) + + if (expiration) { + if (!isNaN(expiration)) { + expiration = new Date(expiration) + } + + this.#accessTokenExpiration = expiration + expiration = expiration.getTime() - (new Date()).getTime() + + if (expiration <= 0) { + this.emit('token.expired', { manually: false }) + } else { + this.emit('update.token', { expires: this.#accessTokenExpiration }) + this.#accessTokenTimer = setTimeout(() => this.emit('token.expired', { manually: false }), expiration) + if (this.#accessTokenRenewalDuration > 0) { + this.#accessTokenRenewalTimer = setTimeout(() => this.emit('token.pending.expiration', this), this.#accessTokenRenewalDuration) + } + } + } + } + + /** + * @property {QueryParameters} query + * Represents the current global query paramaters. + * + * This is commonly used when a remote resource requires a specific + * query paramater on every call. + * + * **Example** + * + * ```js + * let resource = new Resource(...) + * + * resource.query = { + * 'user_id': '12345' + * } + * + * console.log(resource.query.get('user_id')) + * ``` + * + * All parameter values are automatically URL-encoded. + */ + get query () { + return this.#request.query + } + + set query (value) { + this.#request.query = value + } + + /** + * @method route + * Route requests to a specific path. For example: + * + * ```javascript + * const API = new Resource({ baseUrl: 'https://api.domain.com' }) + * const v1 = API.route('/v1') + * const v2 = API.route('/v2') + * + * API.GET('/endpoint', ...) // GET https://api.domain.com/endpoint + * v1.GET('/endpoint', ...) // GET https://api.domain.com/v1/endpoint + * v2.GET('/endpoint', ...) // GET https://api.domain.com/v2/endpoint + * v1.GET('https://different.com', ...) // GET https://different.com + * ``` + * + * The route method provides a way to organize resources by path. + * @note The special `Resource` object returned by this method only + * affects the request path. All other properties, such as credentials, + * headers, and query parameters are proxied to the original resource. + * For example, changing the credentials will impact all other instances: + * + * ```javascript + * API.accessToken = 'newtoken' + * v1.GET(...) // Uses "newtoken"! + * v2.accessToken = 'different' + * v1.GET(...) // Uses "different"! + * ``` + * + * Remember, routes are a _(sub)instance of the original resource_. + * This allows developers to easily configure/change multiple requests + * very easily. If a resource needs uniquely different properties, + * create a new resource instead of using routes, i.e. + * + * ```javascript + * const API = new Resource({ baseUrl: 'https://api.domain.com' }) + * const v1 = new Resource({ baseUrl: 'https://api.domain.com/v1' }) + * const v2 = API.clone({ baseUrl: 'https://api.domain.com/v2' }) + * ``` + * @note If you need a distinctly different resource, consider + * using the #clone method. + * @param {string} path + * The path/subroute to apply to requests. + * @return {Resource} + * Returns a reference to the original resource, with a path modifier. + */ + route (path) { + return new Proxy(this, { + get (target, prop) { + // Base properties + switch (prop) { + case 'baseUrl': + return target.prepareUrl(path) + + case 'prepareUrl': + return uri => new URL(uri, target.prepareUrl(path)).href + } + + // Pseudo-middleware for client, which modifies the request path. + if ((HTTP_METHODS.has(prop.toUpperCase())) && typeof target[prop] === 'function') { + return function () { + const args = Array.from(arguments) + return target[prop](path + '/' + args.shift(), ...args) + } + } else if (prop.toUpperCase() === 'REQUEST') { + return (cfg = {}, callback) => { + cfg.url = path + '/' + arguments[0] + return target.request(cfg, callback) + } + } + + // All other attributes + return target[prop] + } + }) + } + + /** + * @method prepareUrl + * Prepare a URL by applying the base URL (only when appropriate). + * @param {string} uri + * The universal resource indicator (URI/URL) to prepare. + * @return {string} + * Returns a fully qualified URL. + * @private + */ + prepareUrl (uri) { + const input = uri + uri = new Address(new URL(uri, this.#baseUrl.href)) + + if (!/^\.{2}/.test(input)) { + uri.path = `${this.#baseUrl.path}/${uri.path}`.replace(/\/{2,}|\\/gi, '/') + } + + if (this.#tlsonly) { + uri.protocol = 'https' + } + + return uri.href + } + + /** + * @method preflight + * Prepares a request before it is sent. + * @param {Request} request + * The request object. + * @private + */ + preflight (req) { + req.url = this.prepareUrl(req.configuredURL) + req.assign(this.#request, false) + + // Set no-cache header if configured. + if (this.#nocache) { + req.cacheMode = 'no-cache' + } + + // Force unique URL + if (this.#unique) { + req.setQueryParameter('nocache' + (new Date()).getTime().toString() + Math.random().toString().replace('.', ''), '') + } + + // Use custom user agents + let useragent = coalesce(this.#useragent) + if (this.#uniqueagent) { + useragent += ` ID#${(new Date()).getTime().toString() + Math.random().toString().replace('.', '')}` + } + + if (useragent) { + if (NGN.runtime !== 'browser') { + req.setHeader('User-Agent', useragent.trim()) + } else { + req.removeHeader('user-agent') + WARN(`Cannot set user agent to "${useragent.trim()}" in a browser. Browsers consider this an unsafe operation and will block the request.`) + } + } + } + + /** + * Set a global header. This will be sent on every request. + * It is also possible to set multiple global headers at the same time by + * providing an object, where each object + * key represents the header and each value is the header value. + * + * For example: + * + * ``` + * setHeader({ + * 'x-header-a': 'value', + * 'x-header-b': 'value' + * }) + * ``` + * @param {string} header + * The header name. + * @param {string} value + * The value of the header. + */ + setHeader (key, value) { + if (typeof key === 'object') { + for (const [attr, val] of key) { + this.#request.headers.set(attr, val) + } + + return + } + + this.#request.headers.set(key, value) + } + + /** + * Remove a global header so it is not sent + * on every request. This method accepts multiple + * keys, allowing for bulk delete via `removeHeader('a', 'b', '...')` + * @param {string} key + * The header key to remove. + */ + removeHeader (key) { + Array.from(arguments).forEach(el => this.#request.headers.delete(el)) + } + + /** + * Remove all global headers. + */ + clearHeaders () { + this.#request.headers = new Map() + } + + /** + * Set a global URL parameter. This will be sent on every request. + * It is also possible to set multiple parameters at the same time by + * providing an object, where each object + * key represents the parameter name and each value is the parameter value. + * + * For example: + * + * ``` + * setParameter({ + * 'id': 'value', + * 'token': 'value' + * }) + * ``` + * @param {string} queryParameterName + * The parameter name. + * @param {string} value + * The value of the parameter. This will be automatically URI-encoded. + */ + setParameter (key, value) { + if (typeof key === 'object') { + for (const [attr, val] of key) { + this.#request.setQueryParameter(attr, val) + } + + return + } + + this.#request.setQueryParameter(key, value) + } + + /** + * Remove a global query parameter so it is not sent + * on every request. This method accepts multiple + * parameters, allowing for bulk delete via `removeParameter('a', 'b', '...')` + * @param {string} queryParameterName + * The name of the parameter to remove. + */ + removeParameter () { + Array.from(arguments).forEach(this.#request.removeQueryParameter) + } + + /** + * Remove all global query parameters. + */ + clearParameters () { + this.#request.clearQueryParameters() + } + + /** + * Clone the resource as a new independent network resource. + * There is no relationship between the original resource + * and its clone. However; the clone will receive events from + * the original resource. All event names are prefixed with + * `origin`. For example: + * + * ```javascript + * API = new Resource({ + * baseUrl: 'https://api.domain.com', + * accessToken: '...', + * accessTokenRenewal: '...' + * }) + * + * API.on('token.expired', function() { + * console.log('time to renew the token') + * }) + * + * ALT = API.clone({ + * accessToken: 'different_token' + * }) + * + * ALT.on('origin.token.expired', function () { + * console.log('The token from the original resource expired.') + * }) + * ``` + * + * In the example above, `ALT` has a new token of `different_token` + * and the expiration of the original resource's token doesn't impact + * the clone at all. Relaying the origin events to the clone is merely + * a convenience. It can be used for logging, diffing, syncing specific + * changes, etc. + * @note If you only need to modify the base path, consider using + * the #route method instead. + * @param {object} [configuration] + * This object may contain any of the Resource configuration attributes. + * These configuration values will override/replace the original + * values. + * @return {Resource} + */ + clone (cfg = {}) { + const resource = new Resource({ + baseUrl: coalesceb(cfg.baseUrl, this.#baseUrl.href), + headers: coalesceb(cfg.headers, this.headers), + username: coalesceb(cfg.username, this.username), + password: coalesceb(cfg.password, this.#secret), + accessToken: coalesceb(cfg.accessToken, this.#accessToken), + httpsOnly: coalesceb(cfg.httpsOnly, this.#tlsonly), + query: coalesceb(cfg.query, this.query), + nocache: coalesceb(cfg.nocache, this.#nocache), + unique: coalesceb(cfg.unique, this.#unique), + useragent: coalesceb(cfg.useragent, this.#useragent), + uniqueagent: coalesceb(cfg.uniqueagent, this.#uniqueagent), + tokenRenewalNotice: coalesceb(cfg.tokenRenewalNotice, this.#accessTokenRenewalDuration) + }) + + this.relay('*', resource, 'origin') + + return resource + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..a78f1e8 --- /dev/null +++ b/src/index.js @@ -0,0 +1,17 @@ +import Address from './lib/URL.js' +import Fetch, { POLYFILLED } from './lib/fetch/index.js' +import { INTERFACES, HOSTNAME } from './lib/constants.js' +import Request from './lib/Request.js' +import Client from './Client.js' +import Resource from './Resource.js' + +export { + INTERFACES, + HOSTNAME, + POLYFILLED, // This is exported so plugins can determine if polyfilled features are available. + Address as URL, + Fetch, + Request, + Client, + Resource +} diff --git a/src/lib/Credential.js b/src/lib/Credential.js new file mode 100644 index 0000000..16e3c2e --- /dev/null +++ b/src/lib/Credential.js @@ -0,0 +1,207 @@ +import { coalesceb } from '@ngnjs/libdata' +import Reference from '@ngnjs/plugin' + +const NGN = new Reference('>=2.0.0') + +export default class Credential extends NGN.EventEmitter { + #user = null + #secret = null + #accessToken = null + #accessTokenType = 'Bearer' + #applyAuthHeader = null + + constructor (cfg = {}) { + super(...arguments) + this.name = coalesceb(cfg.name, 'HTTP Credential') + + /** + * @cfgproperty {string} username + * A username to authenticate the request with (basic auth). + */ + this.#user = coalesceb(cfg.username) + + /** + * @cfgproperty {string} password + * A password to authenticate the request with (basic auth). + * @readonly + */ + this.#secret = coalesceb(cfg.password) + + /** + * @cfgproperty {string} accessToken + * An access token to authenticate the request with (Bearer auth). + * If this is configured, it will override any basic auth settings. + */ + this.#accessToken = coalesceb(cfg.accessToken) + + /** + * @cfgproperty {String} [accessTokenType='Bearer'] + * The type of access token. This is used to populate the + * Authorization header when a token is present. + * + * _Example:_ + * ``` + * Authorization: 'Bearer myTokenGoesHere' + * ``` + */ + this.#accessTokenType = coalesceb(cfg.accessTokenType, 'Bearer') + + Object.defineProperties(this, { + /** + * @method basicAuthToken + * Generates a basic authentication token from a username and password. + * @return {string} [description] + * @private + */ + basicAuthToken: NGN.privateconstant((user, secret) => { + // Binary to base64-ascii conversions + if (NGN.runtime === 'node') { + return `Basic ${Buffer.from(`${user}:${secret}`, 'binary').toString('base64')}` + } else if (globalThis.btoa !== undefined) { + return 'Basic ' + globalThis.btoa(`${user}:${secret}`) + } + + return '' + }) + }) + + /** + * @fires {header:string} update.header + * Triggered whenever the username, password, access token, + * or access token type change. + */ + this.on(['update.access*', 'update.username', 'update.password'], () => this.emit('update.header', this.header)) + } + + set reference (value) { + if (value && typeof value.applyAuthorizationHeader === 'function') { + this.#applyAuthHeader = value.applyAuthorizationHeader + this.#applyAuthHeader() + } + } + + /** + * Represents the value of an `Authorization` or `Proxy-Authorization` + * HTTP header. + * + * Authorization: **header value** + * _or_ + * Proxy-Authorization: **header value** + */ + get header () { + if (coalesceb(this.#accessToken) !== null) { + return `${this.#accessTokenType} ${this.#accessToken}` + } else if (coalesceb(this.#user) && coalesceb(this.#secret)) { + return this.basicAuthToken(this.#user, this.#secret) + } + + return null + } + + /** + * The type of authorization. This will usually be + * `basic` for username/password credentials and + * `token` for token credentials. It can also be `none` + * if no valid credentials are available. + */ + get authType () { + if (this.#accessToken) { + return 'token' + } else if (coalesceb(this.#user) && coalesceb(this.#secret)) { + return 'basic' + } + + return 'none' + } + + /** + * @property {string} username + * The username that will be used in any basic authentication operations. + */ + get username () { + return coalesceb(this.#user) + } + + set username (user) { + const old = this.#user + user = coalesceb(user) + + if (this.#user !== user) { + this.#user = user + } + + if (old !== this.#user) { + this.emit('update.username', { old, new: this.#user }) + this.#applyAuthHeader && this.#applyAuthHeader() + } + } + + /** + * @property {string} password + * It is possible to set a password for any basic authentication operations, + * but it is not possible to read a password. + * @writeonly + */ + set password (secret) { + const old = this.#secret + secret = coalesceb(secret) + + if (this.#secret !== secret) { + this.#secret = secret + } + + if (old !== this.#secret) { + this.emit('update.password') + this.#applyAuthHeader && this.#applyAuthHeader() + } + } + + /** + * @property {string} accessToken + * Supply a bearer access token for basic authenticaiton operations. + * @writeonly + */ + set accessToken (token) { + const old = this.#accessToken + + token = coalesceb(token) + + if (this.#accessToken !== token) { + this.#accessToken = token + } + + if (token) { + this.username = null + this.password = null + } + + if (old !== token) { + this.emit('update.accessToken') + this.#applyAuthHeader && this.#applyAuthHeader() + } + } + + set accessTokenType (value) { + value = coalesceb(value, 'bearer') + + if (value !== this.#accessTokenType) { + const old = this.#accessTokenType + this.#accessTokenType = value + this.emit('update.accessTokenType', { old, new: value }) + this.#applyAuthHeader && this.#applyAuthHeader() + } + } + + get accessTokenType () { + return this.#accessTokenType + } + + clone () { + return new Credential({ + username: this.#user, + password: this.#secret, + accessToken: this.#accessToken, + accessTokenType: this.#accessTokenType + }) + } +} diff --git a/src/lib/Headers.js b/src/lib/Headers.js new file mode 100644 index 0000000..1234f46 --- /dev/null +++ b/src/lib/Headers.js @@ -0,0 +1,19 @@ +import MapStore from './map.js' + +/** + * This class polyfills the Headers object, primarily + * for Node.js runtimes. This class is currently just + * a polyfill, but it may become an event emitter if + * demand warrants it. + * @fires {name:string, value:any} header.create + * Triggered when a new header is created. + * @fires {name:string, old:any, new:any} header.update + * Triggered when a header value is updated. + * @fires {name:string, value: any} header.delete + * Triggered when a header value is deleted. + */ +export default class Headers extends MapStore { + constructor (init = {}) { + super(init, 'lower', 'header') + } +} diff --git a/src/lib/Request.js b/src/lib/Request.js new file mode 100644 index 0000000..aebab17 --- /dev/null +++ b/src/lib/Request.js @@ -0,0 +1,1011 @@ +import Address from './URL.js' +import Fetch from './fetch/index.js' +import Headers from './Headers.js' +import Reference from '@ngnjs/plugin' +import { object, coalesce, coalesceb } from '@ngnjs/libdata' +import { + IDEMPOTENT_METHODS, + REQUEST_NOBODY_METHODS, + REQUEST_CREDENTIALS, + HTTP_METHODS, + CORS_MODES, + CACHE_MODES +} from './constants.js' +import Credential from './Credential.js' + +const NGN = new Reference('>=2.0.0').requires('WARN', 'public', 'private', 'privateconstant', 'nodelike', 'EventEmitter') +const { WARN } = NGN + +/** + * Represents a network request. + * @private + */ +export default class Request extends NGN.EventEmitter { // eslint-disable-line no-unused-vars + #method = 'GET' + #headers = new Headers() + #body = null + #cacheMode = 'default' + #corsMode = 'cors' + referrer = null + #referrerPolicy = 'no-referrer-when-downgrade' + sri = null + #controller + #uri + #auth + #proxyAuth + #credentials + #rawURL + + constructor (cfg = {}) { + super() + this.name = 'HTTP Request' + + if (typeof cfg === 'string') { + cfg = { url: cfg } + } + + if (cfg.url instanceof URL || cfg.url instanceof Address) { + cfg.url = cfg.href + } + + // Require URL and HTTP method + object.require(cfg, 'url') + this.#rawURL = cfg.url instanceof URL || cfg.url instanceof Address ? cfg.url.href : cfg.url + + if (object.any(cfg, 'form', 'json')) { + WARN('Request', '"form" and "json" configuration properties are invalid. Use "body" instead.') + } + + /** + * @cfgproperty {string} url (required) + * The complete URL for the request, including query parameters. + * This value is automatically normalized. + */ + this.url = cfg.url + // Use the setter to configure the URL + + /** + * @cfg {string} [method=GET] + * The HTTP method to invoke when the request is sent. The standard + * RFC 2616 HTTP methods include: + * + * - OPTIONS + * - HEAD + * - GET + * - POST + * - PUT + * - DELETE + * - TRACE + * - CONNECT + * + * There are many additional non-standard methods some remote hosts + * will accept, including `PATCH`, `COPY`, `LINK`, `UNLINK`, `PURGE`, + * `LOCK`, `UNLOCK`, `VIEW`, and many others. If the remote host + * supports these methods, they may be used in an NGN Request. + * Non-standard methods will not be prevented, but NGN will trigger + * a warning event if a non-standard request is created. + */ + this.#method = coalesceb(cfg.method, 'GET') + + /** + * @cfg {object} [headers] + * Optionally supply custom headers for the request. Most standard + * headers will be applied automatically (when appropriate), such + * as `Content-Type`, `Content-Length`, and `Authorization`. + * In Node-like environments, a `User-Agent` will be applied containing + * the `hostname` of the system making the request. Any custom headers + * supplied will override/augment headers these headers. + */ + this.#headers = new Headers(coalesceb(cfg.headers, {})) + + /** + * @cfg {object|string|binary} [body] + * The body configuration supports text, an object, data URL, or + * binary content. **For multi-part form data (file uploads), use + * the #files configuration _instead_ of this attribute.** + * + * To construct a simple form submission (x-www-form-urlencoded), + * use a specially formatted key/value object conforming to the + * following syntax: + * + * ```json + * { + * form: { + * form_field_1: "value", + * form_field_2: "value", + * form_field_3: "value", + * } + * } + * ``` + * The object above is automatically converted and url-encoded as: + * + * ```js + * form_field_1=value&form_field_2=value&form_field_3=value + * ``` + * + * The appropriate request headers are automatically applied. + */ + this.#body = coalesce(cfg.body) + + /** + * @cfg {string} [credentials=include] + * The request credentials you want to use for the request: + * `omit`, `same-origin`, or `include`. To automatically + * send cookies for the current domain, this option must be + * provided. + */ + this.#credentials = coalesceb(cfg.credentials) + + /** + * @cfgproperty {string} [cacheMode=default] (default, no-store, reload, no-cache, force-cache, only-if-cached) + * The [caching mechanism](https://developer.mozilla.org/en-US/docs/Web/API/Request/cache) applied to the request. + * Node-like environments do not have a native HTTP cache. NGN provides + * a limited HTTP cache for Node-like environments, adhering to the principles + * defined in the [MDN HTTP Cache Documentation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching). + * The most noticeable differences are: + * 1. Incomplete results (HTTP Status 206 / Partial Content) won't be cached. + * 1. Redirects are handled differently (internally only, results are the same). + * @warning Browsers are the only runtime to support this feature natively. The + * NGN libnet-node plugin adds support for modern Node.js runtimes. + */ + this.cacheMode = coalesce(cfg.cacheMode, NGN.nodelike ? 'no-store' : 'default') + + /** + * @cfgproperty {string} [referrer] + * The referrer URL to send to the destination. By default, this will be the current URL + * of the page or the hostname of the process. + * See the [MDN overview of referrers](https://hacks.mozilla.org/2016/03/referrer-and-cache-control-apis-for-fetch/) for details. + */ + this.referrer = coalesceb(cfg.referrer) + + /** + * @cfgproperty {string} [referrerPolicy=no-referrer-when-downgrade] (no-referrer, no-referrer-when-downgrade, same-origin, origin, strict-origin, origin-when-cross-origin, strict-origin-when-cross-origin, unsafe-url) + * Specify the [referrer policy](https://w3c.github.io/webappsec-referrer-policy/#referrer-policies). This can be empty/null. + * @warning Browsers are the only runtime to support this feature natively. The + * NGN libnet-node plugin adds support for modern Node.js runtimes. + */ + this.referrerPolicy = coalesce(cfg.referrerPolicy, 'no-referrer-when-downgrade') + + /** + * @cfgproperty {string} [sri] + * The [subresource integrity](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) value of the request. + * Example: `sha256-BpfBw7ivV8q2jLiT13fxDYAe2tJllusRSZ273h2nFSE=` + * @warning Browsers are the only runtime to support this feature natively. The + * NGN libnet-node plugin adds support for modern Node.js runtimes. + */ + this.sri = coalesceb(cfg.sri, cfg.integrity, cfg.subresourceintegrity, cfg.subresourceIntegrity) + + Object.defineProperties(this, { + /** + * @cfg {boolean} [enforceMethodSafety=true] + * [RFC 2616](https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html) + * defines some HTTP methods as idempotent (safe). These methods + * should have no significance to data (i.e. read-only). For example, + * `OPTIONS`, `HEAD`, and `GET` are all idempotent. By default, requests + * loosely enforce idempotence by ignoring the request body when making a + * request. While it is not advised, nor officially supported, requests can + * technically ignore method safety, allowing a request body to be + * sent to a remote server with an unsage method. Set this configuration + * to `false` to prevent requests from enforcing idempotence/safety. + */ + enforceMethodSafety: NGN.private(coalesce(cfg.enforceMethodSafety, cfg.enforcemethodsafety, true)), + + /** + * @cfgproperty {Number} [timeout=30000] + * The number of milliseconds to wait before considering the request to + * have timed out. Defaults to `30000` (30 seconds). + */ + timeout: NGN.public(coalesce(cfg.timeout, 30000)), + + /** + * @method isCrossOrigin + * Determine if accessing a URL is considered a cross origin request. + * @param {string} url + * The URL to identify as a COR. + * @returns {boolean} + * @private + */ + isCrossOrigin: NGN.privateconstant(url => this.#uri.isSameOrigin(url)), + + /** + * @method applyAuthorizationHeader + * Generates and applies the authorization header for the request, + * based on the presence of #username, #password, or #accessToken. + * @private + */ + applyAuthorizationHeader: NGN.privateconstant(() => { + const authHeader = this.#auth.header + const proxyAuthHeader = this.#proxyAuth.header + + if (authHeader) { + this.setHeader('Authorization', authHeader, true) + } else { + this.removeHeader('Authorization') + } + + if (proxyAuthHeader) { + this.setHeader('Proxy-Authorization', proxyAuthHeader, true) + } else { + this.removeHeader('Proxy-Authorization') + } + }), + + prepareBody: NGN.private(() => { + // Request body management + if (this.#body !== null) { + const contentType = coalesceb(this.getHeader('content-type')) + + switch (typeof this.#body) { + case 'object': { + if (object.exactly(this.#body, 'form')) { + const dataString = [] + + for (let [key, value] of Object.entries(this.#body.form)) { + if (typeof value === 'function') { + throw new Error('Invalid form data. Form data cannot be a complex data format such as an object or function.') + } else if (typeof value === 'object') { + value = JSON.stringify(value) + } + + dataString.push(`${key}=${encodeURIComponent(value)}`) + } + + this.#body = dataString.join('&') + } else { + this.#body = JSON.stringify(this.#body).trim() + this.setHeader('Content-Length', this.#body.length) + this.setHeader('Content-Type', coalesceb(contentType, 'application/json')) + } + + break + } + + case 'string': { + if (contentType !== null) { + // Check for form data + let match = /([^=]+)=([^&]+)/.exec(this.#body) + + if (match !== null && this.#body.trim().substr(0, 5).toLowerCase() !== 'data:' && this.#body.trim().substr(0, 1).toLowerCase() !== '<') { + this.setHeader('Content-Type', 'application/x-www-form-urlencoded') + } else { + this.setHeader('Content-Type', 'text/plain') + + if (this.#body.trim().substr(0, 5).toLowerCase() === 'data:') { + // Crude Data URL mimetype detection + match = /^(data:)(.*);/gi.exec(this.#body.trim()) + + if (match !== null) { + this.setHeader('Content-Type', match[2]) + } + } else if (/^(<\?xml.*)/gi.test(this.#body.trim())) { + // Crude XML Detection + this.setHeader('Content-Type', 'application/xml') + } else if (/^ { this.#auth = cred.clone() }), + PROXY_CREDENTIAL: NGN.set(cred => { this.#proxyAuth = cred.clone() }) + }) + + this.#auth = new Credential({ + /** + * @cfgproperty {string} username + * A username to authenticate the request with (basic auth). + */ + username: coalesceb(cfg.username), + /** + * @cfgproperty {string} password + * A password to authenticate the request with (basic auth). + * @readonly + */ + password: coalesceb(cfg.password), + /** + * @cfgproperty {string} accessToken + * An access token to authenticate the request with (Bearer auth). + * If this is configured, it will override any basic auth settings. + */ + accessToken: coalesceb(cfg.accessToken), + /** + * @cfgproperty {String} [accessTokenType='Bearer'] + * The type of access token. This is used to populate the + * Authorization header when a token is present. + * + * _Example:_ + * ``` + * Authorization: 'Bearer myTokenGoesHere' + * ``` + */ + accessTokenType: coalesceb(cfg.accessTokenType, 'Bearer') + }) + + this.#proxyAuth = new Credential({ + /** + * @cfgproperty {string} proxyUsername + * A username to authenticate the request with (basic auth). + */ + username: coalesceb(cfg.proxyUsername), + /** + * @cfgproperty {string} proxyPassword + * A password to authenticate the request with (basic auth). + * @readonly + */ + password: coalesceb(cfg.proxyPassword), + /** + * @cfgproperty {string} proxyAccessToken + * An access token to authenticate the request with (Bearer auth). + * If this is configured, it will override any basic auth settings. + */ + accessToken: coalesceb(cfg.proxyAccessToken), + /** + * @cfgproperty {String} [proxyAccessTokenType='Bearer'] + * The type of access token. This is used to populate the + * Authorization header when a token is present. + * + * _Example:_ + * ``` + * Authorization: 'Bearer myTokenGoesHere' + * ``` + */ + accessTokenType: coalesceb(cfg.proxyAccessTokenType, 'Bearer') + }) + + this.#auth.reference = this + this.#proxyAuth.reference = this + + // if (cfg.maxRedirects) { + // this.maxRedirects = cfg.maxRedirects + // } + + this.method = coalesceb(cfg.method, 'GET') + + this.prepareBody() + } + + /** + * @property {string} configuredURL + * The URL value supplied to the Request constructor + * (i.e. the original URL provided to `new Request()`). + */ + get configuredURL () { + return this.#rawURL + } + + get authType () { + return this.#auth.authType + } + + get proxyAuthType () { + return this.#proxyAuth.authType + } + + get cacheMode () { + return this.#cacheMode + } + + set cacheMode (value) { + value = coalesceb(value, 'default') + if (typeof value !== 'string') { + throw new Error(`Cache mode must be one of the following: ${Array.from(CACHE_MODES).join(', ')}. "${value}" is invalid.`) + } + + const old = this.#cacheMode + + value = value.trim().toLowerCase() + + if (!CACHE_MODES.has(value)) { + throw new Error(`"${value}" is an unrecognized cache mode.Must be one of: ${Array.from(CACHE_MODES).join(', ')}.`) + } else { + this.#cacheMode = value + } + + if (value === 'only-if-cached' && this.#corsMode !== 'same-origin') { + this.corsMode = 'same-origin' + WARN('Request\'s CORS mode automatically set to "same-origin" for caching mode of "only-if-cached".') + } + + if (old !== value) { + this.emit('update.cachemode', { old, new: value }) + } + } + + get corsMode () { + return this.#corsMode + } + + set corsMode (value) { + if (value === null || value === undefined || typeof value !== 'string') { + throw new Error(`CORS mode must be cors, no-cors, or same-origin. "${value}" is invalid.`) + } + + const old = this.#corsMode + + if (this.#cacheMode !== 'only-if-cached') { + value = value.trim().toLowerCase() + if (!CORS_MODES.has(value)) { + throw new Error(`"${value} is an invalid CORS mode. Must be one of: ${Array.from(CORS_MODES).join(', ')}`) + } + } + + this.#corsMode = value + if (old !== value) { + this.emit('update.corsMode', { old, new: value }) + } + } + + get referrerPolicy () { + return this.#referrerPolicy + } + + set referrerPolicy (value) { + const old = this.#referrerPolicy + + if (coalesceb(value) === null) { + this.#referrerPolicy = null + } else { + if (value === null || value === undefined || typeof value !== 'string') { + throw new Error(`Referrer Policy mode must be no-referrer, no-referrer-when-downgrade, same-origin, origin, strict-origin, origin-when-cross-origin, strict-origin-when-cross-origin, unsafe-url, null, or an empty/blank string. "${value}" is invalid.`) + } + + value = value.trim().toLowerCase() + + if (['no-referrer', 'no-referrer-when-downgrade', 'same-origin', 'origin', 'strict-origin', 'origin-when-cross-origin', 'strict-origin-when-cross-origin', 'unsafe-url'].indexOf(value) < 0) { + throw new Error(`"${value}" is an invalid referrer policy. Must be one of: no-referrer, no-referrer-when-downgrade, same-origin, origin, strict-origin, origin-when-cross-origin, strict-origin-when-cross-origin, unsafe-url, null, or an empty/blank string.`) + } + + this.#referrerPolicy = value + } + + if (old !== this.#referrerPolicy) { + this.emit('update.referrerPolicy', { old, new: this.#referrerPolicy }) + } + } + + /** + * @property {string} protocol + * The protocol used to make the request. + * @readonly + */ + get protocol () { + return this.#uri.protocol + } + + /** + * @property {string} host + * The hostname/domain of the request (includes port if applicable). + */ + get host () { + return this.#uri.toString({ + protocol: false, + username: false, + password: false, + urlencode: false, + hash: false, + querystring: false + }) + } + + /** + * @property {string} hostname + * The hostname/domain of the request (does not include port). + */ + get hostname () { + return this.#uri.hostname + } + + /** + * @property {number} port + * The port of the remote host. + */ + get port () { + return this.#uri.port + } + + /** + * @property {string} path + * The pathname of the URL. + */ + get path () { + return this.#uri.path + } + + /** + * @property {string} querystring + * The raw query string of the URI. To retrieve a key/value list, + * use #queryParameters instead. + */ + get querystring () { + return this.#uri.querystring + } + + /** + * @property {Map} query + * Returns a key/value object containing the URL query parameters of the + * request, as defined in the #url. The parameter values (represented as keys + * in this object) may not be modified or removed (use setQueryParameter or removeQueryParameter + * to modify/delete a query parameter). + * @readonly + */ + get query () { + return Object.freeze(Object.assign({}, this.#uri.query)) + } + + set query (value) { + if (value instanceof Map) { + value = Object.fromEntries(value) + } + + if (typeof value !== 'object') { + throw new TypeError('Query parameters must be set as an object or Map.') + } + + this.clearQueryParameters() + + for (const [key, val] of Object.entries(value)) { + this.setQueryParameter(key, val) + } + } + + /** + * @property {string} hash + * The hash part of the URL (i.e. everything after the trailing `#`). + */ + get hash () { + return coalesceb(this.#uri.hash) || '' + } + + /** + * @property {string} url + * The URL where the request will be sent. + */ + get url () { + return this.#uri.toString() + } + + set url (value) { + if (!coalesceb(value)) { + WARN('Request.url', 'A blank URL was identified for a request. Using current URL instead.') + } + + const old = this.#uri ? this.#uri.href : null + this.#uri = value instanceof URL ? new Address(value.href) : new Address(value) + + if (coalesceb(this.#uri.username)) { + this.#auth.username = coalesceb(this.#uri.username) + } + + if (coalesceb(this.#uri.password)) { + this.#auth.password = coalesceb(this.#uri.password) + } + + if (old !== this.#uri.href) { + this.emit('update.url', { old, new: this.#uri.href }) + } + } + + get href () { + return this.url + } + + set href (value) { + this.url = value + } + + get method () { + return this.#method + } + + set method (value) { + const old = this.#method + + value = coalesceb(value) + + if (this.#method !== value) { + if (!value) { + WARN('NGN.Request.method', 'No HTTP method specified.') + } + + value = value.trim().toUpperCase() + + if (HTTP_METHODS.has(value)) { + WARN('NGN.Request.method', `A non-standard HTTP method was recognized in a request: ${value}.`) + } + + this.#method = value + } + + if (old !== this.#method) { + this.emit('update.method', { old, new: this.#method }) + } + } + + get body () { + return this.#body + } + + set body (value) { + const old = this.#body + this.#body = value + this.prepareBody() + if (old !== this.#body) { + this.emit('update.body', { old, new: this.body }) + } + } + + /** + * @property {boolean} crossOriginRequest + * Indicates the request will be made to a domain outside of the + * one hosting the request. + */ + get crossOriginRequest () { + return this.#uri.isSameOrigin('./') + } + + get username () { + return this.#auth.username + } + + set username (user) { + this.#auth.username = user + } + + set password (secret) { + this.#auth.password = secret + } + + set accessToken (token) { + this.#auth.accessToken = token + } + + get accessTokenType () { + return this.#auth.accessTokenType + } + + set accessTokenType (value) { + this.#auth.accessTokenType = value + } + + get proxyUsername () { + return this.#proxyAuth.username + } + + set proxyUsername (user) { + this.#proxyAuth.username = user + } + + set proxyPassword (secret) { + this.#proxyAuth.password = secret + } + + set proxyAccessToken (token) { + this.#proxyAuth.accessToken = token + } + + get proxyAccessTokenType () { + return this.#proxyAuth.accessTokenType + } + + set proxyAccessTokenType (value) { + this.#proxyAuth.accessTokenType = value + } + + /** + * @property {Headers} headers + * Represents the request headers. + */ + get headers () { + return this.#headers + } + + set headers (value) { + this.#headers = typeof value === 'object' ? new Headers(value) : new Headers() + } + + /** + * @method getHeader + * @param {string} header + * The name of the header to retrieve. + * @return {string} + * Returns the current value of the specified header. + */ + getHeader (name) { + return this.#headers.get(name) + } + + /** + * @method setHeader + * Add a header to the request. + * @param {string} header + * The name of the header. + * @param {string} value + * Value of the header. + * @param {Boolean} [overwriteExisting=true] + * If the header already exists, setting this to `false` will prevent + * the original header from being overwritten. + */ + setHeader (name, value, overwriteExisting = true) { + if (overwriteExisting || !this.headers.has(name)) { + this.#headers.set(name, value) + } + } + + /** + * Append a value to an existing header. If the header does + * not already exist, it will be created. + * @param {String} name + * @param {String} value + */ + appendHeader (name, value) { + this.#headers.append(...arguments) + } + + /** + * Remove a header. This does nothing if the header doesn't exist. + * @param {string} name + */ + removeHeader (name) { + this.#headers.delete(name) + } + + /** + * @method setQueryParameter + * Set/add a query parameter to the request. + * @param {string} parameter + * The name of the parameter. + * @param {string} value + * Value of the parameter. The value is automatically URL encoded. If the + * value is null, only the key will be added to the URL (ex: `http://domain.com/page.html?key`) + * @param {Boolean} [overwriteExisting=true] + * If the parameter already exists, setting this to `false` will prevent + * the original parameter from being overwritten. + */ + setQueryParameter (key, value, overwriteExisting = true) { + if (overwriteExisting || this.#uri.query[key] === undefined) { + this.#uri.query[key] = value + } + } + + /** + * @method removeQueryParameter + * Remove a query parameter from the request URI. + * @param {string} key + */ + removeQueryParameter (key) { + delete this.#uri.query[key] + } + + /** + * Remove all query parameters + */ + clearQueryParameters () { + for (const key of Object.keys(this.#uri.query)) { + delete this.#uri.query[key] + } + } + + get queryParameterCount () { + return this.#uri.queryParameterCount + } + + get hasQueryParameters () { + return this.#uri.hasQueryParameters + } + + /** + * Abort a sent request before a response is returned. + * @warning Not currently supported in Deno. + */ + abort () { + if (NGN.runtime === 'node') { + if (typeof this.#controller === 'function') { + this.#controller() + } + } else if (this.#controller !== null && !this.#controller.signal.aborted) { + this.#controller.abort() + } + } + + /** + * @method send + * Send the request. + * @param {Function} callback + * The callback is executed when the request is complete. + * @param {Error} callback.error + * If an error occurs, it will be the first argument of + * the callback. When no error occurs, this value will be + * `null`. + * @param {Object} callback.response + * The response object returned by the server. + * @return {Promise} + * If no callback is specified, a promise is returned. + */ + send (callback) { + let body = this.body + + // Disable body when safe methods (idempotent) are enforced. + if (coalesce(body)) { + if (this.enforceMethodSafety && IDEMPOTENT_METHODS.has(this.method)) { + body = null + } + } + + // Create the request configuration + const init = { + method: this.method, + // CORS mode must be same-origin if the cache mode is "only-if-cached": https://developer.mozilla.org/en-US/docs/Web/API/Request/cache + mode: this.#cacheMode === 'only-if-cached' ? 'same-origin' : this.#corsMode, + cache: this.#cacheMode, + redirect: 'follow', + referrer: coalesceb(this.referrer), + referrerPolicy: this.#referrerPolicy + } + + // Apply Request Headers + if (this.#headers.size > 0) { + init.headers = this.#headers.toObject() + } + + // Apply request body (if applicable) + if (this.#body !== null && REQUEST_NOBODY_METHODS.has(init.method)) { + init.body = this.#body + } + + // Apply timer + if (this.timeout > 0) { + init.timeout = this.timeout + } + + // Add abort capability + if (globalThis.AbortController) { + this.#controller = new globalThis.AbortController() + init.signal = this.#controller.signal + init.signal.addEventListener('abort', e => { + this.#controller = null + this.emit('abort', e) + }) + } else if (NGN.runtime === 'node') { + init.signal = req => { + this.#controller = () => { + req.destroy() + this.emit('abort', new Error('Aborted')) + } + } + } + + // Apply credentials + if (REQUEST_CREDENTIALS.has(this.#credentials)) { + init.credentials = this.#credentials + } else if (this.#auth.authType !== 'none') { + WARN('Request', `"${this.#credentials}" is not a valid option. Must be one of the following: ${Array.from(REQUEST_CREDENTIALS).join(', ')}`) + } + + // Apply subresource identity + if (coalesceb(this.sri, this.integrity)) { + init.integrity = coalesce(this.sri, this.integrity) + } + + // Send the request + const res = Fetch(this.#uri, init, this) + + if (callback) { + res.then(r => callback(null, r)).catch(callback) + } + + return res + } + + /** + * Retrieve a cloned instance (copy) of the request + */ + clone () { + const req = new Request({ + url: this.url, + method: this.#method, + headers: Object.fromEntries(this.#headers.entries()), + query: this.query, + body: this.#body, + // username: this.#auth.username, + // password: this.#auth[this.#auth.SECERT], + // accessToken: this.#auth[this.#auth.SECERT_TOKEN], + // accessTokenType: this.#auth.accessTokenType, + // proxyUsername: this.#proxyAuth.username, + // proxyPassword: this.#proxyAuth[this.#proxyAuth.SECERT], + // proxyAccessToken: this.#proxyAuth[this.#proxyAuth.SECERT_TOKEN], + // proxyAccessTokenType: this.#proxyAuth.accessTokenType, + cacheMode: this.#cacheMode, + corsMode: this.#corsMode, + referrer: this.referrer, + referrerPolicy: this.#referrerPolicy, + sri: this.sri, + enforceMethodSafety: this.enforceMethodSafety, + timeout: this.timeout + }) + + req.AUTH_CREDENTIAL = this.#auth + req.PROXY_CREDENTIAL = this.#proxyAuth + } + + /** + * Assign the properties of another request to this one. + * This does **not** assign the hostname, port, or path. It + * _does_ apply request _properties_ such as headers, + * query parameters, credentials, the HTTP method, body, + * caching/CORS modes, referral policy, timeouts, etc. + * + * This method works similarly to [Object.assign](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign), + * where new attributes are assigned to the request. Conflicting + * attributes are overwritten, while everything else remains untouched. + * @param {Request} req + * Any number of requests can be assigned, i.e. `assign(reqA, reqB, ...)`. + * @param {boolean} [override=true] + * Set this to false to prevent overriding conflicting attributes. + * For example: `assign(reqA, reqB, ..., false)` + */ + assign () { + let override = true + const args = Array.from(arguments) + if (typeof args[args.length - 1] === 'boolean') { + override = args.pop() + } + + for (const req of args) { + if (!(req instanceof Request)) { + throw new Error('Cannot assign a non-Request value to a request.') + } + + merge(this, req, override) + } + } +} + +// Merges one request into another +// Item b is merged into item a. Any conflicting attributes +// will be overwritten using the value of b (default). If +// override is set to `false`, existing values will not be +// overwritten +function merge (a, b, override = true) { + let aa = a + let bb = b + + // When overriding is disabled, switch the coalescing order + if (!override) { + aa = b + bb = a + } + + a.method = coalesceb(bb.method, aa.method) + a.body = coalesceb(bb.body, aa.body) + a.username = coalesceb(bb.username, aa.username) + a.password = coalesceb(bb.password, aa.SECERT) + a.accessToken = coalesceb(bb.accessToken, aa.accessToken) + a.accessTokenType = coalesceb(bb.accessTokenType, aa.accessTokenType) + a.cacheMode = coalesceb(bb.cacheMode, aa.cacheMode) + a.corsMode = coalesceb(bb.corsMode, aa.corsMode) + a.referrer = coalesceb(bb.referrer, aa.referrer) + a.referrerPolicy = coalesceb(bb.referrerPolicy, aa.referrerPolicy) + a.sri = coalesceb(bb.sri, aa.sri) + a.enforceMethodSafety = coalesceb(bb.enforceMethodSafety, aa.enforceMethodSafety) + a.timeout = coalesceb(bb.timeout, aa.timeout) + + for (const [name, value] of b.headers.entries()) { + a.setHeader(name, value, override) + } + + for (const [key, value] of Object.entries(b.query)) { + a.setQueryParameter(key, value, override) + } +} diff --git a/src/lib/URL.js b/src/lib/URL.js new file mode 100644 index 0000000..e51b6f5 --- /dev/null +++ b/src/lib/URL.js @@ -0,0 +1,631 @@ +import { DEFAULT_PORT, HOSTNAME, INTERFACES, URL_RELATIVE_PATTERN } from './constants.js' +import { coalesce, coalesceb, forceBoolean, forceNumber } from '@ngnjs/libdata' +import Reference from '@ngnjs/plugin' + +const NGN = new Reference('>=2.0.0').requires('EventEmitter', 'WARN') +const localschemes = new Set(INTERFACES.map(host => host.trim().toLowerCase())) + +/** + * @class URL + * Represents a **URL**. This is a lightweight wrapper around the standard [URL API](https://developer.mozilla.org/en-US/docs/Web/API/URL), + * which is supported in browsers and other JS runtimes. + * + * **Differences from Standard URL API:** + * 1. The `toString` method accepts a optional configuration to modify the generated URL string. + * 1. Protocols are stripped of separator (i.e. `:`), the port is converted to an integer, and the hash sign (`#`) is stripped from the hash attribute. + * 1. Password is masked by default. This can be overridden with a constructor configuration argument, but is not recommended. + * 1. A `formatString` method is available for simplistic URL templating. + * 1. Provides an alternative/simpler object (query) for managing search parameters. The native `searchParams` is still available for those who prefer to use it. + * 1. Extends NGN.EventEmitter (i.e. fires change events). + * --- + * 1) Complies with [RFC 3986](https://tools.ietf.org/html/rfc3986), + * always assuming that a URL has an authority. See [syntax components](https://tools.ietf.org/html/rfc3986#section-3) + * for details about the "authority" component. The important point is + * all URL's will use the `://` separator, i.e. `scheme://authority/path` + * as opposed to `scheme:path` UR**I** syntax. + */ +export default class Address extends NGN.EventEmitter { + #uri + #originalURI + #parts = null + #protocol = globalThis.location ? globalThis.location.protocol.replace(':', '') : 'http' + #username + #password + #hostname = HOSTNAME + #port = null + #path = '/' + #querystring = null + #queryObject = {} + #querymode = 'boolean' + #hash = null + #parsed = false + #insecure = false + #defaultPort = DEFAULT_PORT + + #parse = (force = false) => { + if (this.#parsed && !force) { + return + } + + // If a relative path is defined, apply the current path to the URL + if (this.#uri && URL_RELATIVE_PATTERN.test(this.#uri) && globalThis.location && globalThis.location.pathname) { + this.#uri = `${globalThis.location.pathname}/${this.#uri}` + } + + const parts = new URL(this.#uri || '/', `${this.#protocol}://${this.#hostname}`) + this.#parts = parts + this.#protocol = coalesceb(parts.protocol, this.#protocol).replace(':', '') + this.#hostname = coalesceb(parts.hostname, this.#hostname) + this.#port = coalesceb(parts.port, this.#defaultPort[this.#protocol]) + this.#username = coalesceb(parts.username) + this.#password = coalesceb(parts.password) + this.#path = coalesceb(parts.pathname, '/') + this.#querystring = coalesce(parts.search, '').replace(/^\?/, '').trim() + this.#hash = coalesceb(parts.hash.replace(/^#+/, '')) + this.#queryObject = this.#deserializeQueryParameters(this.#querystring) + + if (this.#port !== null) { + this.#port = forceNumber(this.#port) + } + + this.#parsed = true + } + + // TODO: Modify when private variables no longer need transpilation, + // this method can replace alot of boilerplate code when updating attributes. + // #update = (key, value) => { + // const attr = this[`#${key}`] + // if (value !== attr) { + // const old = attr + // this[`#${key}`] = value + // this.emit(`update.${key}`, { old, new: value }) + // } + // } + + #deserializeQueryParameters = (paramString = '') => { + if (!coalesceb(paramString)) { + return new Map() + } + + const PATTERN = /(((?[^&]+)(?:=)(?[^&]+))|(?[^&]+))[&|\n]?/g + return new Map(paramString.trim().match(PATTERN).map(p => { + const mode = this.#querymode === 'boolean' ? true : (this.#querymode === 'string' ? '' : null) + const values = p.replace('&', '').split('=').concat([mode]) + const value = values[1] + if (typeof value === 'string') { + if (value.trim().toLowerCase() === 'true' || value.trim().toLowerCase() === 'false') { + values[1] = forceBoolean(value) + } else if (!isNaN(value)) { + values[1] = forceNumber(value) + } + } + return values + })) + } + + /** + * @param {string} [uri=./] + * The URI. This can be fully qualified or a relative path. + * Examples: `https://domain.com`, `./path/to/my.html` + * Produces [RFC 3986](https://tools.ietf.org/html/rfc3986) compliant URL's. + * @param {boolean} [insecure=false] + * Setting this to `true` will display the #password in plain text instead of a masked format (in toString operations). + * Not recommended. + */ + constructor (uri = null, insecure = false) { + super() + + this.#insecure = forceBoolean(coalesceb(insecure, false)) + this.href = uri + + /** + * @property {object} query + * Represents the query string as an object. + */ + this.query = new Proxy({}, { + // Proxy query parameters and handle change events. + get: (obj, prop) => { + !this.#parsed && this.#parse() + const result = this.#queryObject.get(prop) + switch (this.#querymode) { + case 'string': + return '' + case 'null': + return null + } + + return result + }, + + set: (obj, prop, value) => { + const oldParamVal = this.#queryObject.get(prop) + + if (oldParamVal === value) { + return false + } + + const old = Object.freeze(Object.fromEntries(this.#queryObject)) + + switch (this.#querymode) { + case 'null': + case 'string': + if (coalesceb(value) === null) { + value = true + } + break + } + + this.#queryObject.set(prop, value) + const newValue = Object.freeze(Object.fromEntries(this.#queryObject)) + + this.#querystring = Array.from(this.#queryObject).map(items => `${items[0]}=${items[1]}`).join('&') + this.emit('query.update', { old, new: newValue, parameter: { name: prop, old: oldParamVal, new: value } }) + return true + }, + + has: (obj, prop) => this.#queryObject.has(prop), + + deleteProperty: (obj, prop) => this.#queryObject.delete(prop), + + ownKeys: obj => Array.from(this.#queryObject.keys()), + + defineProperty: (obj, prop, descriptor) => { + if (coalesce(descriptor.enumerable, true)) { + this.#queryObject.add(prop, coalesce(descriptor.value, descriptor.get)) + } + }, + + getOwnPropertyDescriptor: (obj, prop) => { + const val = this.#queryObject.get(prop) + return { + enumerable: val !== undefined, + configurable: true, + writable: val !== undefined, + value: val + } + } + }) + + /** + * @property {string} scheme + * Alias for #property. + */ + this.alias('scheme', this.protocol) + } + + get protocol () { + !this.#parsed && this.#parse() + return this.#protocol + } + + set protocol (value) { + value = /^(.*):?(\/+?.*)?/i.exec(value) + + if (value !== null && value.length > 0 && value[1] !== this.#protocol) { + const old = this.#protocol + this.#protocol = value[1] + this.emit('update.protocol', { old, new: this.#protocol }) + } + } + + get username () { + !this.#parsed && this.#parse() + return this.#username + } + + set username (value) { + if (value.length > 0 && value !== this.#username) { + const old = this.#username + this.#username = value + this.emit('update.username', { old, new: value }) + } + } + + get password () { + !this.#parsed && this.#parse() + if (coalesceb(this.#password) === null) { + return null + } + + return this.#insecure ? this.#password : this.#password.replace(/./g, '*') + } + + set password (value) { + value = coalesceb(value) + + if ((value === null || value.length > 0) && value !== this.#password) { + let old = coalesce(this.#password, '') + if (!this.#insecure) { + old = old.replace(/./g, '*') + } + this.#password = value + this.emit('update.password', { old, new: !value ? null : value.replace(/./g, '*') }) + } + } + + get hostname () { + !this.#parsed && this.#parse() + return this.#hostname + } + + set hostname (value) { + value = coalesce(value, '').trim().toLowerCase() + if (value.length > 0 && value !== this.#hostname) { + const old = this.#hostname + this.#hostname = value + this.emit('update.hostname', { old, new: value }) + } + } + + get host () { + return `${this.hostname}:${this.port}` + } + + get port () { + !this.#parsed && this.#parse() + return coalesce(this.#port, this.defaultPort) + } + + set port (value) { + value = coalesceb(value, 'default') + if (typeof value === 'string') { + value = coalesce(this.#defaultPort[value.trim().toLowerCase()], this.defaultPort) + } + + if (value === null || value < 1 || value > 65535) { + throw new Error(`"${value}" is an invalid port. Must be a number between 1-65535, "default" to use the protocol's default port, or one of these recognized protocols: ${Object.keys(this.#defaultPort).join(', ')}.`) + } + + if (this.#port !== value) { + const old = this.#port + this.#port = value + this.emit('update.port', { old, new: this.port }) + } + } + + /** + * A method for resetting the port to the default. + * The default value is determined by the protocol. + * If the protocol is not recognized, port `80` is used. + * This is the equivalent of setting port = `null`. + */ + resetPort () { + this.port = null + } + + get defaultPort () { + return coalesce(this.#defaultPort[this.#protocol], this.#protocol !== 'file' ? 80 : null) + } + + get path () { + !this.#parsed && this.#parse() + return this.#path + } + + set path (value) { + value = coalesceb(value, '/') + if (value !== this.#path) { + const old = this.#path + this.#path = value + this.emit('update.path', { old, new: value }) + } + } + + get querystring () { + !this.#parsed && this.#parse() + return this.#querystring + } + + set querystring (value) { + value = coalesceb(value) || '' + if (value !== this.#querystring) { + const old = this.#querystring + this.#querystring = value + this.#queryObject = this.#deserializeQueryParameters(value) + this.emit('update.querystring', { old, new: value }) + } + } + + get hash () { + !this.#parsed && this.#parse() + return this.#hash + } + + set hash (value) { + value = coalesce(value, '').trim().replace(/^#+/, '') + + if (value !== this.#hash) { + const old = this.#hash + this.#hash = value + this.emit('update.hash', { old, new: value }) + } + } + + /** + * @property {string} + * The full URL represented by this object. + */ + get href () { + return this.toString() + } + + set href (uri) { + this.#originalURI = uri + this.#uri = uri + this.#parsed = false + this.#parse() + } + + get searchParams () { + !this.#parsed && this.#parse() + return this.#parts.searchParams + } + + get origin () { + !this.#parsed && this.#parse() + return this.#parts.origin + } + + get search () { + !this.#parsed && this.#parse() + return this.#parts.search + } + + get local () { + !this.#parsed && this.#parse() + return localschemes.has(this.hostname.toLowerCase()) + } + + /** + * The canonical URL as a string. + * @param {object} [cfg] + * There are a number of flags that can be used to change + * the result of this method. Refer to the following: + * + * http://username@password:domain.com/path/to/file.html?optionA=a&optionB=b#demo + * |__| |______| |______| |________||________________| |_________________| |__| + * 1 2 3 4 5 6 7 + * + * 1. Protocol/Scheme + * 1. Username + * 1. Password + * 1. Domain/Authority + * 1. Path + * 1. Querystring + * 1. Hash + * @param {boolean} [cfg.protocol=true] + * Generate the protocol/scheme (i.e. `http://`) + * @param { boolean } [cfg.hostname = true] + * Generate the hostname. + * @param { boolean } [cfg.username = false] + * Generate the username. Example: `https://username@hostname.com`. + * Setting this to `true` will force the hostname to also be generated, + * (even if hostname is set to `false`). + * @param { boolean} [cfg.password = false] + * Generate the password. Example: `https://username:pasword@domain.com`. + * This requires the `username` option to be `true`, and it will only be generated + * if a username exists. + * @param {boolean} [cfg.forcePort=false] + * By default, no port is output in the string for known + * protocols/schemes. Set this to `true` to force the + * output to contain the port. This is ignored for URL's + * with a `file` protocol. + * @param {boolean} [cfg.path=true] + * Generate the path. + * @param {boolean} [cfg.querystring=true] + * Generate the query string + * @param {boolean} [cfg.shrinkQuerystring=false] + * This unique flag can shrink boolean flags by stripping off `true` values and eliminating `false` parameters entirely. For + * example, a query string of `?a=true&b=false&c=demo` would become `?a&c=demo`. + * This is designed for interacting with API's that use "parameter presence" to toggle/filter responses, + * especially when there are many boolean query parameters in the URL. + * @param {boolean} [cfg.hash=true] + * Generate the hash value. + * @param { string } [cfg.queryMode] + * Override the default #queryMode ('boolean' by default). + * @warning Displaying the password in plain text is a security vulnerability. + */ + toString (cfg = {}) { + !this.#parsed && this.#parse() + + const uri = new URL(`${this.protocol}://nourl/`) + if (coalesce(cfg.path, true)) { + uri[uri.pathname ? 'pathname' : 'path'] = this.#path.replace(/\/{2,}|\\{2,}/g, '/') + } + + if (uri.protocol !== 'file') { + (cfg.username || cfg.password) && (uri.username = this.#username) + cfg.password && this.#password && (uri.password = this.#password) + coalesce(cfg.hostname, true) && (uri.hostname = this.#hostname) + + if (cfg.forcePort || this.port !== this.defaultPort) { + uri.port = this.port + } + + if (coalesce(cfg.hash, true) && coalesceb(this.#hash)) { + uri.hash = this.#hash + } + + if (coalesce(cfg.querystring, true) && this.queryParameterCount > 0) { + const qs = [] + for (const [key, value] of this.#queryObject) { + // Shrink + if (typeof value === 'boolean' && cfg.shrinkQuerystring) { + if (value) { + qs.push(key) + } + } else { + qs.push(`${key}${value.toString().trim().length === 0 ? '' : '=' + value}`) + } + } + + if (qs.length > 0) { + uri.search = qs.join('&') + } + } + } + + let result = uri.toString().replace(/\/\/nourl\//, '') + if (!coalesce(cfg.protocol, true)) { + result = result.replace(`${this.protocol}://`, '') + } + + if (cfg.forcePort && result.indexOf(`:${this.port}`) < 0) { + result = result.replace(uri.hostname, `${uri.hostname}:${this.port}`) + } + + return result + } + + /** + * Uses a find/replace strategy to generate a custom URL string. + * All variables surrounded in double brackets will be replaced + * by the URL equivalent. + * + * - `{{protocol}}` is the URL protocol, such as `http`, `https`, or `file`. + * - `{{separator}}` is what separates the protocol from everything else. Default is `://`. + * - `{{username}}` is the username. + * - `{{password}}` is the ** plain text ** password. + * - `{{hostname}}` is the domain/authority. + * - `{{port}}` is the port number, prefixed by `:` (i.e. `:port`). + * - `{{path}}` is the path. + * - `{{querystring}}` is the querystring prefixed by `?` (i.e. `?a=1&b=2`). + * - `{{hash}}` is the hash, prefixed by `#` (i.e. `#myhash`) + * @param {string} [template={{protocol}}{{separator}}{{hostname}}{{port}}{{path}}{{querystring}}{{hash}}] + * The template to use for constructing the output. + * @param {boolean} [encode=true] + * URI encode the result string. + * @param {string} [separator=://] + * The optional separator is defined dynamically, but defaults to `://`. + * @returns {string} + */ + formatString (template = '{{protocol}}{{separator}}{{hostname}}{{port}}{{path}}{{querystring}}{{hash}}', encode = true, separator = '://') { + const hasQueryString = this.queryParameterCount > 0 + const hasHash = coalesceb(this.#hash) !== null + const result = template + .replace(/{+protocol}+/gi, coalesce(this.protocol)) + .replace(/{+scheme}+/gi, coalesce(this.protocol)) + .replace(/{+username}+/gi, coalesce(this.username)) + .replace(/{+password}+/gi, coalesce(this.password)) + .replace(/{+hostname}+/gi, coalesce(this.hostname)) + .replace(/{+host}+/gi, coalesce(this.hostname)) + .replace(/{+port}+/gi, this.port === this.defaultPort ? '' : `:${this.port}`) + .replace(/{+path}+/gi, this.path) + .replace(/{+querystring}+/gi, hasQueryString ? '?' + this.querystring : '') + .replace(/{+query}+/gi, hasQueryString ? '?' + this.querystring : '') + .replace(/{+hash}+/gi, hasHash ? '#' + this.hash : '') + .replace(/{+separator}+/gi, separator) + + return encode ? encodeURI(result) : result + } + + /** + * Map a protocol to a default port. This will override any existing setting. + * Common TCP/UDP ports can be found [here](https://en.wikipedia.org/wiki/List_of_TCP_and_UDP_port_numbers). + * @param {string|object} protocol + * The protocol, such as `http`. Case insensitive. + * This can also be an object, specifying multiple protocol/port combinations. + * For example: + * ``` + * { + * snmp: 162, + * nntp: 563 + * } + * ``` + * @param {number} port + * The port number to assign as the protocol default. + */ + setDefaultProtocolPort (protocol, port) { + if (typeof protocol === 'object') { + Object.keys(protocol).forEach(key => this.setDefaultProtocolPort(key, protocol[key])) + return + } + + protocol = protocol.trim().toLowerCase() + port = forceNumber(port, 10) + const old = this.#defaultPort[protocol] + this.#defaultPort[protocol] = port + + if (old !== port) { + this.emit('update.defaultport', { + protocol, + old, + new: port + }) + } + } + + /** + * Remove default port mapping for a protocol. + * @param {string} protocol + * The protocol, such as `http`. Case insensitive. + * Multiple protocols can be specified, using multiple arguments `unsetDefaultProtocolPort('http', 'https', 'ssh')` + * @warning **DESTRUCTIVE METHOD:** If a default protocol was overridden, unsetting it with this method will not rollback to the prior value. + */ + removeDefaultProtocolPort () { + for (let protocol of arguments) { + protocol = protocol.trim().toLowerCase() + const old = this.#defaultPort[protocol] + + if (old) { + delete this.#defaultPort[protocol] + this.emit('delete.defaultport', { protocol, old }) + } + } + } + + /** + * Determine if accessing a URL is considered a cross origin request or part of the same domain. + * @param {string|URI} [alternativeUrl] + * Optionally provide an alternative URL to compare the #url with. + * @param {boolean} [strictProtocol=false] + * Requires the protocol to be the same (not just the hostname). + * @returns {boolean} + * `true` = same origin + * `false` = different origin (cross origin) + */ + isSameOrigin (url, strictProtocol = false) { + !this.#parsed && this.#parse() + const parts = typeof url === 'string' ? new URL(url, `${this.#protocol}://${this.#hostname}`) : url + const host = coalesceb(parts.hostname, this.#hostname) + return host === this.#hostname && (strictProtocol ? (parts.protocol === this.protocol) : true) + } + + get hasQueryParameters () { + return this.queryParameterCount > 0 + } + + get queryParameterCount () { + return Object.keys(this.query).length + } + + /** + * @property {string} [mode=boolean] (boolean, string, null) + * Specify how to treat "empty" query parameters. + * For example, a query string of `?a=1&b=demo&c` has a + * non-descript query parameter (`c`). The presence of + * this attribute suggests it could be a boolean (`true`). + * It could also be an empty string or a null value. + * + * The following modes are available: + * + * 1. `boolean`: Non-descript query parameters are assigned a value of `true` when present. + * 1. `string`: Non-descript query parameters are assigned a zero-length (empty) string. + * 1. `null`: Non-descript query parameters are assigned a value of `null`. + */ + set queryMode (mode = 'boolean') { + if (mode === null) { + NGN.WARN('Query mode was set to a null object. Expected a string (\'null\'). The value was auto-converted to a string, but this method is only supposed to receive string values.') + } + + mode = coalesce(mode, 'null').trim().toLowerCase() + + if (mode !== this.#querymode) { + if (new Set(['boolean', 'string', 'null']).has(mode)) { + const old = this.#querymode + this.#querymode = mode + this.emit('update.querymode', { old, new: mode }) + } + } + } +} diff --git a/src/lib/constants.js b/src/lib/constants.js new file mode 100644 index 0000000..e0913d3 --- /dev/null +++ b/src/lib/constants.js @@ -0,0 +1,85 @@ +import Reference from '@ngnjs/plugin' +const NGN = new Reference('>=2.0.0') + +export const DEFAULT_PORT = { + http: 80, + https: 443, + ssh: 22, + ldap: 389, + sldap: 689, + ftp: 20, + ftps: 989, + sftp: 21 +} + +export const HTTP_METHODS = new Set([ + 'OPTIONS', + 'HEAD', + 'GET', + 'POST', + 'PUT', + 'DELETE', + 'TRACE', + 'CONNECT' +]) + +export const CACHE_MODES = new Set([ + 'default', + 'no-store', + 'reload', + 'no-cache', + 'force-cache', + 'only-if-cached' +]) + +export const CORS_MODES = new Set([ + 'cors', + 'no-cors', + 'same-origin' +]) + +export const REFERRER_MODES = new Set([ + '', + 'no-referrer', + 'no-referrer-when-downgrade', + 'same-origin', + 'origin', + 'strict-origin', + 'origin-when-cross-origin', + 'strict-origin-when-cross-origin', + 'unsafe-url' +]) + +export const REQUEST_CREDENTIALS = new Set(['omit', 'same-origin', 'include']) + +export const IDEMPOTENT_METHODS = new Set(['OPTIONS', 'HEAD', 'GET']) +export const REQUEST_NOBODY_METHODS = new Set(['HEAD', 'GET']) + +export const URI_PATTERN = /^(([^:/?#]+):)?(\/\/([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?/ +export const URL_PATTERN = /^(([^:/?#]+):)(\/\/([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?/ +export const URL_RELATIVE_PATTERN = /^\.{1,2}\//gi + +let HOSTNAME = globalThis.location ? globalThis.location.host : 'localhost' +const interfaces = new Set([ + '127.0.0.1', + 'localhost', + HOSTNAME +]) + +// Attempt to retrieve local hostnames in Node.js runtime (using libnet-node when available) +if (NGN.runtime === 'node') { + (async () => { + try { + const polyfills = await import('@ngnjs/libnet-node') + polyfills.INTERFACES.forEach(i => interfaces.add(i)) + HOSTNAME = polyfills.HOSTNAME + } catch (e) { + if (e.code !== 'ERR_MODULE_NOT_FOUND') { + throw e + } + } + })() +} + +export { HOSTNAME } +export const INTERFACES = Array.from(interfaces) diff --git a/src/lib/fetch/constants.js b/src/lib/fetch/constants.js new file mode 100644 index 0000000..0f4b40f --- /dev/null +++ b/src/lib/fetch/constants.js @@ -0,0 +1,79 @@ +// HTTP Status Codes +export const cacheStatusCodes = new Set([412, 304]) + +// Identifiers +export const REDIRECTS = Symbol('redirect count') +export const BLOBS = new Set(['document', 'blog']) + +// HTTP Status Code Map +export const HTTP_STATUS = new Map([ + [100, 'Continue'], + [101, 'Switching Protocols'], + [102, 'Processing'], + [103, 'Early Hints'], + [200, 'OK'], + [201, 'Created'], + [202, 'Accepted'], + [203, 'Non - Authoritative Information'], + [204, 'No Content'], + [205, 'Reset Content'], + [206, 'Partial Content'], + [207, 'Multi - Status'], + [208, 'Already Reported'], + [226, 'IM Used'], + [300, 'Multiple Choices'], + [301, 'Moved Permanently'], + [302, 'Found'], + [303, 'See Other'], + [304, 'Not Modified'], + [305, 'Use Proxy'], + [306, 'Switch Proxy'], + [307, 'Temporary Redirect'], + [308, 'Permanent Redirect'], + [400, 'Bad Request'], + [401, 'Unauthorized'], + [402, 'Payment Required'], + [403, 'Forbidden'], + [404, 'Not Found'], + [405, 'Method Not Allowed'], + [406, 'Not Acceptable'], + [407, 'Proxy Authentication Required'], + [408, 'Request Timeout'], + [409, 'Conflict'], + [410, 'Gone'], + [411, 'Length Required'], + [412, 'Precondition Failed'], + [413, 'Payload Too Large'], + [414, 'URI Too Long'], + [415, 'Unsupported Media Type'], + [416, 'Range Not Satisfiable'], + [417, 'Expectation Failed'], + [418, 'I\'m a teapot'], + [421, 'Misdirected Request'], + [422, 'Unprocessable Entity'], + [423, 'Locked'], + [424, 'Failed Dependency'], + [425, 'Too Early'], + [426, 'Upgrade Required'], + [428, 'Precondition Required'], + [429, 'Too Many Requests'], + [431, 'Request Header Fields Too Large'], + [451, 'Unavailable For Legal Reasons'], + [500, 'Internal Server Error'], + [501, 'Not Implemented'], + [502, 'Bad Gateway'], + [503, 'Service Unavailable'], + [504, 'Gateway Timeout'], + [505, 'HTTP Version Not Supported'], + [506, 'Variant Also Negotiates'], + [507, 'Insufficient Storage'], + [508, 'Loop Detected'], + [510, 'Not Extended'], + [511, 'Network Authentication Required'] +]) + +export const HTTP_INFO = new Set([100, 101, 102, 103]) +export const HTTP_SUCCESS = new Set([200, 201, 202, 203, 204, 205, 206, 207, 208, 226]) +export const HTTP_REDIRECT = new Set([301, 302, 303, 304, 305, 306, 307, 308]) +export const HTTP_CLIENT_ERROR = new Set([400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418, 421, 422, 423, 424, 425, 426, 428, 429, 431, 451]) +export const HTTP_SERVER_ERROR = new Set([500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511]) diff --git a/src/lib/fetch/fetch.js b/src/lib/fetch/fetch.js new file mode 100644 index 0000000..d2e400b --- /dev/null +++ b/src/lib/fetch/fetch.js @@ -0,0 +1,78 @@ +import { BLOBS } from './constants.js' +import { coalesce } from '@ngnjs/libdata' + +export default function Fetch (resource, init = {}) { + return new Promise((resolve, reject) => { + return globalThis.fetch(resource.href, init).then(async r => { + try { + const rt = init.responseType + // The result object is explicitly defined because + // some headless browsers were not recognizing the attributes + // (non-enumerable). This tactic changes the property definition, + // making them accessible to all runtimes. + const result = Object.assign({ + status: r.status, + statusText: r.statusText, + headers: r.headers, + ok: r.ok, + redirected: r.redirected, + trailers: r.trailers, + type: r.type, + url: r.url + }, { + body: init.body, + responseText: '' + }) + + let body = '' + if (rt === 'arraybuffer') { + body = await r.arrayBuffer().catch(reject) + } else if (BLOBS.has(rt)) { + body = await r.blob().catch(reject) + } else { + body = await r.text().catch(reject) + } + + switch (rt) { + case 'document': + if (/^text\/.*/.test(body.type)) { + result.responseText = await body.text().catch(reject) + break + } + case 'arraybuffer': // eslint-disable-line no-fallthrough + case 'blob': + result.body = new globalThis.Blob(body.slice(), { type: coalesce(result.headers['content-type']) }) + break + default: + result.responseText = body || result.statusText + } + + Object.defineProperty(result, 'JSON', { + enumerable: true, + get () { try { return JSON.parse(result.responseText) } catch (e) { return null } } + }) + + const hiddenBody = result.body + const hiddenSource = result._bodySource + const hiddenStream = result._stream + delete result.body + delete result._bodySource + delete result._stream + Object.defineProperties(result, { + body: { get: () => hiddenBody || result.responseText }, + _bodySource: { get: () => hiddenSource }, + _stream: { get: () => hiddenStream } + }) + + if (init.method === 'HEAD') { + result.responseText = '' + } + + return resolve(result) + } catch (e) { + console.log(e) + reject(e) + } + }).catch(reject) + }) +} diff --git a/src/lib/fetch/index.js b/src/lib/fetch/index.js new file mode 100644 index 0000000..2d407ad --- /dev/null +++ b/src/lib/fetch/index.js @@ -0,0 +1,83 @@ +/** + * This is an internal module, used for making network + * requests. NGN is cross-runtime, but Node.js does not + * support the fetch API. This method is polyfilled, + * matching the fetch API and supplying a consistent + * interface across runtimes. + * + * Node lacks caching, subresource identification, and + * referral policies. These features are stubbed and + * will not work on on their own. However; these + * features are available in the libnet-node plugin for + * NGN. This module attempts to auto-load the + * libnet-node plugin in Node.js environments. If the + * module is found, the additional capabilities are + * automatically enabled. + */ +import Reference from '@ngnjs/plugin' +import { coalesceb } from '@ngnjs/libdata' +import { REDIRECTS } from './constants.js' + +const NGN = new Reference('>=2.0.0').requires('runtime', 'INFO') + +// Load the runtime-specific fetch method. +// Both runtime fetch methods are abstracted from this +// file in an effort to reduce code size. In many cases, +// the additional code from the unnecessary runtime does +// not add much additional overhead, but this strategy +// will eliminate unnecessary code in the tree-shaking +// phase of any build process. +let request +let POLYFILLED = false +; (async () => { + if (!request) { + if (NGN.runtime === 'node') { + request = await import('./node.js').catch(console.error) + POLYFILLED = request.POLYFILLED !== undefined ? request.POLYFILLED : POLYFILLED + } else { + request = await import('./fetch.js').catch(console.error) + } + + request = request.default + } +})() + +export { POLYFILLED } + +export default async function Fetch (resource, init, caller = null) { + init = init || {} + init.method = typeof init.method === 'string' ? init.method.toUpperCase() : 'GET' + init.cache = coalesceb(init.cache, 'default') + init.redirect = coalesceb(init.redirect, 'follow') + init.responseType = coalesceb(init.responseType, 'text') + init.referrerPolicy = coalesceb(init.referrerPolicy, 'unsafe-url') + init[REDIRECTS] = coalesceb(init[REDIRECTS], 0) + resource = typeof resource === 'string' ? new URL(resource) : resource + + return new Promise((resolve, reject) => { + if (!init.method) { + return reject(new Error('An HTTP method is required.')) + } + + if (init.method === 'CONNECT') { + return reject(new Error('CONNECT is not a valid fetch method. It is only used for opening network tunnels, not complete HTTP requests.')) + } + + request(resource, init).then(r => { + resolve(r) + + const redirects = init[REDIRECTS] + delete init[REDIRECTS] + + NGN.INFO('HTTP Request', { + request: Object.assign({}, { + url: resource.href, + redirects + }, init), + response: r, + caller, + runtime: NGN.runtime + }) + }).catch(reject) + }) +} diff --git a/src/lib/fetch/node.js b/src/lib/fetch/node.js new file mode 100644 index 0000000..2722c7c --- /dev/null +++ b/src/lib/fetch/node.js @@ -0,0 +1,191 @@ +import Reference from '@ngnjs/plugin' +import { coalesce, coalesceb } from '@ngnjs/libdata' +import { cacheStatusCodes, HTTP_REDIRECT, REDIRECTS } from './constants.js' +import { HOSTNAME, URL_PATTERN } from '../constants.js' + +const NGN = new Reference('>=2.0.0').requires('runtime', 'WARN', 'private') + +// Stubs for Node +let POLYFILLED = false +let hostname = HOSTNAME +let http +let https +let cache = { get: () => undefined, put: (req, res) => res, capture () { } } +let SRI = { verify: () => false } // subresource identity support +function ReferrerPolicy (policy) { this.policy = policy } +ReferrerPolicy.prototype.referrerURL = uri => uri + +// Apply libnet-node plugin, if available, to override stubs +if (NGN.runtime === 'node') { + ; (async () => { + http = await import('http').catch(console.error) + https = await import('https').catch(console.error) + + // Do not abort if the Node library is inaccessible. + try { + const polyfills = await import('@ngnjs/libnet-node') + + SRI = polyfills.SRI + ReferrerPolicy = polyfills.ReferrerPolicy // eslint-disable-line no-func-assign + cache = new polyfills.Cache(coalesce(process.env.HTTP_CACHE_DIR, 'memory')) + hostname = polyfills.HOSTNAME + POLYFILLED = true + } catch (e) { + NGN.WARN('fetch', e) + } + })() +} + +// export default function Fetch (resource, init) { +// return new Promise(resolve => resolve()) +// } + +function result (result) { + Object.defineProperties(result, { + JSON: { + enumerable: true, + get () { try { return JSON.parse(result.responseText) } catch (e) { return null } } + }, + body: { get: () => result.responseText } + }) + + return result +} + +function cleanResponse (res, status, responseText = '', statusText = '') { + status = coalesce(status, res.statusCode, res.status, 500) + + const r = { + status, + statusText: coalesceb(res.statusMessage, res.statusText, http.STATUS_CODES[status]), + responseText: coalesceb(res.responseText, (status >= 300 ? http.STATUS_CODES[status] : res.responseText)) || '', + headers: res.headers, + trailers: res.trailers, + url: res.url + } + + if (res.redirected) { + r.redirected = res.redirected + } + + return r +} + +export default function Fetch (resource, init = {}) { + if (!(resource instanceof URL)) { + if (!URL_PATTERN.test(resource)) { + resource = new URL(`http://${hostname}/${resource}`) + } else { + resource = new URL(resource) + } + } + + return new Promise((resolve, reject) => { + // Mimic browser referrer policy action + let referrer = coalesceb(init.referrer, 'http://' + hostname) + if (!URL_PATTERN.test(referrer)) { + referrer = new URL(`http://${hostname}/${referrer}`) + } else { + referrer = new URL(referrer) + } + + init.referrer = new ReferrerPolicy(init.referrerPolicy).referrerURL(referrer.href, resource.href) || '' + + if (init.referrer.trim().length > 0) { + init.headers = init.headers || {} + init.headers.Referer = init.referrer + } + + const signal = init.signal + delete init.signal + + const net = (resource.protocol === 'https:' ? https : http) + const req = net.request(resource.href, init, res => { + let body = '' + + res.setEncoding('utf8') + res.on('data', c => { body += c }) + res.on('end', () => { + if (cacheStatusCodes.has(res.statusCode)) { + // Respond from cache (no modifications) + const response = cache.get(resource.toString()) + return response + ? resolve(result(response)) + : resolve(cache.put(req, cleanResponse(res, 500, 'Failed to retrieve cached response.'), init.cache)) + } else if (HTTP_REDIRECT.has(res.statusCode)) { + // Redirect as necessary + switch (init.redirect) { + case 'manual': + return resolve(result(cleanResponse(res))) + case 'follow': + if (!res.headers.location) { + return resolve(result(cache.put(req, cleanResponse(res, 502), init.cache))) + } else { + if (init[REDIRECTS] >= 10) { + return resolve(result(cache.put(req, cleanResponse(res, 500, 'Too many redirects'), init.cache))) + } + + init[REDIRECTS]++ + + return Fetch(res.headers.location, init).then(r => { + r.redirected = true + r.url = res.headers.location + resolve(result(cache.put(req, cleanResponse(r), init.cache))) + }).catch(reject) + } + } + + return reject(new Error(`Refused to redirect ${resource.toString()} -> ${res.headers.location || 'Unknown/Missing Location'}`)) + } + + if (init.integrity) { + const integrity = SRI.verify(init.integrity, body) + if (!integrity.valid) { + return reject(new Error(integrity.reason)) + } + } + + res.responseText = body + + if (init.method === 'HEAD') { + res.responseText = '' + } + + const r = cache.put(req, cleanResponse(res)) + + resolve(result(r)) + }) + }) + + // Apply the abort manager to the request + if (signal) { + signal(req) + } + + req.on('error', reject) + req.on('timeout', () => { + req.destroy() + reject(new Error('Timed out.')) + }) + req.setNoDelay(true) + + if (init.body) { + req.write(init.body) + } + + if (init.cache !== 'reload' && init.cache !== 'no-cache') { + // Check the cache first in Node environments + const cached = cache.get(req, init.cache) + if (cached) { + req.abort() // Prevents orphan request from existing (orphans cause the process to hang) + return resolve(cached.response) + } + + cache.capture(req, init.cache) + } + + req.end() + }) +} + +export { POLYFILLED } diff --git a/src/lib/map.js b/src/lib/map.js new file mode 100644 index 0000000..dbccd87 --- /dev/null +++ b/src/lib/map.js @@ -0,0 +1,170 @@ +import Reference from '@ngnjs/plugin' +import { forceString } from '@ngnjs/libdata' + +const NGN = new Reference('^2.0.0').requires('EventEmitter') + +/** + * This class provides an Map object with additional + * methods, such as `append` & `toObject`. It extends `NGN.EventEmitter`, + * making it possible to observe events. It also provides optional + * key normalization (case insensitivity). + */ +export default class EnhancedMap extends NGN.EventEmitter { + #values + #prefix + #keyCase = null + #key = key => { + key = forceString(key) + return this.#keyCase === 'lower' ? key.toLowerCase() : (this.#keyCase === 'upper' ? key.toUpperCase() : key) + } + + constructor (init = {}, keyCase = null, prefix = '') { + super() + this.#keyCase = keyCase + if (keyCase === null) { + this.#values = new Map(Object.entries(init)) + } else { + this.#values = new Map() + for (const [name, value] of Object.entries(init)) { + this.#values.set(this.#key(name), value) + } + } + this.#prefix = prefix.trim().length === 0 ? '' : (prefix.trim() + '.').replace(/\.{2,}/gi, '.') + } + + get size () { + return this.#values.size + } + + /** + * Appends a new value onto an existing value, or adds the value if it does not already exist. + * @param {string} name + * @param {string} value + */ + append (name, value) { + const key = this.#key(name) + const old = this.#values.get(key) + + // Do not shorten this to if (old), because the retrieved value could be null/undefined. + if (this.#values.has(key)) { + value = old.split(', ').concat([value]).join(', ') + } + + this.#values.set(key, value) + if (old) { + this.emit(`${this.#prefix}update`, { name, old, new: value }) + } else { + this.emit(`${this.#prefix}create`, { name, value }) + } + } + + /** + * Deletes a value. + * @param {string} name + */ + delete (name) { + const key = this.#key(name) + if (this.#values.has(key)) { + const old = this.#values.get(key) + this.#values.delete(key) + this.emit(`${this.#prefix}delete`, { name, value: old }) + } + } + + /** + * Returns an iterator to loop through all key/value pairs contained in the object. + * @return {Iterable} + */ + entries () { + return this.#values.entries() + } + + /** + * Executes a function once for each element. + * @param {function} handler + * Run the handler on each header entry. + * @return { Iterable } + */ + forEach (fn) { + return this.#values.entries.forEach(fn) + } + + /** + * Returns the value fort the specified name. + * @param {string} name + * @return {any} + */ + get (name) { + return this.#values.get(this.#key(name)) + } + + /** + * Returns a boolean stating whether the specified key name exists. + * @param {string} name + * @return {boolean} + */ + has (name) { + return this.#values.has(this.#key(name)) + } + + /** + * Returns an iterator to loop through all keys of the available key/value pairs. + * @return { Iterable } + */ + keys () { + return this.#values.keys() + } + + /** + * Sets a new value for an existing name, or creates a new entry if the name doesn't aleady exist. + * @param {string} name + * @param {string} value + */ + set (name, value) { + const key = this.#key(name) + const exists = this.#values.has(key) + const old = this.#values.get(key) + + this.#values.set(key, value) + + if (!exists) { + this.emit(`${this.#prefix}create`, { name, value }) + } else if (old !== value) { + this.emit(`${this.#prefix}update`, { name, old, new: value }) + } + + return this.#values + } + + /** + * Returns an iterator to loop through all values of the key/value pairs. + * @return { Iterable } + */ + values () { + return this.#values.values() + } + + /** + * Remove all entries + */ + clear () { + const entries = this.#values.entries() + this.#values.clear() + + for (const [name, value] of entries) { + this.emit(`${this.#prefix}delete`, { name, value }) + } + } + + toString () { + return this.#values.toString() + } + + toObject () { + return Object.fromEntries(this.#values) + } + + get Map () { + return this.#values.Map + } +} diff --git a/tests/00-sanity.js b/tests/00-sanity.js new file mode 100644 index 0000000..c54f7cd --- /dev/null +++ b/tests/00-sanity.js @@ -0,0 +1,16 @@ +import test from 'tappedout' +import ngn from 'ngn' +import * as NET from '@ngnjs/net' + +test('Sanity', t => { + t.expect('function', typeof NET.Client, 'NET.Client class available.') + t.expect('function', typeof NET.Resource, 'NET.Resource class available.') + t.expect('function', typeof NET.Request, 'NET.Request class available.') + t.expect('function', typeof NET.URL, 'NET.URL class available.') + t.expect('function', typeof NET.Fetch, 'NET.Fetch method available.') + t.expect('string', typeof NET.HOSTNAME, 'NET.HOSTNAME available.') + t.expect('boolean', typeof NET.POLYFILLED, 'Indicate the Node polyfill is active.') + t.expect(true, Array.isArray(NET.INTERFACES), 'Interfaces are available.') + + t.end() +}) diff --git a/tests/01-url.js b/tests/01-url.js new file mode 100644 index 0000000..fe886d8 --- /dev/null +++ b/tests/01-url.js @@ -0,0 +1,143 @@ +import test from 'tappedout' +import ngn from 'ngn' +import { URL as Address } from '@ngnjs/net' + +test('URI: Basic Parsing', t => { + let url = new Address() + + t.expect('/', url.path, 'Recognizes current root as base path when no root is specified.') + t.expect('http', url.protocol, 'Defaults to HTTP protocol.') + t.expect('http', url.scheme, 'Scheme alias returns appropriate protocol.') + + url = new Address('https://domain.com/path/to/file.html?min=0&max=1&safe#h1') + t.expect('https', url.protocol, 'Proper protocol identified.') + t.expect('domain.com', url.hostname, 'Identified hostname.') + t.expect(443, url.port, 'Correctly identifies appropriate default port for known protocols.') + t.expect('/path/to/file.html', url.path, 'Identified the path.') + t.expect('min=0&max=1&safe', url.querystring, 'Identified correct query string.') + t.expect('h1', url.hash, 'Identified correct hash value.') + + url = new Address('https://domain.com:4443/path/to/file.html?min=0&max=1&safe#h1') + t.expect('https', url.protocol, 'Proper protocol identified.') + t.expect('domain.com', url.hostname, 'Identified hostname.') + t.expect(4443, url.port, 'Correctly identifies custom port for known protocols.') + t.expect('/path/to/file.html', url.path, 'Identified the path.') + t.expect('min=0&max=1&safe', url.querystring, 'Identified correct query string.') + t.expect('h1', url.hash, 'Identified correct hash value.') + // t.ok(true, url.toString()) + + t.end() +}) + +test('URL: Basic Modifications', t => { + const url = new Address('https://domain.com:4443/path/to/file.html?min=0&max=1&safe#h1') + + url.port = 'default' + t.expect(443, url.port, 'Setting port to "default" leverages known protocols to determine port.') + url.port = 7777 + t.expect(7777, url.port, 'Setting a non-standard port still works.') + + t.throws(() => { url.port = 70000 }, 'Settting the port over 65535 throws an error.') + t.throws(() => { url.port = 0 }, 'Settting the port below 1 throws an error.') + + url.resetPort() + t.expect(443, url.port, 'Port successfully cleared.') + // t.comment(url.toString()) + t.expect('https://domain.com/path/to/file.html?min=0&max=1&safe=true#h1', url.toString(), 'Port not displayed after being cleared with a well known protocol.') + t.expect('https://domain.com:443/path/to/file.html?min=0&max=1&safe=true#h1', url.toString({ forcePort: true }), 'Port still displayed after being cleared with a well known protocol (forcing port in toString).') + + t.end() +}) + +test('URL: Query Parameters', t => { + const url = new Address('https://domain.com:4443/path/to/file.html?min=0&max=1&safe#h1') + t.expect(3, Object.keys(url.query).length, 'Query object enumerates values correctly.') + t.expect(0, url.query.min, 'Identify numeric attributes.') + t.ok(url.query.safe, 'Identify boolean attributes.') + + delete url.query.max + t.ok(Object.keys(url.query).length === 2, 'Query object enumerates values correctly after deletion.') + + url.query.test = 'value' + t.expect('value', url.query.test, 'Adding new query parameter is reflected in query object.') + t.expect('min=0&safe=true&test=value', url.querystring, 'Querystring modifications reflect query object.') + t.expect('https://domain.com:4443/path/to/file.html?min=0&safe=true&test=value#h1', url.toString(), 'Querystring present with non-default port.') + + url.querystring = 'a=a&b&c=c' + t.expect('https://domain.com:4443/path/to/file.html?a=a&b&c=c#h1', url.toString({ shrinkQuerystring: true }), 'Overwriting querystring generates appropriate URL.') + t.ok(url.query.b, 'Overwritten query object returns appropriate new default values.') + url.query.b = false + t.expect('https://domain.com:4443/path/to/file.html?a=a&b=false&c=c#h1', url.toString(), 'Overwriting querystring generates appropriate URL.') + t.ok(url.query.a === 'a' && url.query.b === false && url.query.c === 'c', 'Overwritten query string is properly reflected in query object.') + t.end() +}) + +test('URL: Hash', t => { + const url = new Address('https://domain.com:4443/path/to/file.html?min=0&max=1&safe#h1') + + t.expect('h1', url.hash, 'Properly parsed hash.') + url.hash = 'h2' + t.expect('h2', url.hash, 'Properly updated hash.') + t.expect('https://domain.com:4443/path/to/file.html?min=0&max=1&safe=true#h2', url.toString(), 'Hash update reflected in URL.') + t.expect('https://domain.com:4443/path/to/file.html?min=0&max=1&safe=true', url.toString({ hash: false }), 'Hash successfully ignored.') + + url.hash = null + t.expect('https://domain.com:4443/path/to/file.html?min=0&max=1&safe=true', url.toString(), 'Hash successfully removed.') + + t.end() +}) + +test('URL: Credentials', t => { + const url = new Address('https://admin:supersecret@domain.com:4443/path/to/file.html?min=0&max=1&safe#h1') + + t.expect('admin', url.username, 'Successfully parsed username.') + t.expect('***********', url.password, 'Successfully parsed and hid password.') + t.expect('https://domain.com:4443/path/to/file.html?min=0&max=1&safe=true#h1', url.toString(), 'Credentials are not generated in toString by default.') + t.expect('https://admin:supersecret@domain.com:4443/path/to/file.html?min=0&max=1&safe=true#h1', url.toString({ username: true, password: true }), 'Credentials are generated in toString when requested.') + t.expect('https://admin:supersecret@domain.com:4443/path/to/file.html?min=0&max=1&safe=true#h1', url.toString({ password: true }), 'Credentials are generated in toString when password is requested.') + t.expect('https://admin@domain.com:4443/path/to/file.html?min=0&max=1&safe=true#h1', url.toString({ username: true }), 'Username is generated in toString when requested.') + url.password = null + t.expect('https://admin@domain.com:4443/path/to/file.html?min=0&max=1&safe=true#h1', url.toString({ username: true, password: true }), 'Username is generated in toString when credentials are requested but only a username exists.') + + t.end() +}) + +test('URL: Formatting', t => { + const url = new Address('https://admin:supersecret@domain.com:443/path/to/file.html?min=0&max=1&safe#h1') + + t.expect('https://domain.com/path/to/file.html?min=0&max=1&safe#h1', url.formatString(), 'Standard formatting works.') + t.expect('https://domain.com', url.formatString('{{protocol}}://{{hostname}}'), 'Basic formatting works.') + t.expect('https://domain.com/#h1', url.formatString('{{protocol}}://{{hostname}}/{{hash}}'), 'Basic formatting works.') + + t.expect('https://domain.com/path/to/file.html', url.toString({ querystring: false, hash: false }), 'Configuration options in toString works properly.') + t.end() +}) + +test('URL: Special Protocols', t => { + const url = new Address('mailtob://john@doe.com') + + url.setDefaultProtocolPort('mailtob', 587) + t.expect(587, url.port, 'Successfully mapped a custom protocol to a default port.') + + url.setDefaultProtocolPort({ + snmp: 162, + ssh: 2222 + }) + + url.href = 'ssh:user@domain.com' + t.expect(2222, url.port, 'Overriding a default port is successful.') + + url.removeDefaultProtocolPort('ssh', 'mailtob') + t.expect(2222, url.port, 'Unsetting a default port does not change the current value of the port.') + + url.resetPort() + t.expect(80, url.port, 'Resetting a port with a removed default protocol defaults to port 80.') + + t.end() +}) + +test('URL: Local Path Parsing', t => { + const tmpurl = new Address('path/to/file.html') + t.expect('/path/to/file.html', tmpurl.path, 'Properly handles local paths.') + t.end() +}) diff --git a/tests/02-fetch.js b/tests/02-fetch.js new file mode 100644 index 0000000..49f0a81 --- /dev/null +++ b/tests/02-fetch.js @@ -0,0 +1,132 @@ +import test from 'tappedout' +import ngn from 'ngn' +import { Fetch, URL as Address, HOSTNAME, POLYFILLED } from '@ngnjs/net' + +const root = new Address('http://localhost') +function route (route) { + root.path = route; + return root +} + +test('Sanity Check', t => { + t.expect('function', typeof Fetch, 'Fetch is recognized.') + t.end() +}) + +test('Basic Requests', async t => { + let res + res = await Fetch(route('/basic'), { method: 'OPTIONS' }).catch(console.error) + t.expect(200, res.status, 'OPTIONS request returns a success status code.') + + res = await Fetch(route('/basic'), { method: 'HEAD' }).catch(t.fail) + t.expect(200, res.status, 'HEAD request returns a success status code.') + + res = await Fetch(route('/basic'), { method: 'GET' }).catch(console.error) + t.expect(200, res.status, 'GET request returns a success status code.') + + res = await Fetch(route('/basic'), { method: 'POST' }).catch(t.fail) + t.expect(200, res.status, 'POST request returns a success status code.') + + res = await Fetch(route('/basic'), { method: 'PUT' }).catch(t.fail) + t.expect(200, res.status, 'PUT request returns a success status code.') + + res = await Fetch(route('/basic'), { method: 'DELETE' }).catch(t.fail) + t.expect(200, res.status, 'DELETE request returns a success status code.') + + if (ngn.runtime !== 'browser') { + res = await Fetch(route('/basic'), { method: 'TRACE' }).catch(t.fail) + t.expect(200, res.status, 'TRACE request returns a success status code.') + } else { + t.pass('Skip TRACE method (not used in browsers).') + } + + // CONNECT should not be used. It is used to open a tunnel, not make a complete request. + // await Fetch(route('/basic'), { method: 'CONNECT' }) + // .then(() => t.fail('CONNECT method is not allowed.')) + // .catch(() => t.pass('CONNECT method is not allowed.')) + + t.end() +}) + +test('Redirect Support', async t => { + let res + const redirect = route('/redirect').href + const endpoint = route('/endpoint').href + + res = await Fetch(redirect, { method: 'HEAD' }).catch(t.fail) + t.expect(200, res.status, 'HEAD request returns a success status code.') + t.expect(true, res.redirected, 'HEAD response recognizes it was redirected.') + t.expect(endpoint, res.url, 'HEAD request recognized the redirect URL.') + + res = await Fetch(redirect, { method: 'GET' }).catch(console.error) + t.expect(200, res.status, 'GET request returns a success status code.') + t.expect(true, res.redirected, 'GET response recognizes it was redirected.') + t.expect(true, res.JSON.data, 'Redirected GET request receives data payload.') + t.expect(endpoint, res.url, 'GET request recognized the redirect URL.') + + res = await Fetch(redirect, { method: 'POST' }).catch(t.fail) + t.expect(200, res.status, 'POST request returns a success status code.') + t.expect(true, res.redirected, 'POST response recognizes it was redirected.') + t.expect(true, res.JSON.data, 'Redirected POST request receives data payload.') + t.expect(endpoint, res.url, 'POST request recognized the redirect URL.') + + res = await Fetch(redirect, { method: 'PUT' }).catch(t.fail) + t.expect(200, res.status, 'PUT request returns a success status code.') + t.expect(true, res.redirected, 'PUT response recognizes it was redirected.') + t.expect(true, res.JSON.data, 'Redirected PUT request receives data payload.') + t.expect(endpoint, res.url, 'PUT request recognized the redirect URL.') + + res = await Fetch(redirect, { method: 'DELETE' }).catch(t.fail) + t.expect(200, res.status, 'DELETE request returns a success status code.') + t.expect(true, res.redirected, 'DELETE response recognizes it was redirected.') + t.expect(true, res.JSON.data, 'Redirected DELETE request receives data payload.') + t.expect(endpoint, res.url, 'DELETE request recognized the redirect URL.') + + if (ngn.runtime !== 'browser') { + res = await Fetch(redirect, { method: 'TRACE' }).catch(t.fail) + t.expect(200, res.status, 'TRACE request returns a success status code.') + t.expect(true, res.redirected, 'TRACE response recognizes it was redirected.') + t.expect(true, res.JSON.data, 'Redirected TRACE request receives data payload.') + t.expect(endpoint, res.url, 'TRACE request recognized the redirect URL.') + } else { + t.pass(`TRACE redirect test skipped (not applicable to ${ngn.runtime} runtime.`) + } + + t.end() +}) + +test('Referrer-Policy', async t => { + const host = encodeURIComponent(`http://${HOSTNAME}/run/tests/02-fetch.js`) + let res + + if (ngn.runtime === 'browser' || POLYFILLED) { + res = await Fetch(route(`/refer/no-referrer/${host}`), { referrerPolicy: 'no-referrer' }).catch(console.error) + t.expect(200, res.status, 'Expected no referer header present on request.') + + res = await Fetch(route(`/refer/unsafe-url/${host}`), { referrerPolicy: 'unsafe-url' }).catch(console.error) + t.expect(200, res.status, 'Expected the presence of a Referer HTTP header for unsafe-url referral.') + + res = await Fetch(route(`/refer/origin/${encodeURIComponent('http://' + HOSTNAME)}`), { referrerPolicy: 'origin' }).catch(console.error) + t.expect(200, res.status, 'Expected the presence of a Referer HTTP header for origin referral.') + + res = await Fetch(route(`/refer/same-origin/${encodeURIComponent('http://' + HOSTNAME)}`), { referrerPolicy: 'same-origin' }).catch(console.error) + t.expect(200, res.status, 'Expected the presence of a Referer HTTP header for same-origin referral.') + + res = await Fetch(route(`/refer/strict-origin/${encodeURIComponent('http://' + HOSTNAME)}`), { referrerPolicy: 'strict-origin' }).catch(console.error) + t.expect(200, res.status, 'Expected the presence of a Referer HTTP header for strict-origin referral.') + } else { + t.comment('Referrer-Policy tests ignored in runtimes when "@ngnjs/libnet-node" polyfill is unavailable.') + } + + // Skip no-referrer-when-downgrade, origin-when-cross-origin, & strict-origin-when-cross-origin + // because we cannot replicate cross origin requests without additional test scaffolding. + t.end() +}) + +test.todo('Request Cache', async t => { + t.end() +}) + +test.todo('CORS Mode', async t => { + t.end() +}) \ No newline at end of file diff --git a/tests/03-request.js b/tests/03-request.js new file mode 100644 index 0000000..40ea5b7 --- /dev/null +++ b/tests/03-request.js @@ -0,0 +1,123 @@ +import test from 'tappedout' +import ngn from 'ngn' +import { Request as NGNRequest } from '@ngnjs/net' // Rename to prevent conflicts in browser tests + +const root = 'http://localhost' + +test('NGN HTTP Request Sanity', t => { + t.expect('function', typeof NGNRequest, 'Request class recognized.') + t.end() +}) + +test('NGN HTTP Request Configuration', t => { + const request = new NGNRequest({ + username: 'test', + password: 'test', + proxyUsername: 'user', + proxyPassword: 'pass', + url: root + '/?a=1', + body: { + test: 'test' + } + }) + + request.setHeader('X-NGN', 'test') + + t.expect('test', request.getHeader('X-NGN'), 'Request.getHeader returns proper value.') + t.expect(undefined, request.password, 'Password is not exposed.') + t.expect('localhost', request.hostname, 'Properly parsed hostname') + t.expect('http', request.protocol, 'Properly parsed protocol.') + t.expect('GET', request.method, 'Defaults to GET method.') + t.expect('test', request.headers.get('x-ngn'), 'Custom header applied.') + t.ok(request.headers.has('content-length'), 'Content-Length header present for secure requests (basic auth).') + t.ok(request.headers.has('content-type'), 'Content-Type header present for secure requests (basic auth).') + t.ok(request.headers.has('authorization'), 'Authorization header present for secure requests (basic auth).') + t.ok(request.headers.has('proxy-authorization'), 'Proxy-Authorization header present for proxied requests (basic auth).') + t.expect('application/json', request.headers.get('content-type'), 'Content-Type correctly identifies JSON.') + t.expect(15, request.headers.get('content-length'), 'Content-Type correctly identifies length of JSON string.') + t.expect(0, request.headers.get('authorization').indexOf('Basic '), 'Authorization basic auth digest correctly assigned to header.') + t.expect('', request.hash, 'Correct hash identified.') + + // t.ok(request.maxRedirects === 10, 'Default to a maximum of 10 redirects.') + // request.maxRedirects = 30 + // t.ok(request.maxRedirects === 25, 'Prevent exceeding 25 redirect threshold.') + // request.maxRedirects = -1 + // t.ok(request.maxRedirects === 0, 'Do not allow negative redirect maximum.') + // request.maxRedirects = 15 + // t.ok(request.maxRedirects === 15, 'Support custom redirect maximum between 0-25.') + + request.accessToken = '12345' + + t.ok(request.headers.has('authorization'), 'Authorization header present for secure requests (token).') + t.expect('Bearer 12345', request.headers.get('authorization'), 'Authorization token correctly assigned to header.') + + request.proxyAccessToken = '12345' + + t.ok(request.headers.has('proxy-authorization'), 'Proxy-Authorization header present for proxied requests (token).') + t.expect('Bearer 12345', request.headers.get('proxy-authorization'), 'Proxy-Authorization token correctly assigned to header.') + + t.ok(request.crossOriginRequest, 'Correctly identifies request as a cross-domain request.') + t.expect(1, request.queryParameterCount, 'Correctly identifies and parses query parameters.') + + request.removeHeader('X-NGN') + t.ok(!request.headers.has('x-ngn'), 'Removed header no longer part of request.') + + request.setQueryParameter('mytest', 'ok') + t.ok('expect', request.query.mytest, 'Added correct query parameter value.') + +// try { +// request.query.mytest = 'done' +// t.fail('Query parameters were updated directly (not allowed).') +// } catch (e) { +// t.pass('Request query attribute is readonly.') +// } + + request.setQueryParameter('mytest', 'done') + t.expect('done', request.query.mytest, 'Inline update of query parameter correctly identifies new value.') + + request.removeQueryParameter('mytest') + t.expect(undefined, request.query.mytest, 'Removing query parameter yields a URL without the parameter.') + + request.method = 'post' + t.expect('POST', request.method, 'Dynamically setting method returns proper HTTP method.') + t.expect((request.protocol === 'https' ? 443 : 80), request.port, 'Proper port identified.') + + request.url = 'http://user:passwd@test.com:7788/path/to/file.html' + t.expect('user', request.username, 'Properly parsed basic auth string.') + t.ok(request.isCrossOrigin, 'CORS recognition correctly identifies cross origin request.') + + request.url = 'http://test.com:7788/path/to/to/../file.html' + t.expect('http://test.com:7788/path/to/file.html', request.url, 'Properly normalized URL.') + + request.body = { + form: { + a: 'test', + b: 1, + c: { + nested: true + } + } + } + + t.expect('string', typeof request.body, 'Properly converted structured form to string-based body.') + + request.body = 'data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==' + t.expect('image/png', request.getHeader('content-type'), 'Correctly identified data image body type.') + + request.body = 'ToveJaniReminderDon\'t forget me this weekend!' + t.expect('application/xml', request.getHeader('content-type'), 'Correctly identified XML body type.') + + request.body = 'test' + t.expect('text/html', request.getHeader('content-type'), 'Correctly identified HTML body type.') + + request.body = 'Basic text body.' + t.expect('text/plain', request.getHeader('content-type'), 'Correctly identified HTML body type.') + +// request = new Request({ +// url: uri.get, +// maxRedirects: 7 +// }) + +// t.ok(request.maxRedirects === 7, 'Maximum redirects can be set via configuration.') + t.end() +}) diff --git a/tests/04-client.js b/tests/04-client.js new file mode 100644 index 0000000..9b6ebce --- /dev/null +++ b/tests/04-client.js @@ -0,0 +1,88 @@ +import test from 'tappedout' +import ngn from 'ngn' +import { Client, Request as NgnRequest } from '@ngnjs/net' + +const url = 'http://localhost/requests' + +test('Sanity Check', t => { + t.expect('function', typeof Client, 'Client class recognized.') + + const client = new Client() + t.ok(client instanceof Client, 'Client class instantiated.') + + t.ok(client.Request === NgnRequest, 'Request class is exposed to client.') + + t.expect('function', typeof client.request, 'Client request method available.') + t.expect('function', typeof client.send, 'Client send method available.') + t.expect('function', typeof client.preflight, 'Client preflight method available.') + t.expect('function', typeof client.options, 'Client options request method available.') + t.expect('function', typeof client.OPTIONS, 'Client OPTIONS alias method available.') + t.expect('function', typeof client.head, 'Client head request method available.') + t.expect('function', typeof client.HEAD, 'Client HEAD request alias available.') + t.expect('function', typeof client.get, 'Client get request method available.') + t.expect('function', typeof client.GET, 'Client GET request alias available.') + t.expect('function', typeof client.post, 'Client post request method available.') + t.expect('function', typeof client.POST, 'Client POST request alias available.') + t.expect('function', typeof client.put, 'Client put request method available.') + t.expect('function', typeof client.PUT, 'Client PUT request alias available.') + t.expect('function', typeof client.delete, 'Client delete request method available.') + t.expect('function', typeof client.DELETE, 'Client DELETE request alias available.') + t.expect('function', typeof client.trace, 'Client trace request method available.') + t.expect('function', typeof client.TRACE, 'Client TRACE request alias available.') + t.expect('function', typeof client.json, 'Client json request method available.') + t.expect('function', typeof client.JSON, 'Client JSON request alias available.') + t.expect('function', typeof client.jsonp, 'Client jsonp request method available.') + t.expect('function', typeof client.JSONP, 'Client JSONP request alias available.') + + t.end() +}) + +test('HTTP Requests', async t => { + const client = new Client() + let res + + res = await client.OPTIONS(url).catch(console.error) + t.expect(200, res.status, 'OPTIONS sends and receives.') + + res = await client.HEAD(url).catch(console.error) + t.expect(200, res.status, 'HEAD sends and receives.') + + if (ngn.runtime !== 'browser') { + res = await client.TRACE(url).catch(t.fail) + if (res.status === 405) { + t.pass('TRACE sends and receives.') + } else { + t.expect(200, res.status, 'TRACE sends and receives.') + } + } else { + t.pass('Ignore HTTP TRACE in browser environments.') + } + + res = await client.GET(url).catch(console.error) + t.expect(200, res.status, 'GET sends and receives.') + + res = await client.POST(url).catch(console.error) + t.expect(200, res.status, 'POST sends and receives.') + + res = await client.PUT(url).catch(console.error) + t.expect(200, res.status, 'PUT sends and receives.') + + res = await client.DELETE(url).catch(console.error) + t.expect(200, res.status, 'DELETE sends and receives.') + + let body = await client.JSON(url + '/test.json').catch(console.error) + t.expect('object', typeof body, 'JSON autoparses objects and returns the result.') + t.expect('worked', body.result, 'Recognized JSON object values.') + + if (ngn.runtime === 'browser') { + body = await client.JSONP(url + '/jsonp/test.json').catch(console.error) + t.expect('worked', body.result, 'JSONP retrieves data via script tag.') + } else { + await client.JSONP(url + '/test.json').then(r => t.fail('JSONP should throw an error in non-browser runtimes.')).catch(e => t.pass(e.message)) + } + + res = await client.request({ url, method: 'PATCH' }).catch(console.error) + t.expect(200, res.status, 'Generic request method issues request.') + + t.end() +}) diff --git a/tests/05-resource.js b/tests/05-resource.js new file mode 100644 index 0000000..a914bee --- /dev/null +++ b/tests/05-resource.js @@ -0,0 +1,233 @@ +import test from 'tappedout' +import ngn from 'ngn' +import { Resource, Request } from '@ngnjs/net' // Rename to prevent conflicts in browser tests + +const baseUrl = 'http://localhost/resource' + +test('NGN Network Resource Sanity Check', t => { + t.expect('function', typeof Resource, 'Resource class recognized.') + + const client = new Resource() + t.ok(client instanceof Resource, 'Resource class instantiated.') + + t.expect('function', typeof client.request, 'Client request method available.') + t.expect('function', typeof client.send, 'Client send method available.') + t.expect('function', typeof client.preflight, 'Client preflight method available.') + t.expect('function', typeof client.options, 'Client options request method available.') + t.expect('function', typeof client.OPTIONS, 'Client OPTIONS alias method available.') + t.expect('function', typeof client.head, 'Client head request method available.') + t.expect('function', typeof client.HEAD, 'Client HEAD request alias available.') + t.expect('function', typeof client.get, 'Client get request method available.') + t.expect('function', typeof client.GET, 'Client GET request alias available.') + t.expect('function', typeof client.post, 'Client post request method available.') + t.expect('function', typeof client.POST, 'Client POST request alias available.') + t.expect('function', typeof client.put, 'Client put request method available.') + t.expect('function', typeof client.PUT, 'Client PUT request alias available.') + t.expect('function', typeof client.delete, 'Client delete request method available.') + t.expect('function', typeof client.DELETE, 'Client DELETE request alias available.') + t.expect('function', typeof client.trace, 'Client trace request method available.') + t.expect('function', typeof client.TRACE, 'Client TRACE request alias available.') + t.expect('function', typeof client.json, 'Client json request method available.') + t.expect('function', typeof client.JSON, 'Client JSON request alias available.') + t.expect('function', typeof client.jsonp, 'Client jsonp request method available.') + t.expect('function', typeof client.JSONP, 'Client JSONP request alias available.') + + t.end() +}) + +test('Resource Credential Validation', function (t) { + const req = new Resource({ baseUrl }) + + req.username = 'john' + req.password = 'passwd' + + t.expect('john', req.username, 'Properly set username.') + t.expect(undefined, req.password, 'Password is not easily accessible.') + + req.username = 'bill' + t.expect('bill', req.username, 'Properly reset username.') + + req.accessToken = '12345abcde' + t.expect(null, req.username, 'Setting an access token clears username/password from credentials.') + + t.expect(undefined, req.accessToken, 'Cannot retrieve access token.') + t.expect(undefined, req.password, 'Cannot retrieve password.') + + req.username = 'bob' + req.password = 'xpwd' + + t.expect('bob', req.username, 'Properly set username via credentials.') + + t.end() +}) + +test('', t => { + const req = new Resource({ baseUrl }) + const tmp = req.prepareUrl('/blah') + t.expect(req.baseUrl + '/blah', tmp, 'Sucessfully prepended base URL to URI.') + t.end() +}) + +test('Resource', t => { + const a = new Resource({ + headers: { + 'X-NGN-TEST': 'test' + }, + query: { + nonce: 'easy' + }, + username: 'admin', + password: 'secure', + unique: true, + nocache: true + }) + + const b = new Resource({ + headers: { + 'X-OTHER': 'other' + }, + query: { + other: 'simple' + }, + accessToken: '12345', + httpsonly: true + }) + + t.expect(-1, a.baseUrl.indexOf('https://'), 'Not forcing SSL does not reqrite baseURL to use HTTPS') + t.expect(0, b.baseUrl.indexOf('https://'), 'Forcing SSL rewrites baseURL to use HTTPS') + + const req = new Request({ url: baseUrl }) + const breq = new Request({ url: baseUrl }) + + a.preflight(req) + b.preflight(breq) + + t.expect('test', req.headers.get('x-ngn-test'), 'Custom header present.') + t.expect('other', breq.headers.get('x-other'), 'Custom header present on different resource.') + t.ok(req.headers.has('authorization'), 'Authorization header present for secure requests (basic auth).') + t.ok(breq.headers.has('authorization'), 'Authorization header present for secure requests (token auth).') + t.expect('Bearer 12345', breq.headers.get('authorization'), 'Authorization token correctly assigned to header.') + t.expect('easy', req.query.nonce, 'Proper query parameter appended to URL.') + t.expect('no-cache', req.cacheMode, 'Nocache query parameter applied to request.') + t.expect(2, req.queryParameterCount, 'Unique query parameter applied to request.') + + t.expect('test', req.headers.get('x-ngn-test'), 'Header reference retrieves correct headers.') + + req.headers = { 'X-TEST': 'test' } + t.expect('test', req.headers.get('X-TEST'), 'Properly set global headers.') + + req.accessToken = '12345ABCDEF' + t.expect('token', req.authType, 'Properly replaced basic auth with token.') + + a.query = { test: 1 } + t.expect(1, a.query.test, 'Properly set query parameters of a resource.') + + t.end() +}) + +test('Resource Requests', async t => { + const client = new Resource({ baseUrl }) + let res + + res = await client.OPTIONS('/OPTIONS').catch(console.error) + t.expect(200, res.status, 'OPTIONS responds.') + t.expect('OK', res.body, 'OPTIONS sends and receives.') + + res = await client.HEAD('/HEAD').catch(console.error) + t.expect(200, res.status, 'HEAD responds.') + t.expect('', res.body, 'HEAD sends and receives.') + + res = await client.GET('/GET').catch(console.error) + t.expect(200, res.status, 'GET responds.') + t.expect('GET', res.body, 'GET sends and receives.') + + res = await client.POST('/POST').catch(console.error) + t.expect(200, res.status, 'POST responds.') + t.expect('POST', res.body, 'POST sends and receives.') + + res = await client.PUT('/PUT').catch(console.error) + t.expect(200, res.status, 'PUT responds.') + t.expect('PUT', res.body, 'PUT sends and receives.') + + res = await client.DELETE('/DELETE').catch(console.error) + t.expect(200, res.status, 'DELETE responds.') + t.expect('DELETE', res.body, 'DELETE sends and receives.') + + const body = await client.JSON('/GET/json').catch(console.error) + t.expect('object', typeof body, 'JSON autoparses objects and returns the result.') + t.expect('worked', body.result, 'Recognized JSON object values.') + + t.end() +}) + +test('Resource Routes', async t => { + const API = new Resource({ + baseUrl, + username: 'user', + password: 'pass' + }) + const v1 = API.route('/v1') + const v2 = API.route('/v2') + const v3 = API.route('/v3') + const v4 = API.route('/v4') + let res + + t.expect(baseUrl + '/v1', v1.baseUrl, 'Route is appended to base URL') + t.expect(baseUrl + '/v2', v2.baseUrl, 'Alternate route is appended to base URL') + + res = await v1.GET('/test/path').catch(console.error) + t.expect(200, res.status, 'Routed to appropriate path.') + + res = await v2.GET('/test/path').catch(console.error) + t.expect(201, res.status, 'Routed to appropriate alternative path.') + + res = await v3.GET('/test/path').catch(console.error) + t.expect(200, res.status, 'Security credentials inherited from origin.') + + API.username = 'other' + API.password = 'secret' + res = await v4.GET('/test/path').catch(console.error) + t.expect(200, res.status, 'Modified credentials accepted.') + + t.end() +}) + +test('Resource Cloning', async t => { + const API = new Resource({ + baseUrl, + username: 'user', + password: 'pass' + }) + + let res + const v1 = API.clone({ baseUrl: baseUrl + '/v1' }) + const v2 = API.clone({ baseUrl: baseUrl + '/v2' }) + const v3 = API.clone({ baseUrl: baseUrl + '/v3' }) + const v4 = API.clone({ + baseUrl: baseUrl + '/v4', + username: 'other', + password: 'secret' + }) + + t.expect(baseUrl + '/v1', v1.baseUrl, 'Route is appended to base URL') + t.expect(baseUrl + '/v2', v2.baseUrl, 'Alternate route is appended to base URL') + + res = await v1.GET('/test/path').catch(console.error) + t.expect(200, res.status, 'Routed to appropriate path.') + + res = await v2.GET('/test/path').catch(console.error) + t.expect(201, res.status, 'Routed to appropriate alternative path.') + + res = await v3.GET('/test/path').catch(console.error) + t.expect(200, res.status, 'Security credentials inherited from origin.') + + res = await v4.GET('/test/path').catch(console.error) + t.expect(200, res.status, 'Modified credentials accepted.') + + API.username = 'other' + API.password = 'secret' + res = await v3.GET('/test/path').catch(console.error) + t.expect(200, res.status, 'Changing origin attributes does NOT change clones.') + + t.end() +}) \ No newline at end of file diff --git a/tests/assets/server.js b/tests/assets/server.js new file mode 100644 index 0000000..eb4427d --- /dev/null +++ b/tests/assets/server.js @@ -0,0 +1,90 @@ +export default (app, API) => { + // app.use(API.log) + // API.applyCommonConfiguration(app) + // API.logRedirects() + app.use(API.allowAll()) + + const response = { text: 'static' } + + app.get('/', (req, res) => { + // res.set('cache-control', 'max-age=200') + res.json(response) + }) + + app.all('/basic', API.OK) + + app.all('/redirect', API.redirect('/endpoint')) + + app.all('/endpoint', API.reply({ data: true })) + + app.all('/refer/:type/:host', (req, res) => { + const { type, host } = req.params + const referrer = req.get('referer') + const source = referrer ? new URL(referrer) : null + + switch (type) { + case 'no-referrer': + return res.sendStatus(referrer ? 400 : 200) + + case 'no-referrer-when-downgrade': + if (source.protocol === 'https:' || req.get('pretendTLS')) { + return res.sendStatus(referrer ? 400 : 200) + } + + return res.status(400).send('Referer header present for downgraded protocol.') + + case 'strict-origin': + if (source !== null && source.hostname === req.hostname) { + return res.sendStatus(200) + } + + return res.status(400).send('Referer header present for strict-origin request when origins or protocols are not the same.') + + case 'same-origin': + if (source === null && req.hostname === (new URL(host)).hostname) { + return res.sendStatus(200) + } + + return res.status(400).send('Referer header present for same-origin request when origins are not the same.') + + default: + if (!referrer) { + console.log(req.headers) + return res.status(400).send('Expected a Referer HTTP header for ' + type + ' referral.') + } + } + + if (referrer === host) { + return res.sendStatus(200) + } else if (referrer.length - 1 === host.length && referrer.substr(-1) === '/') { + return res.sendStatus(200) + } else { + console.log(referrer, '!==', host) + } + + res.status(400).send(`${referrer} !== ${host}`) + }) + + app.get('/requests/jsonp/test.json', (req, res) => { + if (!req.query.callback) { + res.status(400).send('Missing callback query parameter.') + } + + res.send(`${req.query.callback}({ "result": "worked" })`) + }) + + app.get('/requests/test.json', API.reply({ result: 'worked' })) + + app.all('/requests', API.OK) + + app.all('/resource/v1/test/path', API.OK) + app.all('/resource/v2/test/path', API.HTTP201) + app.all('/resource/v3/test/path', API.basicauth('user', 'pass'), API.OK) + app.all('/resource/v4/test/path', API.basicauth('other', 'secret'), API.OK) + + app.all('/resource/:method', (req, res) => { + res.send(req.params.method.toUpperCase()) + }) + + app.get('/resource/:method/json', API.reply({ result: 'worked' })) +}