diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 4aacdee6b..6762532aa 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -15,6 +15,7 @@ body: - builders - collection - rest + - proxy - voice validations: required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index b5bc36159..f7e0ee44f 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -17,6 +17,7 @@ body: - builders - collection - rest + - proxy - voice validations: required: true diff --git a/.github/labeler.yml b/.github/labeler.yml index 2f739772c..590039102 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -14,6 +14,10 @@ chore: - packages/discord.js/* - packages/discord.js/**/* +'packages:proxy': + - packages/proxy/* + - packages/proxy/**/* + 'packages:rest': - packages/rest/* - packages/rest/**/* diff --git a/.github/labels.yml b/.github/labels.yml index 9df9999e6..b644606f9 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -50,6 +50,8 @@ color: 'fbca04' - name: 'packages:discord.js' color: 'fbca04' +- name: 'packages:proxy' + color: 'fbca04' - name: 'packages:rest' color: 'fbca04' - name: 'packages:voice' diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 27f6fbbfd..d22114618 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -62,7 +62,7 @@ jobs: max-parallel: 1 fail-fast: false matrix: - package: ['builders', 'collection', 'discord.js', 'rest', 'voice'] + package: ['builders', 'collection', 'discord.js', 'proxy', 'rest', 'voice'] runs-on: ubuntu-latest env: BRANCH_NAME: ${{ needs.build.outputs.BRANCH_NAME }} diff --git a/.github/workflows/publish-dev.yml b/.github/workflows/publish-dev.yml index 7a1d8310b..ba9c1ccdd 100644 --- a/.github/workflows/publish-dev.yml +++ b/.github/workflows/publish-dev.yml @@ -16,6 +16,8 @@ jobs: folder: 'collection' - package: 'discord.js' folder: 'discord.js' + - package: '@discordjs/proxy' + folder: 'proxy' - package: '@discordjs/rest' folder: 'rest' - package: '@discordjs/voice' diff --git a/packages/proxy/.eslintrc.json b/packages/proxy/.eslintrc.json new file mode 100644 index 000000000..809a8448b --- /dev/null +++ b/packages/proxy/.eslintrc.json @@ -0,0 +1,11 @@ +{ + "root": true, + "extends": "marine/prettier/node", + "parserOptions": { + "project": "./tsconfig.eslint.json" + }, + "ignorePatterns": ["**/dist/*"], + "env": { + "jest": true + } +} diff --git a/packages/proxy/.gitignore b/packages/proxy/.gitignore new file mode 100644 index 000000000..b5e30aac1 --- /dev/null +++ b/packages/proxy/.gitignore @@ -0,0 +1,32 @@ +# Packages +node_modules/ + +# Log files +logs/ +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed + +# Env +.env + +# Dist +dist/ +typings/ +docs/**/* +!docs/index.yml +!docs/README.md + +# Miscellaneous +.tmp/ +coverage/ +tsconfig.tsbuildinfo +.turbo + +# Yarn files +.yarn/install-state.gz +.yarn/build-state.yml diff --git a/packages/proxy/.prettierrc.json b/packages/proxy/.prettierrc.json new file mode 100644 index 000000000..eba3f4077 --- /dev/null +++ b/packages/proxy/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "printWidth": 120, + "useTabs": true, + "singleQuote": true, + "quoteProps": "as-needed", + "trailingComma": "all", + "endOfLine": "lf" +} diff --git a/packages/proxy/LICENSE b/packages/proxy/LICENSE new file mode 100644 index 000000000..f9786ff8f --- /dev/null +++ b/packages/proxy/LICENSE @@ -0,0 +1,191 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2022 Noel Buechler + Copyright 2022 Charlotte Cristea + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/proxy/README.md b/packages/proxy/README.md new file mode 100644 index 000000000..8c467de21 --- /dev/null +++ b/packages/proxy/README.md @@ -0,0 +1,46 @@ +
+
+

+ discord.js +

+
+

+ Discord server + npm version + npm downloads + Build status +

