mirror of
https://github.com/discordeno/discordeno.git
synced 2026-06-02 17:00:08 +00:00
feat: standalone rest server (#290)
* feat: standalone rest server * desc * fmt * <3 vlad * Update src/rest/README.md Co-authored-by: Ayyan <ayyantee@gmail.com> * Update src/rest/README.md Co-authored-by: Ayyan <ayyantee@gmail.com> * Update README.md * Update src/rest/deps.ts Co-authored-by: Ayyan <ayyantee@gmail.com> * Update src/rest/queue.ts Co-authored-by: Ayyan <ayyantee@gmail.com> * chore: ignore no-explicit-any rule * fix(rest): replace with correct import paths * deno fmt * fixes * fmt * use user agent cons * fix typings * Update src/rest/cache.ts Co-authored-by: Ayyan <ayyantee@gmail.com> * Update src/rest/cache.ts Co-authored-by: Ayyan <ayyantee@gmail.com> Co-authored-by: Ayyan <ayyantee@gmail.com>
This commit is contained in:
@@ -38,8 +38,8 @@ module.exports = {
|
||||
nav,
|
||||
sidebar,
|
||||
yuu: {
|
||||
defaultDarkTheme: true
|
||||
}
|
||||
defaultDarkTheme: true,
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
"@vuepress/plugin-back-to-top",
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
module.exports = [
|
||||
{
|
||||
text: "Home",
|
||||
link: "/",
|
||||
},
|
||||
{
|
||||
text: "Docs",
|
||||
link: "https://doc.deno.land/https/deno.land/x/discordeno/mod.ts",
|
||||
},
|
||||
{
|
||||
text: "Discord",
|
||||
link: "https://discord.gg/5vBgXk3UcZ",
|
||||
target: "_blank",
|
||||
},
|
||||
]
|
||||
{
|
||||
text: "Home",
|
||||
link: "/",
|
||||
},
|
||||
{
|
||||
text: "Docs",
|
||||
link: "https://doc.deno.land/https/deno.land/x/discordeno/mod.ts",
|
||||
},
|
||||
{
|
||||
text: "Discord",
|
||||
link: "https://discord.gg/5vBgXk3UcZ",
|
||||
target: "_blank",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
module.exports = {
|
||||
"/": [
|
||||
{
|
||||
title: "Home",
|
||||
children: [
|
||||
"/",
|
||||
"faq",
|
||||
"gettingstarted",
|
||||
"djs",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Step By Step Guide",
|
||||
children: [
|
||||
"/stepbystep/",
|
||||
"/stepbystep/createbot",
|
||||
"/stepbystep/createcommand",
|
||||
"/stepbystep/createevent",
|
||||
"/stepbystep/createlanguage",
|
||||
"/stepbystep/createmonitor",
|
||||
"/stepbystep/createinhibitor",
|
||||
"/stepbystep/createtask",
|
||||
"/stepbystep/hostingbot",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Advanced Guide",
|
||||
children: [
|
||||
"/advanced/",
|
||||
"/advanced/arguments",
|
||||
"/advanced/customizations",
|
||||
"/advanced/dockerhosting",
|
||||
"/advanced/dynamiccommands",
|
||||
"/advanced/permlevels",
|
||||
"/advanced/subcommands",
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
"/": [
|
||||
{
|
||||
title: "Home",
|
||||
children: [
|
||||
"/",
|
||||
"faq",
|
||||
"gettingstarted",
|
||||
"djs",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Step By Step Guide",
|
||||
children: [
|
||||
"/stepbystep/",
|
||||
"/stepbystep/createbot",
|
||||
"/stepbystep/createcommand",
|
||||
"/stepbystep/createevent",
|
||||
"/stepbystep/createlanguage",
|
||||
"/stepbystep/createmonitor",
|
||||
"/stepbystep/createinhibitor",
|
||||
"/stepbystep/createtask",
|
||||
"/stepbystep/hostingbot",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Advanced Guide",
|
||||
children: [
|
||||
"/advanced/",
|
||||
"/advanced/arguments",
|
||||
"/advanced/customizations",
|
||||
"/advanced/dockerhosting",
|
||||
"/advanced/dynamiccommands",
|
||||
"/advanced/permlevels",
|
||||
"/advanced/subcommands",
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RequestManager } from "../../rest/mod.ts";
|
||||
import { RequestManager } from "../../rest/request_manager.ts";
|
||||
import {
|
||||
ChannelEditOptions,
|
||||
ChannelTypes,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { identifyPayload } from "../../bot.ts";
|
||||
import { RequestManager } from "../../rest/mod.ts";
|
||||
import { RequestManager } from "../../rest/request_manager.ts";
|
||||
import {
|
||||
AuditLogs,
|
||||
BannedUser,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { botID } from "../../bot.ts";
|
||||
import { RequestManager } from "../../rest/mod.ts";
|
||||
import { RequestManager } from "../../rest/request_manager.ts";
|
||||
import {
|
||||
ChannelCreatePayload,
|
||||
DMChannelCreatePayload,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { botID } from "../../bot.ts";
|
||||
import { RequestManager } from "../../rest/mod.ts";
|
||||
import { RequestManager } from "../../rest/request_manager.ts";
|
||||
import {
|
||||
Errors,
|
||||
MessageContent,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { botID } from "../../bot.ts";
|
||||
import { RequestManager } from "../../rest/mod.ts";
|
||||
import {
|
||||
CreateSlashCommandOptions,
|
||||
EditSlashCommandOptions,
|
||||
@@ -18,6 +17,7 @@ import { endpoints } from "../../util/constants.ts";
|
||||
import { botHasChannelPermissions } from "../../util/permissions.ts";
|
||||
import { urlToBase64 } from "../../util/utils.ts";
|
||||
import { structures } from "../structures/mod.ts";
|
||||
import { RequestManager } from "../../rest/request_manager.ts";
|
||||
|
||||
/** Create a new webhook. Requires the MANAGE_WEBHOOKS permission. Returns a webhook object on success. Webhook names follow our naming restrictions that can be found in our Usernames and Nicknames documentation, with the following additional stipulations:
|
||||
*
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RequestManager } from "./rest/mod.ts";
|
||||
import { RequestManager } from "./rest/request_manager.ts";
|
||||
import {
|
||||
BotConfig,
|
||||
DiscordBotGatewayData,
|
||||
|
||||
13
src/rest/README.md
Normal file
13
src/rest/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Discordeno Rest
|
||||
|
||||
A standalone and server-less REST module with functionality of REST, independently.
|
||||
|
||||
- Easily host on any serverless infrastructure.
|
||||
- Easy to use and setup with Cloudflare Workers (FREE for 100K requests per day!)
|
||||
- Freedom from global rate limit errors
|
||||
- As your bot grows, you want to handle global rate limits better. Shards don't communicate fast enough to truly handle it properly so this allows 1 rest handler across the entire bot.
|
||||
- In fact, you can host multiple instances of your bot and all connect to the same rest server.
|
||||
- REST does not rest!
|
||||
- Separate rest means if your bot for whatever reason crashes, your requests that are queued will still keep going and will not be lost.
|
||||
- Seamless updates! When you want to update and reboot the bot, you could potentially lose tons of messages or responses that are in queue. Using this you could restart your bot without ever worrying about losing any responses.
|
||||
- Scalability! Scalability! Scalability!
|
||||
21
src/rest/cache.ts
Normal file
21
src/rest/cache.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { RestCache } from "./types/mod.ts";
|
||||
|
||||
export const restCache: RestCache = {
|
||||
pathQueues: new Map(),
|
||||
processingQueue: false,
|
||||
globallyRateLimited: false,
|
||||
ratelimitedPaths: new Map(),
|
||||
eventHandlers: {
|
||||
// BY DEFAULT WE WILL LOG ALL ERRORS TO CONSOLE. USER CAN CHOOSE TO OVERRIDE
|
||||
error: function (_type, error) {
|
||||
console.error(error);
|
||||
},
|
||||
// PLACEHOLDERS TO ALLOW USERS TO CUSTOMIZE
|
||||
fetching() {},
|
||||
fetched() {},
|
||||
fetchSuccess() {},
|
||||
fetchFailed() {},
|
||||
globallyRateLimited() {},
|
||||
retriesMaxed() {},
|
||||
},
|
||||
};
|
||||
1
src/rest/deps.ts
Normal file
1
src/rest/deps.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "https://deno.land/std@0.83.0/http/server.ts";
|
||||
@@ -1 +1,4 @@
|
||||
export * from "./request_manager.ts";
|
||||
export * from "./cache.ts";
|
||||
export * from "./queue.ts";
|
||||
export * from "./request.ts";
|
||||
export * from "./server.ts";
|
||||
|
||||
187
src/rest/queue.ts
Normal file
187
src/rest/queue.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { restCache } from "./cache.ts";
|
||||
import { createRequestBody, processRequestHeaders } from "./request.ts";
|
||||
import { HttpResponseCode } from "./types/mod.ts";
|
||||
|
||||
/** If the queue is not already processing, this will start processing the queue. */
|
||||
export function startQueue() {
|
||||
// IF ALREADY PROCESSING CANCEL
|
||||
if (restCache.processingQueue) return;
|
||||
// MARK AS PROCESSING
|
||||
restCache.processingQueue = true;
|
||||
processQueue();
|
||||
}
|
||||
|
||||
/** Processes the queue by looping over each path separately until the queues are empty. */
|
||||
export function processQueue() {
|
||||
while (restCache.processingQueue) {
|
||||
// FOR EVERY PATH WE WILL START ITS OWN LOOP.
|
||||
restCache.pathQueues.forEach(async (queue) => {
|
||||
// EACH PATH IS UNIQUE LIMITER
|
||||
while (queue.length) {
|
||||
// IF THE BOT IS GLOBALLY RATELIMITED TRY AGAIN
|
||||
if (!restCache.globallyRateLimited) continue;
|
||||
// SELECT THE FIRST ITEM FROM THIS QUEUE
|
||||
const [queuedRequest] = queue;
|
||||
// IF THIS DOESNT HAVE ANY ITEMS JUST CANCEL, THE CLEANER WILL REMOVE IT.
|
||||
if (!queuedRequest) return;
|
||||
|
||||
// IF THIS URL IS STILL RATE LIMITED, TRY AGAIN
|
||||
const urlResetIn = checkRateLimits(queuedRequest.payload.url);
|
||||
if (urlResetIn) continue;
|
||||
|
||||
// IF A BUCKET EXISTS, CHECK THE BUCKET'S RATE LIMITS
|
||||
const bucketResetIn = queuedRequest.payload.bucketID
|
||||
? checkRateLimits(queuedRequest.payload.bucketID)
|
||||
: false;
|
||||
// THIS BUCKET IS STILL RATELIMITED, RE-ADD TO QUEUE
|
||||
if (bucketResetIn) continue;
|
||||
|
||||
// EXECUTE THE REQUEST
|
||||
|
||||
// IF THIS IS A GET REQUEST, CHANGE THE BODY TO QUERY PARAMETERS
|
||||
const query =
|
||||
queuedRequest.payload.method === "get" && queuedRequest.payload.body
|
||||
? Object.entries(queuedRequest.payload.body).map(([key, value]) =>
|
||||
`${encodeURIComponent(key)}=${
|
||||
encodeURIComponent(value as string)
|
||||
}`
|
||||
)
|
||||
.join("&")
|
||||
: "";
|
||||
const urlToUse = queuedRequest.payload.method === "get" && query
|
||||
? `${queuedRequest.payload.url}?${query}`
|
||||
: queuedRequest.payload.url;
|
||||
|
||||
// CUSTOM HANDLER FOR USER TO LOG OR WHATEVER WHENEVER A FETCH IS MADE
|
||||
restCache.eventHandlers.fetching(queuedRequest.payload);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
urlToUse,
|
||||
createRequestBody(queuedRequest),
|
||||
);
|
||||
restCache.eventHandlers.fetched(queuedRequest.payload);
|
||||
const bucketIDFromHeaders = processRequestHeaders(
|
||||
queuedRequest.payload.url,
|
||||
response.headers,
|
||||
);
|
||||
|
||||
if (response.status < 200 && response.status >= 400) {
|
||||
restCache.eventHandlers.error(
|
||||
"httpError",
|
||||
queuedRequest.payload,
|
||||
response,
|
||||
);
|
||||
|
||||
const error = response.status === HttpResponseCode.BadRequest
|
||||
? "The request was improperly formatted, or the server couldn't understand it."
|
||||
: response.status === HttpResponseCode.Unauthorized
|
||||
? "The Authorization header was missing or invalid."
|
||||
: response.status === HttpResponseCode.Forbidden
|
||||
? "The Authorization token you passed did not have permission to the resource."
|
||||
: response.status === HttpResponseCode.NotFound
|
||||
? "The resource at the location specified doesn't exist."
|
||||
: response.status === HttpResponseCode.MethodNotAllowed
|
||||
? "The HTTP method used is not valid for the location specified."
|
||||
: response.status === HttpResponseCode.GatewayUnavailable
|
||||
? "There was not a gateway available to process your request. Wait a bit and retry."
|
||||
: "REQUEST_UNKNOWN_ERROR";
|
||||
|
||||
queuedRequest.request.respond(
|
||||
{ status: response.status, body: JSON.stringify({ error }) },
|
||||
);
|
||||
queue.shift();
|
||||
continue;
|
||||
}
|
||||
|
||||
// SOMETIMES DISCORD RETURNS AN EMPTY 204 RESPONSE THAT CAN'T BE MADE TO JSON
|
||||
if (response.status === 204) {
|
||||
restCache.eventHandlers.fetchSuccess(queuedRequest.payload);
|
||||
return queuedRequest.request.respond({ status: 204 });
|
||||
}
|
||||
|
||||
// CONVERT THE RESPONSE TO JSON
|
||||
const json = await response.json();
|
||||
|
||||
// IF THE RESPONSE WAS RATE LIMITED, HANDLE ACCORDINGLY
|
||||
if (
|
||||
json.retry_after ||
|
||||
json.message === "You are being rate limited."
|
||||
) {
|
||||
// IF IT HAS MAXED RETRIES SOMETHING SERIOUSLY WRONG. CANCEL OUT.
|
||||
if (
|
||||
queuedRequest.payload.retryCount >=
|
||||
queuedRequest.options.maxRetryCount
|
||||
) {
|
||||
restCache.eventHandlers.retriesMaxed(queuedRequest.payload);
|
||||
queuedRequest.request.respond(
|
||||
{
|
||||
status: 200,
|
||||
body: JSON.stringify(
|
||||
{
|
||||
error:
|
||||
"The request was rate limited and it maxed out the retries limit.",
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
// REMOVE ITEM FROM QUEUE TO PREVENT RETRY
|
||||
queue.shift();
|
||||
continue;
|
||||
}
|
||||
|
||||
// SET THE BUCKET ID IF IT WAS PRESENT
|
||||
if (bucketIDFromHeaders) {
|
||||
queuedRequest.payload.bucketID = bucketIDFromHeaders;
|
||||
}
|
||||
// SINCE IT WAS RATELIMITE, RETRY AGAIN
|
||||
continue;
|
||||
}
|
||||
|
||||
restCache.eventHandlers.fetchSuccess(queuedRequest.payload);
|
||||
// REMOVE FROM QUEUE
|
||||
queue.shift();
|
||||
queuedRequest.request.respond(
|
||||
{ status: 200, body: JSON.stringify(json) },
|
||||
);
|
||||
} catch (error) {
|
||||
// SOMETHING WENT WRONG, LOG AND RESPOND WITH ERROR
|
||||
restCache.eventHandlers.fetchFailed(queuedRequest.payload, error);
|
||||
queuedRequest.request.respond(
|
||||
{ status: 404, body: JSON.stringify({ error }) },
|
||||
);
|
||||
// REMOVE FROM QUEUE
|
||||
queue.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// ONCE QUEUE IS DONE, WE CAN TRY CLEANING UP
|
||||
cleanupQueues();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Cleans up the queues by checking if there is nothing left and removing it. */
|
||||
export function cleanupQueues() {
|
||||
restCache.pathQueues.forEach((queue, key) => {
|
||||
if (queue.length) return;
|
||||
// REMOVE IT FROM CACHE
|
||||
restCache.pathQueues.delete(key);
|
||||
});
|
||||
}
|
||||
|
||||
/** Check the rate limits for a url or a bucket. */
|
||||
export function checkRateLimits(url: string) {
|
||||
const ratelimited = restCache.ratelimitedPaths.get(url);
|
||||
const global = restCache.ratelimitedPaths.get("global");
|
||||
const now = Date.now();
|
||||
|
||||
if (ratelimited && now < ratelimited.resetTimestamp) {
|
||||
return ratelimited.resetTimestamp - now;
|
||||
}
|
||||
if (global && now < global.resetTimestamp) {
|
||||
return global.resetTimestamp - now;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
162
src/rest/request.ts
Normal file
162
src/rest/request.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { USER_AGENT } from "../util/constants.ts";
|
||||
import { restCache } from "./cache.ts";
|
||||
import { ServerRequest } from "./deps.ts";
|
||||
import { startQueue } from "./queue.ts";
|
||||
import {
|
||||
QueuedRequest,
|
||||
RestServerOptions,
|
||||
RunMethodOptions,
|
||||
} from "./types/mod.ts";
|
||||
|
||||
/** Processes a request and assigns it to a queue or creates a queue if none exists for it. */
|
||||
export function processRequest(
|
||||
request: ServerRequest,
|
||||
payload: RunMethodOptions,
|
||||
options: RestServerOptions,
|
||||
) {
|
||||
const route = payload.url.substring(payload.url.indexOf("api/"));
|
||||
const parts = route.split("/");
|
||||
// REMOVE THE API
|
||||
parts.shift();
|
||||
// REMOVES THE VERSION NUMBER
|
||||
if (parts[0]?.startsWith("v")) parts.shift();
|
||||
// REMOVE THE MAJOR PARAM
|
||||
parts.shift();
|
||||
|
||||
const [id] = parts;
|
||||
|
||||
const queue = restCache.pathQueues.get(id);
|
||||
// IF THE QUEUE EXISTS JUST ADD THIS TO THE QUEUE
|
||||
if (queue) {
|
||||
queue.push({ request, payload, options });
|
||||
} else {
|
||||
// CREATES A NEW QUEUE
|
||||
restCache.pathQueues.set(id, [{ request, payload, options }]);
|
||||
}
|
||||
|
||||
startQueue();
|
||||
}
|
||||
|
||||
/** Creates the request body and headers that are necessary to send a request. Will handle different types of methods and everything necessary for discord. */
|
||||
export function createRequestBody(queuedRequest: QueuedRequest) {
|
||||
const headers: { [key: string]: string } = {
|
||||
Authorization: queuedRequest.options.token,
|
||||
"User-Agent": USER_AGENT,
|
||||
};
|
||||
|
||||
// GET METHODS SHOULD NOT HAVE A BODY
|
||||
if (queuedRequest.payload.method === "get") {
|
||||
queuedRequest.payload.body = undefined;
|
||||
}
|
||||
|
||||
// IF A REASON IS PROVIDED ENCODE IT IN HEADERS
|
||||
if (queuedRequest.payload.body?.reason) {
|
||||
headers["X-Audit-Log-Reason"] = encodeURIComponent(
|
||||
queuedRequest.payload.body.reason,
|
||||
);
|
||||
}
|
||||
|
||||
// IF A FILE/ATTACHMENT IS PRESENT WE NEED SPECIAL HANDLING
|
||||
if (queuedRequest.payload.body?.file) {
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
"file",
|
||||
queuedRequest.payload.body.file.blob,
|
||||
queuedRequest.payload.body.file.name,
|
||||
);
|
||||
form.append(
|
||||
"payload_json",
|
||||
JSON.stringify({ ...queuedRequest.payload.body, file: undefined }),
|
||||
);
|
||||
queuedRequest.payload.body.file = form;
|
||||
} else if (
|
||||
queuedRequest.payload.body &&
|
||||
!["get", "delete"].includes(queuedRequest.payload.method)
|
||||
) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
return {
|
||||
headers,
|
||||
body: queuedRequest.payload.body?.file ||
|
||||
JSON.stringify(queuedRequest.payload.body),
|
||||
method: queuedRequest.payload.method.toUpperCase(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Processes the rate limit headers and determines if it needs to be ratelimited and returns the bucket id if available */
|
||||
export function processRequestHeaders(url: string, headers: Headers) {
|
||||
let ratelimited = false;
|
||||
|
||||
// GET ALL NECESSARY HEADERS
|
||||
const remaining = headers.get("x-ratelimit-remaining");
|
||||
const resetTimestamp = headers.get("x-ratelimit-reset");
|
||||
const retryAfter = headers.get("retry-after");
|
||||
const global = headers.get("x-ratelimit-global");
|
||||
const bucketID = headers.get("x-ratelimit-bucket");
|
||||
|
||||
// IF THERE IS NO REMAINING RATE LIMIT, MARK IT AS RATE LIMITED
|
||||
if (remaining && remaining === "0") {
|
||||
ratelimited = true;
|
||||
|
||||
// SAVE THE URL AS LIMITED, IMPORTANT FOR NEW REQUESTS BY USER WITHOUT BUCKET
|
||||
restCache.ratelimitedPaths.set(url, {
|
||||
url,
|
||||
resetTimestamp: Number(resetTimestamp) * 1000,
|
||||
bucketID,
|
||||
});
|
||||
|
||||
// SAVE THE BUCKET AS LIMITED SINCE DIFFERENT URLS MAY SHARE A BUCKET
|
||||
if (bucketID) {
|
||||
restCache.ratelimitedPaths.set(bucketID, {
|
||||
url,
|
||||
resetTimestamp: Number(resetTimestamp) * 1000,
|
||||
bucketID,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// IF THERE IS NO REMAINING GLOBAL LIMIT, MARK IT RATE LIMITED GLOBALLY
|
||||
if (global) {
|
||||
const reset = Date.now() + (Number(retryAfter) * 1000);
|
||||
restCache.eventHandlers.globallyRateLimited(url, reset);
|
||||
restCache.globallyRateLimited = true;
|
||||
ratelimited = true;
|
||||
|
||||
restCache.ratelimitedPaths.set("global", {
|
||||
url: "global",
|
||||
resetTimestamp: reset,
|
||||
bucketID,
|
||||
});
|
||||
|
||||
if (bucketID) {
|
||||
restCache.ratelimitedPaths.set(bucketID, {
|
||||
url: "global",
|
||||
resetTimestamp: reset,
|
||||
bucketID,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return ratelimited ? bucketID : undefined;
|
||||
}
|
||||
|
||||
/** This wll create a infinite loop running in 1 seconds using tail recursion to keep rate limits clean. When a rate limit resets, this will remove it so the queue can proceed. */
|
||||
function processRateLimitedPaths() {
|
||||
const now = Date.now();
|
||||
|
||||
restCache.ratelimitedPaths.forEach((value, key) => {
|
||||
// IF THE TIME HAS NOT REACHED CANCEL
|
||||
if (value.resetTimestamp > now) return;
|
||||
// RATE LIMIT IS OVER, DELETE THE RATE LIMITER
|
||||
restCache.ratelimitedPaths.delete(key);
|
||||
// IF IT WAS GLOBAL ALSO MARK THE GLOBAL VALUE AS FALSE
|
||||
if (key === "global") restCache.globallyRateLimited = false;
|
||||
});
|
||||
|
||||
// RECHECK IN 1 SECOND
|
||||
setTimeout(() => processRateLimitedPaths(), 1000);
|
||||
}
|
||||
|
||||
/** Starts the loop */
|
||||
processRateLimitedPaths();
|
||||
59
src/rest/server.ts
Normal file
59
src/rest/server.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
// SERVERLESS REST CLIENT THAT CAN WORK ACROSS SHARDS/WORKERS TO COMMUNICATE GLOBAL RATE LIMITS EASILY
|
||||
import { restCache } from "./cache.ts";
|
||||
import { serve, ServerRequest } from "./deps.ts";
|
||||
import { processRequest } from "./request.ts";
|
||||
import { RestServerOptions } from "./types/mod.ts";
|
||||
|
||||
/** Begins an http server that will handle incoming requests. */
|
||||
export async function startRESTServer(options: RestServerOptions) {
|
||||
const server = serve({ port: options.port });
|
||||
|
||||
for await (const request of server) {
|
||||
handlePayload(request, options).catch((error) => {
|
||||
restCache.eventHandlers.error("processRequest", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Handler function for every request. Converts to json, verified authorization & requirements and begins processing the request */
|
||||
async function handlePayload(
|
||||
request: ServerRequest,
|
||||
options: RestServerOptions,
|
||||
) {
|
||||
// INSTANTLY IGNORE ANY REQUESTS THAT DON'T HAVE THE SECRET AUTHORIZATION KEY
|
||||
const authorization = request.headers.get("authorization");
|
||||
if (authorization !== options.authorization) return;
|
||||
|
||||
// READ BUFFER AFTER AUTH CHECK
|
||||
const buffer = await Deno.readAll(request.body);
|
||||
|
||||
try {
|
||||
// CONVERT THE BODY TO JSON
|
||||
const data = JSON.parse(new TextDecoder().decode(buffer));
|
||||
if (!data.url) {
|
||||
return request.respond(
|
||||
{
|
||||
status: 400,
|
||||
body: JSON.stringify({ error: "No URL was provided." }),
|
||||
},
|
||||
);
|
||||
}
|
||||
if (!data.method) {
|
||||
return request.respond(
|
||||
{
|
||||
status: 400,
|
||||
body: JSON.stringify({ error: "No METHOD was provided." }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// PROCESS THE REQUEST
|
||||
await processRequest(
|
||||
request,
|
||||
{ method: data.method, url: data.url, body: data.body, retryCount: 0 },
|
||||
options,
|
||||
);
|
||||
} catch (error) {
|
||||
restCache.eventHandlers.error("serverRequest", error);
|
||||
}
|
||||
}
|
||||
15
src/rest/types/cache.ts
Normal file
15
src/rest/types/cache.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { QueuedRequest, RateLimitedPath } from "./queue.ts";
|
||||
import { RestEventHandlers } from "./server.ts";
|
||||
|
||||
export interface RestCache {
|
||||
/** The queues that are currently needing to be executed. Key is the url path and the value is all the requests in this same path. Paths are mapped by MAJOR params. */
|
||||
pathQueues: Map<string, QueuedRequest[]>;
|
||||
/** Whether or not the queues are currently processing. */
|
||||
processingQueue: boolean;
|
||||
/** Whether or not this token has been globally rate limited. */
|
||||
globallyRateLimited: boolean;
|
||||
/** The paths that have been rate limited */
|
||||
ratelimitedPaths: Map<string, RateLimitedPath>;
|
||||
/** The event handlers are functions that run when something is happening internally. Users can customize this for analytics, debugging, logging or anything their heart desires. */
|
||||
eventHandlers: RestEventHandlers;
|
||||
}
|
||||
14
src/rest/types/http.ts
Normal file
14
src/rest/types/http.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export enum HttpResponseCode {
|
||||
Ok = 200,
|
||||
Created = 201,
|
||||
NoContent = 204,
|
||||
NotModified = 304,
|
||||
BadRequest = 400,
|
||||
Unauthorized = 401,
|
||||
Forbidden = 403,
|
||||
NotFound = 404,
|
||||
MethodNotAllowed = 405,
|
||||
TooManyRequests = 429,
|
||||
GatewayUnavailable = 502,
|
||||
// ServerError left untyped because it's 5xx.
|
||||
}
|
||||
5
src/rest/types/mod.ts
Normal file
5
src/rest/types/mod.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from "./cache.ts";
|
||||
export * from "./http.ts";
|
||||
export * from "./queue.ts";
|
||||
export * from "./requests.ts";
|
||||
export * from "./server.ts";
|
||||
20
src/rest/types/queue.ts
Normal file
20
src/rest/types/queue.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ServerRequest } from "../deps.ts";
|
||||
import { RestServerOptions, RunMethodOptions } from "./mod.ts";
|
||||
|
||||
export interface RateLimitedPath {
|
||||
/** The url for this request */
|
||||
url: string;
|
||||
/** The timestamp when this request can be made. */
|
||||
resetTimestamp: number;
|
||||
/** The bucket id that is assigned to this request path. */
|
||||
bucketID: string | null;
|
||||
}
|
||||
|
||||
export interface QueuedRequest {
|
||||
/** The request itself, the server received. This will be used to send a response later. */
|
||||
request: ServerRequest;
|
||||
/** The payload like url, method and such for the request. */
|
||||
payload: RunMethodOptions;
|
||||
/** The intial start configurations like token which is necessary for headers. */
|
||||
options: RestServerOptions;
|
||||
}
|
||||
16
src/rest/types/requests.ts
Normal file
16
src/rest/types/requests.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type RequestMethods =
|
||||
| "get"
|
||||
| "post"
|
||||
| "put"
|
||||
| "patch"
|
||||
| "head"
|
||||
| "delete";
|
||||
|
||||
export interface RunMethodOptions {
|
||||
method: RequestMethods;
|
||||
url: string;
|
||||
retryCount: number;
|
||||
// deno-lint-ignore no-explicit-any
|
||||
body?: any;
|
||||
bucketID?: string | null;
|
||||
}
|
||||
45
src/rest/types/server.ts
Normal file
45
src/rest/types/server.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { RunMethodOptions } from "./requests.ts";
|
||||
|
||||
export interface RestServerOptions {
|
||||
/** The port number where the server will be hosted. */
|
||||
port: number;
|
||||
/** The authorization secret key that all requests must provide in its headers. This prevents anyone from making your server do something. */
|
||||
authorization: string;
|
||||
/** The bot token that will be used for authorization. */
|
||||
token: string;
|
||||
/** When a request is rate limited, how many times should it keep retrying the request. Recommended: 10 */
|
||||
maxRetryCount: number;
|
||||
}
|
||||
|
||||
export interface RestEventHandlers {
|
||||
/** Runs whenever an error occurs. Use this to log to sentry or log to discord with a webhook. */
|
||||
error: (
|
||||
type: RestErrorEventTypes,
|
||||
// deno-lint-ignore no-explicit-any
|
||||
...data: any[]
|
||||
) => unknown | Promise<unknown>;
|
||||
/** Runs before every request is about to be fetched. Can be useful for things like analytics and debugging. */
|
||||
fetching: (data: RunMethodOptions) => unknown | Promise<unknown>;
|
||||
/** Runs right after a request is complete whether it worked or not. Useful for analytics and debugging. */
|
||||
fetched: (data: RunMethodOptions) => unknown | Promise<unknown>;
|
||||
/** The fetch request was successfully executed. Useful for analytics. */
|
||||
fetchSuccess: (data: RunMethodOptions) => unknown | Promise<unknown>;
|
||||
/** The fetch request errored somewhere. Useful for analytics and debugging */
|
||||
fetchFailed: (
|
||||
data: RunMethodOptions,
|
||||
// deno-lint-ignore no-explicit-any
|
||||
error: any,
|
||||
) => unknown | Promise<unknown>;
|
||||
/** Runs whenever the token is globally rate limited. Useful for analytics, debugging */
|
||||
globallyRateLimited: (
|
||||
url: string,
|
||||
resetAt: number,
|
||||
) => unknown | Promise<unknown>;
|
||||
/** Runs when the maximum amount of retries has been reached. Useful for logging and debugging */
|
||||
retriesMaxed: (data: RunMethodOptions) => unknown | Promise<unknown>;
|
||||
}
|
||||
|
||||
export type RestErrorEventTypes =
|
||||
| "serverRequest"
|
||||
| "processRequest"
|
||||
| "httpError";
|
||||
Reference in New Issue
Block a user