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 @@
+
+
+## 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"