+
+ +## About + +`@discordjs/proxy` is a powerful wrapper around `@discordjs/rest` for running an HTTP proxy in front of Discord's API + +## Installation + +**Node.js 16.9.0 or newer is required.** + +```sh-session +npm install @discordjs/proxy +yarn add @discordjs/proxy +pnpm add @discordjs/proxy +``` + +## Links + +- [Website](https://discord.js.org/) ([source](https://github.com/discordjs/website)) +- [Documentation](https://discord.js.org/#/docs/proxy) +- [discord.js Discord server](https://discord.gg/djs) +- [GitHub](https://github.com/discordjs/discord.js/tree/main/packages/proxy) +- [npm](https://www.npmjs.com/package/@discordjs/proxy) + +## Contributing + +Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the +[documentation](https://discord.js.org/#/docs/proxy). +See [the contribution guide](https://github.com/discordjs/discord.js/blob/main/.github/CONTRIBUTING.md) if you'd like to submit a PR. + +## Help + +If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle +nudge in the right direction, please don't hesitate to join our official [discord.js Server](https://discord.gg/djs). diff --git a/packages/proxy/__tests__/proxyRequests.test.ts b/packages/proxy/__tests__/proxyRequests.test.ts new file mode 100644 index 000000000..c26413e20 --- /dev/null +++ b/packages/proxy/__tests__/proxyRequests.test.ts @@ -0,0 +1,83 @@ +import { createServer } from 'node:http'; +import { REST } from '@discordjs/rest'; +import supertest from 'supertest'; +import { MockAgent, Interceptable, setGlobalDispatcher } from 'undici'; +import type { MockInterceptor } from 'undici/types/mock-interceptor'; +import { proxyRequests } from '../src'; + +let mockAgent: MockAgent; +let mockPool: Interceptable; + +const responseOptions: MockInterceptor.MockResponseOptions = { + headers: { + 'content-type': 'application/json', + }, +}; + +const api = new REST().setToken('A-Very-Fake-Token'); +// eslint-disable-next-line @typescript-eslint/no-misused-promises +const server = createServer(proxyRequests(api)); + +beforeEach(() => { + mockAgent = new MockAgent(); + mockAgent.disableNetConnect(); // prevent actual requests to Discord + setGlobalDispatcher(mockAgent); // enabled the mock client to intercept requests + + mockPool = mockAgent.get('https://discord.com'); +}); + +afterEach(async () => { + await mockAgent.close(); +}); + +afterAll(() => { + server.close(); +}); + +test('simple GET', async () => { + mockPool + .intercept({ + path: '/api/v10/simpleGet', + method: 'GET', + }) + .reply(() => ({ + data: { test: true }, + statusCode: 200, + responseOptions: { + ...responseOptions, + headers: { + ...responseOptions.headers, + 'x-ratelimit-limit': '10', + }, + }, + })); + + const res = await supertest(server).get('/api/v10/simpleGet'); + const headers = res.headers as Record; + + expect(headers['content-type']).toEqual(expect.stringMatching(/^application\/json/)); + // Ratelimit headers should be dropped + expect(headers).not.toHaveProperty('x-ratelimit-limit'); + expect(res.statusCode).toBe(200); + expect(res.body).toStrictEqual({ test: true }); +}); + +test('failed request', async () => { + mockPool + .intercept({ + path: '/api/v10/simpleGet', + method: 'GET', + }) + .reply(() => ({ + data: { code: 404, message: 'Not Found' }, + statusCode: 404, + responseOptions, + })); + + const res = await supertest(server).get('/api/v10/simpleGet'); + const headers = res.headers as Record; + + expect(headers['content-type']).toEqual(expect.stringMatching(/^application\/json/)); + expect(res.statusCode).toBe(404); + expect(res.body).toStrictEqual({ code: 404, message: 'Not Found' }); +}); diff --git a/packages/proxy/babel.config.js b/packages/proxy/babel.config.js new file mode 100644 index 000000000..bbaa0e8cb --- /dev/null +++ b/packages/proxy/babel.config.js @@ -0,0 +1,17 @@ +/** + * @type {import('@babel/core').TransformOptions} + */ +module.exports = { + parserOpts: { strictMode: true }, + sourceMaps: 'inline', + presets: [ + [ + '@babel/preset-env', + { + targets: { node: 'current' }, + modules: 'commonjs', + }, + ], + '@babel/preset-typescript', + ], +}; diff --git a/packages/proxy/cliff.toml b/packages/proxy/cliff.toml new file mode 100644 index 000000000..3f8bf2c15 --- /dev/null +++ b/packages/proxy/cliff.toml @@ -0,0 +1,65 @@ +[changelog] +header = """ +# Changelog + +All notable changes to this project will be documented in this file.\n +""" +body = """ +{% if version %}\ + # [{{ version | trim_start_matches(pat="v") }}]\ + {% if previous %}\ + {% if previous.version %}\ + (https://github.com/discordjs/discord.js/compare/{{ previous.version }}...{{ version }})\ + {% else %} + (https://github.com/discordjs/discord.js/tree/{{ version }})\ + {% endif %}\ + {% endif %} \ + - ({{ timestamp | date(format="%Y-%m-%d") }}) +{% else %}\ + # [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ## {{ group | upper_first }} + {% for commit in commits %} + - {% if commit.scope %}\ + **{{commit.scope}}:** \ + {% endif %}\ + {{ commit.message | upper_first }} ([{{ commit.id | truncate(length=7, end="") }}](https://github.com/discordjs/discord.js/commit/{{ commit.id }}))\ + {% if commit.breaking %}\ + \n\n {% raw %} {% endraw %} ### Breaking Changes:\n \ + {% for breakingChange in commit.footers %}\ + {% raw %} {% endraw %} - {{ breakingChange }}\n\ + {% endfor %}\ + {% endif %}\ + {% endfor %} +{% endfor %}\n +""" +trim = true +footer = "" + +[git] +conventional_commits = true +filter_unconventional = true +commit_parsers = [ + { message = "^feat", group = "Features"}, + { message = "^fix", group = "Bug Fixes"}, + { message = "^docs", group = "Documentation"}, + { message = "^perf", group = "Performance"}, + { message = "^refactor", group = "Refactor"}, + { message = "^typings", group = "Typings"}, + { message = "^types", group = "Typings"}, + { message = ".*deprecated", body = ".*deprecated", group = "Deprecation"}, + { message = "^revert", skip = true}, + { message = "^style", group = "Styling"}, + { message = "^test", group = "Testing"}, + { message = "^chore", skip = true}, + { message = "^ci", skip = true}, + { message = "^build", skip = true}, + { body = ".*security", group = "Security"}, +] +filter_commits = true +tag_pattern = "@discordjs\\/proxy@.*" +skip_tags = "v[0-9]*|11|12" +ignore_tags = "" +topo_order = false +sort_commits = "newest" diff --git a/packages/proxy/docs/README.md b/packages/proxy/docs/README.md new file mode 100644 index 000000000..22582e3d0 --- /dev/null +++ b/packages/proxy/docs/README.md @@ -0,0 +1 @@ +## [View the documentation here.](https://discord.js.org/#/docs/proxy) diff --git a/packages/proxy/docs/index.yml b/packages/proxy/docs/index.yml new file mode 100644 index 000000000..2c993519e --- /dev/null +++ b/packages/proxy/docs/index.yml @@ -0,0 +1,5 @@ +- name: General + files: + - name: Welcome + id: welcome + path: ../../README.md diff --git a/packages/proxy/jest.config.js b/packages/proxy/jest.config.js new file mode 100644 index 000000000..5cfe81c91 --- /dev/null +++ b/packages/proxy/jest.config.js @@ -0,0 +1,11 @@ +/** + * @type {import('@jest/types').Config.InitialOptions} + */ +module.exports = { + testMatch: ['**/+(*.)+(spec|test).+(ts|js)?(x)'], + testEnvironment: 'node', + collectCoverage: true, + collectCoverageFrom: ['src/**/*.ts'], + coverageDirectory: 'coverage', + coverageReporters: ['text', 'lcov', 'clover'], +}; diff --git a/packages/proxy/package.json b/packages/proxy/package.json new file mode 100644 index 000000000..fba067d3d --- /dev/null +++ b/packages/proxy/package.json @@ -0,0 +1,89 @@ +{ + "name": "@discordjs/proxy", + "version": "0.1.0-dev", + "description": "Tools for running an HTTP proxy for Discord's API", + "scripts": { + "build": "tsup && tsc --emitDeclarationOnly --incremental", + "test": "jest --pass-with-no-tests --collect-coverage", + "lint": "prettier --check . && eslint src __tests__ --ext mjs,js,ts", + "format": "prettier --write . && eslint src __tests__ --ext mjs,js,ts --fix", + "docs": "typedoc --json docs/typedoc-out.json src/index.ts && node scripts/docs.mjs", + "prepublishOnly": "yarn build && yarn lint && yarn test", + "changelog": "git cliff --prepend ./CHANGELOG.md -u -c ./cliff.toml -r ../../ --include-path 'packages/proxy/*'" + }, + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "typings": "./dist/index.d.ts", + "exports": { + "import": "./dist/index.mjs", + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "directories": { + "lib": "src", + "test": "__tests__" + }, + "files": [ + "dist" + ], + "contributors": [ + "Crawl ", + "Amish Shah ", + "SpaceEEC ", + "Vlad Frangu ", + "Antonio Roman ", + "DD " + ], + "license": "Apache-2.0", + "keywords": [ + "discord", + "api", + "rest", + "proxy", + "discordapp", + "discordjs" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/discordjs/discord.js.git" + }, + "bugs": { + "url": "https://github.com/discordjs/discord.js/issues" + }, + "homepage": "https://discord.js.org", + "dependencies": { + "@discordjs/rest": "workspace:^", + "tslib": "^2.4.0", + "undici": "^5.4.0" + }, + "devDependencies": { + "@babel/core": "^7.18.2", + "@babel/plugin-proposal-decorators": "^7.18.2", + "@babel/preset-env": "^7.18.2", + "@babel/preset-typescript": "^7.17.12", + "@discordjs/ts-docgen": "^0.4.1", + "@types/jest": "^28.1.0", + "@types/node": "^16.11.38", + "@types/supertest": "^2.0.12", + "@typescript-eslint/eslint-plugin": "^5.27.0", + "@typescript-eslint/parser": "^5.27.0", + "babel-plugin-const-enum": "^1.2.0", + "babel-plugin-transform-typescript-metadata": "^0.3.2", + "eslint": "^8.17.0", + "eslint-config-marine": "^9.4.1", + "eslint-config-prettier": "^8.5.0", + "eslint-plugin-import": "^2.26.0", + "jest": "^28.1.0", + "prettier": "^2.6.2", + "supertest": "^6.2.3", + "tsup": "^6.0.1", + "typedoc": "^0.22.17", + "typescript": "^4.7.3" + }, + "engines": { + "node": ">=16.9.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/proxy/src/handlers/proxyRequests.ts b/packages/proxy/src/handlers/proxyRequests.ts new file mode 100644 index 000000000..6119d1047 --- /dev/null +++ b/packages/proxy/src/handlers/proxyRequests.ts @@ -0,0 +1,55 @@ +import { URL } from 'node:url'; +import { DiscordAPIError, HTTPError, RateLimitError, RequestMethod, REST, RouteLike } from '@discordjs/rest'; +import { + populateAbortErrorResponse, + populateGeneralErrorResponse, + populateSuccessfulResponse, + populateRatelimitErrorResponse, +} from '../util/responseHelpers'; +import type { RequestHandler } from '../util/util'; + +/** + * Creates an HTTP handler used to forward requests to Discord + * @param rest REST instance to use for the requests + */ +export function proxyRequests(rest: REST): RequestHandler { + return async (req, res) => { + const { method, url } = req; + + if (!method || !url) { + throw new TypeError( + 'Invalid request. Missing method and/or url, implying that this is not a Server IncomingMesage', + ); + } + + // The 2nd parameter is here so the URL constructor doesn't complain about an "invalid url" when the origin is missing + // we don't actually care about the origin and the value passed is irrelevant + const fullRoute = new URL(url, 'http://noop').pathname.replace(/^\/api(\/v\d+)?/, '') as RouteLike; + + try { + const discordResponse = await rest.raw({ + body: req, + fullRoute, + // This type cast is technically incorrect, but we want Discord to throw Method Not Allowed for us + method: method as RequestMethod, + passThroughBody: true, + }); + + await populateSuccessfulResponse(res, discordResponse); + } catch (error) { + if (error instanceof DiscordAPIError || error instanceof HTTPError) { + populateGeneralErrorResponse(res, error); + } else if (error instanceof RateLimitError) { + populateRatelimitErrorResponse(res, error); + } else if (error instanceof Error && error.name === 'AbortError') { + populateAbortErrorResponse(res); + } else { + // Unclear if there's better course of action here for unknown erorrs. Any web framework allows to pass in an error handler for something like this + // at which point the user could dictate what to do with the error - otherwise we could just 500 + throw error; + } + } finally { + res.end(); + } + }; +} diff --git a/packages/proxy/src/index.ts b/packages/proxy/src/index.ts new file mode 100644 index 000000000..bb8822b90 --- /dev/null +++ b/packages/proxy/src/index.ts @@ -0,0 +1,3 @@ +export * from './handlers/proxyRequests'; +export * from './util/responseHelpers'; +export { RequestHandler } from './util/util'; diff --git a/packages/proxy/src/util/responseHelpers.ts b/packages/proxy/src/util/responseHelpers.ts new file mode 100644 index 000000000..5b4a5afe7 --- /dev/null +++ b/packages/proxy/src/util/responseHelpers.ts @@ -0,0 +1,54 @@ +import type { ServerResponse } from 'node:http'; +import { pipeline } from 'node:stream/promises'; +import type { DiscordAPIError, HTTPError, RateLimitError } from '@discordjs/rest'; +import type { Dispatcher } from 'undici'; + +/** + * Populates a server response with the data from a Discord 2xx REST response + * @param res The server response to populate + * @param data The data to populate the response with + */ +export async function populateSuccessfulResponse(res: ServerResponse, data: Dispatcher.ResponseData): Promise { + res.statusCode = data.statusCode; + + for (const header of Object.keys(data.headers)) { + // Strip ratelimit headers + if (header.startsWith('x-ratelimit')) { + continue; + } + + res.setHeader(header, data.headers[header]!); + } + + await pipeline(data.body, res); +} + +/** + * Populates a server response with the data from a Discord non-2xx REST response that is NOT a 429 + * @param res The server response to populate + * @param error The error to populate the response with + */ +export function populateGeneralErrorResponse(res: ServerResponse, error: DiscordAPIError | HTTPError): void { + res.statusCode = error.status; + res.statusMessage = error.message; + + if ('rawError' in error) { + res.setHeader('Content-Type', 'application/json'); + res.write(JSON.stringify(error.rawError)); + } +} + +/** + * Populates a server response with the data from a Discord 429 REST response + * @param res The server response to populate + * @param error The error to populate the response with + */ +export function populateRatelimitErrorResponse(res: ServerResponse, error: RateLimitError): void { + res.statusCode = 429; + res.setHeader('Retry-After', error.timeToReset / 1000); +} + +export function populateAbortErrorResponse(res: ServerResponse): void { + res.statusCode = 504; + res.statusMessage = 'Upstream timed out'; +} diff --git a/packages/proxy/src/util/util.ts b/packages/proxy/src/util/util.ts new file mode 100644 index 000000000..41ae5f92e --- /dev/null +++ b/packages/proxy/src/util/util.ts @@ -0,0 +1,10 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; + +/** + * Represents a potentially awaitable value + */ +export type Awaitable = T | PromiseLike; +/** + * Represents a simple HTTP request handler + */ +export type RequestHandler = (req: IncomingMessage, res: ServerResponse) => Awaitable; diff --git a/packages/proxy/tsconfig.eslint.json b/packages/proxy/tsconfig.eslint.json new file mode 100644 index 000000000..d04d4be3a --- /dev/null +++ b/packages/proxy/tsconfig.eslint.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowJs": true + }, + "include": [ + "**/*.ts", + "**/*.tsx", + "**/*.js", + "**/*.mjs", + "**/*.jsx", + "**/*.test.ts", + "**/*.test.js", + "**/*.test.mjs", + "**/*.spec.ts", + "**/*.spec.js", + "**/*.spec.mjs" + ], + "exclude": [] +} diff --git a/packages/proxy/tsconfig.json b/packages/proxy/tsconfig.json new file mode 100644 index 000000000..fd8b5e417 --- /dev/null +++ b/packages/proxy/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts"] +} diff --git a/packages/proxy/tsup.config.ts b/packages/proxy/tsup.config.ts new file mode 100644 index 000000000..a0c433e8d --- /dev/null +++ b/packages/proxy/tsup.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + clean: true, + dts: true, + entryPoints: ['src/index.ts'], + format: ['esm', 'cjs'], + minify: false, + keepNames: true, + skipNodeModulesBundle: true, + sourcemap: true, + target: 'es2021', + esbuildOptions: (options, context) => { + if (context.format === 'cjs') { + options.banner = { + js: '"use strict";', + }; + } + }, +}); diff --git a/yarn.lock b/yarn.lock index 34c521342..7b76ce6bb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1947,6 +1947,38 @@ __metadata: languageName: node linkType: hard +"@discordjs/proxy@workspace:packages/proxy": + version: 0.0.0-use.local + resolution: "@discordjs/proxy@workspace:packages/proxy" + dependencies: + "@babel/core": ^7.18.2 + "@babel/plugin-proposal-decorators": ^7.18.2 + "@babel/preset-env": ^7.18.2 + "@babel/preset-typescript": ^7.17.12 + "@discordjs/rest": "workspace:^" + "@discordjs/ts-docgen": ^0.4.1 + "@types/jest": ^28.1.0 + "@types/node": ^16.11.38 + "@types/supertest": ^2.0.12 + "@typescript-eslint/eslint-plugin": ^5.27.0 + "@typescript-eslint/parser": ^5.27.0 + babel-plugin-const-enum: ^1.2.0 + babel-plugin-transform-typescript-metadata: ^0.3.2 + eslint: ^8.17.0 + eslint-config-marine: ^9.4.1 + eslint-config-prettier: ^8.5.0 + eslint-plugin-import: ^2.26.0 + jest: ^28.1.0 + prettier: ^2.6.2 + supertest: ^6.2.3 + tslib: ^2.4.0 + tsup: ^6.0.1 + typedoc: ^0.22.17 + typescript: ^4.7.3 + undici: ^5.4.0 + languageName: unknown + linkType: soft + "@discordjs/rest@workspace:^, @discordjs/rest@workspace:packages/rest": version: 0.0.0-use.local resolution: "@discordjs/rest@workspace:packages/rest" @@ -2640,6 +2672,13 @@ __metadata: languageName: node linkType: hard +"@types/cookiejar@npm:*": + version: 2.1.2 + resolution: "@types/cookiejar@npm:2.1.2" + checksum: f6e1903454007f86edd6c3520cbb4d553e1d4e17eaf1f77f6f75e3270f48cc828d74397a113a36942f5fe52f9fa71067bcfa738f53ad468fcca0bc52cb1cbd28 + languageName: node + linkType: hard + "@types/eslint@npm:^7.2.13": version: 7.29.0 resolution: "@types/eslint@npm:7.29.0" @@ -2785,6 +2824,25 @@ __metadata: languageName: node linkType: hard +"@types/superagent@npm:*": + version: 4.1.15 + resolution: "@types/superagent@npm:4.1.15" + dependencies: + "@types/cookiejar": "*" + "@types/node": "*" + checksum: 347cd74ef0a29e6b9c6d32253c3fb0dd39a31618b50752f84d36b6a9246237bb6b68c9b436c1f94adabc2df89d9f1939e4782f4c850f98b9c2fe431ad4e565a4 + languageName: node + linkType: hard + +"@types/supertest@npm:^2.0.12": + version: 2.0.12 + resolution: "@types/supertest@npm:2.0.12" + dependencies: + "@types/superagent": "*" + checksum: f0e2b44f86bec2f708d6a3d0cb209055b487922040773049b0f8c6b557af52d4b5fa904e17dfaa4ce6e610172206bbec7b62420d158fa57b6ffc2de37b1730d3 + languageName: node + linkType: hard + "@types/ws@npm:^8.5.3": version: 8.5.3 resolution: "@types/ws@npm:8.5.3" @@ -3269,6 +3327,13 @@ __metadata: languageName: node linkType: hard +"asap@npm:^2.0.0": + version: 2.0.6 + resolution: "asap@npm:2.0.6" + checksum: b296c92c4b969e973260e47523207cd5769abd27c245a68c26dc7a0fe8053c55bb04360237cb51cab1df52be939da77150ace99ad331fb7fb13b3423ed73ff3d + languageName: node + linkType: hard + "asn1@npm:~0.2.3": version: 0.2.6 resolution: "asn1@npm:0.2.6" @@ -3988,7 +4053,7 @@ __metadata: languageName: node linkType: hard -"combined-stream@npm:^1.0.6, combined-stream@npm:~1.0.6": +"combined-stream@npm:^1.0.6, combined-stream@npm:^1.0.8, combined-stream@npm:~1.0.6": version: 1.0.8 resolution: "combined-stream@npm:1.0.8" dependencies: @@ -4079,6 +4144,13 @@ __metadata: languageName: node linkType: hard +"component-emitter@npm:^1.3.0": + version: 1.3.0 + resolution: "component-emitter@npm:1.3.0" + checksum: b3c46de38ffd35c57d1c02488355be9f218e582aec72d72d1b8bbec95a3ac1b38c96cd6e03ff015577e68f550fbb361a3bfdbd9bb248be9390b7b3745691be6b + languageName: node + linkType: hard + "concat-map@npm:0.0.1": version: 0.0.1 resolution: "concat-map@npm:0.0.1" @@ -4316,6 +4388,13 @@ __metadata: languageName: node linkType: hard +"cookiejar@npm:^2.1.3": + version: 2.1.3 + resolution: "cookiejar@npm:2.1.3" + checksum: 88259983ebc52ceb23cdacfa48762b6a518a57872eff1c7ed01d214fff5cf492e2660d7d5c04700a28f1787a76811df39e8639f8e17670b3cf94ecd86e161f07 + languageName: node + linkType: hard + "core-js-compat@npm:^3.20.0": version: 3.20.2 resolution: "core-js-compat@npm:3.20.2" @@ -4564,6 +4643,16 @@ __metadata: languageName: node linkType: hard +"dezalgo@npm:1.0.3": + version: 1.0.3 + resolution: "dezalgo@npm:1.0.3" + dependencies: + asap: ^2.0.0 + wrappy: 1 + checksum: 8b26238db91423b2702a7a6d9629d0019c37c415e7b6e75d4b3e8d27e9464e21cac3618dd145f4d4ee96c70cc6ff034227b5b8a0e9c09015a8bdbe6dace3cfb9 + languageName: node + linkType: hard + "diff-sequences@npm:^27.4.0": version: 27.4.0 resolution: "diff-sequences@npm:27.4.0" @@ -5558,6 +5647,13 @@ dts-critic@latest: languageName: node linkType: hard +"fast-safe-stringify@npm:^2.1.1": + version: 2.1.1 + resolution: "fast-safe-stringify@npm:2.1.1" + checksum: a851cbddc451745662f8f00ddb622d6766f9bd97642dabfd9a405fb0d646d69fc0b9a1243cbf67f5f18a39f40f6fa821737651ff1bceeba06c9992ca2dc5bd3d + languageName: node + linkType: hard + "fastq@npm:^1.6.0": version: 1.13.0 resolution: "fastq@npm:1.13.0" @@ -5721,6 +5817,17 @@ dts-critic@latest: languageName: node linkType: hard +"form-data@npm:^4.0.0": + version: 4.0.0 + resolution: "form-data@npm:4.0.0" + dependencies: + asynckit: ^0.4.0 + combined-stream: ^1.0.8 + mime-types: ^2.1.12 + checksum: 01135bf8675f9d5c61ff18e2e2932f719ca4de964e3be90ef4c36aacfc7b9cb2fceb5eca0b7e0190e3383fe51c5b37f4cb80b62ca06a99aaabfcfd6ac7c9328c + languageName: node + linkType: hard + "form-data@npm:~2.3.2": version: 2.3.3 resolution: "form-data@npm:2.3.3" @@ -5732,6 +5839,18 @@ dts-critic@latest: languageName: node linkType: hard +"formidable@npm:^2.0.1": + version: 2.0.1 + resolution: "formidable@npm:2.0.1" + dependencies: + dezalgo: 1.0.3 + hexoid: 1.0.0 + once: 1.4.0 + qs: 6.9.3 + checksum: b35445444e7b6f6f3cacbadd5e6fadd6b5b2e83162e7c41fa22586df584cc515bbd1ee0dc2b701ce031fcb000d71769bc77bd0958db8a89a0ceb8b2227bdc695 + languageName: node + linkType: hard + "fs-constants@npm:^1.0.0": version: 1.0.0 resolution: "fs-constants@npm:1.0.0" @@ -6230,6 +6349,13 @@ dts-critic@latest: languageName: node linkType: hard +"hexoid@npm:1.0.0": + version: 1.0.0 + resolution: "hexoid@npm:1.0.0" + checksum: 27a148ca76a2358287f40445870116baaff4a0ed0acc99900bf167f0f708ffd82e044ff55e9949c71963852b580fc024146d3ac6d5d76b508b78d927fa48ae2d + languageName: node + linkType: hard + "hosted-git-info@npm:^2.1.4": version: 2.8.9 resolution: "hosted-git-info@npm:2.8.9" @@ -7983,6 +8109,13 @@ dts-critic@latest: languageName: node linkType: hard +"methods@npm:^1.1.2": + version: 1.1.2 + resolution: "methods@npm:1.1.2" + checksum: 0917ff4041fa8e2f2fda5425a955fe16ca411591fbd123c0d722fcf02b73971ed6f764d85f0a6f547ce49ee0221ce2c19a5fa692157931cecb422984f1dcd13a + languageName: node + linkType: hard + "micromatch@npm:^4.0.4": version: 4.0.4 resolution: "micromatch@npm:4.0.4" @@ -8009,6 +8142,15 @@ dts-critic@latest: languageName: node linkType: hard +"mime@npm:^2.5.0": + version: 2.6.0 + resolution: "mime@npm:2.6.0" + bin: + mime: cli.js + checksum: 1497ba7b9f6960694268a557eae24b743fd2923da46ec392b042469f4b901721ba0adcf8b0d3c2677839d0e243b209d76e5edcbd09cfdeffa2dfb6bb4df4b862 + languageName: node + linkType: hard + "mimic-fn@npm:^2.1.0": version: 2.1.0 resolution: "mimic-fn@npm:2.1.0" @@ -8499,7 +8641,7 @@ dts-critic@latest: languageName: node linkType: hard -"once@npm:^1.3.0, once@npm:^1.4.0": +"once@npm:1.4.0, once@npm:^1.3.0, once@npm:^1.4.0": version: 1.4.0 resolution: "once@npm:1.4.0" dependencies: @@ -8959,6 +9101,22 @@ dts-critic@latest: languageName: node linkType: hard +"qs@npm:6.9.3": + version: 6.9.3 + resolution: "qs@npm:6.9.3" + checksum: 89cd1b5e521c19a7e0a7a056ddc261c5c30889664608cf9ce6085f9f25606fc48568cf6a6249e641b4b5c04dac7889e3b82133142523abf397228eb4f488fc38 + languageName: node + linkType: hard + +"qs@npm:^6.10.3": + version: 6.10.3 + resolution: "qs@npm:6.10.3" + dependencies: + side-channel: ^1.0.4 + checksum: 0fac5e6c7191d0295a96d0e83c851aeb015df7e990e4d3b093897d3ac6c94e555dbd0a599739c84d7fa46d7fee282d94ba76943983935cf33bba6769539b8019 + languageName: node + linkType: hard + "qs@npm:~6.5.2": version: 6.5.2 resolution: "qs@npm:6.5.2" @@ -10005,6 +10163,35 @@ dts-critic@latest: languageName: node linkType: hard +"superagent@npm:^7.1.3": + version: 7.1.3 + resolution: "superagent@npm:7.1.3" + dependencies: + component-emitter: ^1.3.0 + cookiejar: ^2.1.3 + debug: ^4.3.4 + fast-safe-stringify: ^2.1.1 + form-data: ^4.0.0 + formidable: ^2.0.1 + methods: ^1.1.2 + mime: ^2.5.0 + qs: ^6.10.3 + readable-stream: ^3.6.0 + semver: ^7.3.7 + checksum: 436045d555d35c282de7bcba85102b1421470bdc80781c9a0b7ab7c639675b4eca026a71301974935f3de0d33782a0392274e24f3915335b81a78a04b48eeee5 + languageName: node + linkType: hard + +"supertest@npm:^6.2.3": + version: 6.2.3 + resolution: "supertest@npm:6.2.3" + dependencies: + methods: ^1.1.2 + superagent: ^7.1.3 + checksum: c1bed86c31723a4bc461153a58176fd80d675deb7d23ab7bd170213040673b35c38e3cbeab9a4eb8a325cf736176c08c6f6522e42f0293314f183e192a6681fa + languageName: node + linkType: hard + "supports-color@npm:^2.0.0": version: 2.0.0 resolution: "supports-color@npm:2.0.0"