From 412fb71720890844f04021de7f61c475a306f6b1 Mon Sep 17 00:00:00 2001 From: Skillz4Killz <23035000+Skillz4Killz@users.noreply.github.com> Date: Fri, 29 Jan 2021 21:56:35 +0000 Subject: [PATCH 01/34] create readme --- src/ws/README.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 src/ws/README.md diff --git a/src/ws/README.md b/src/ws/README.md new file mode 100644 index 000000000..8c031950c --- /dev/null +++ b/src/ws/README.md @@ -0,0 +1,3 @@ +# Standalone WS / Proxy WS + +This WS service is meant for ADVANCED DEVELOPERS ONLY! From 5734e2cc6408c698faa78f2110c13288571205b2 Mon Sep 17 00:00:00 2001 From: Skillz4Killz <23035000+Skillz4Killz@users.noreply.github.com> Date: Fri, 29 Jan 2021 21:56:44 +0000 Subject: [PATCH 02/34] phase 1 --- src/ws/proxy/deps.ts | 1 + src/ws/proxy/manager.ts | 80 +++++++++++ src/ws/proxy/mod.ts | 0 src/ws/proxy/shard.ts | 293 ++++++++++++++++++++++++++++++++++++++++ src/ws/proxy/ws.ts | 95 +++++++++++++ src/ws/shard_manager.ts | 2 +- 6 files changed, 470 insertions(+), 1 deletion(-) create mode 100644 src/ws/proxy/deps.ts create mode 100644 src/ws/proxy/manager.ts create mode 100644 src/ws/proxy/mod.ts create mode 100644 src/ws/proxy/shard.ts create mode 100644 src/ws/proxy/ws.ts diff --git a/src/ws/proxy/deps.ts b/src/ws/proxy/deps.ts new file mode 100644 index 000000000..fef4dd6c7 --- /dev/null +++ b/src/ws/proxy/deps.ts @@ -0,0 +1 @@ +export { decompress_with as decompressWith } from "https://unpkg.com/@evan/wasm@0.0.40/target/zlib/deno.js"; diff --git a/src/ws/proxy/manager.ts b/src/ws/proxy/manager.ts new file mode 100644 index 000000000..e66fe5795 --- /dev/null +++ b/src/ws/proxy/manager.ts @@ -0,0 +1,80 @@ +import { getGatewayBot } from "../../api/handlers/gateway.ts"; +import { Intents } from "../../types/options.ts"; +import { ws } from "./ws.ts"; + +/** ADVANCED DEVS ONLY!!!!!! + * Starts the standalone gateway. + * This will require starting the bot separately. + */ +export async function startGateway(options: StartGatewayOptions) { + ws.identifyPayload.token = `Bot ${options.token}`; + ws.firstShardID = options.firstShardID; + ws.url = options.url; + + if (options.compress) { + ws.identifyPayload.compress = options.compress; + } + + ws.identifyPayload.intents = options.intents.reduce( + (bits, next) => (bits |= typeof next === "string" ? Intents[next] : next), + 0, + ); + + const data = await getGatewayBot(); + ws.maxShards = options.maxShards || data.shards; + ws.lastShardID = options.lastShardID || data.shards - 1; + + // TODO: ALL THE FOLLOWING CAN BE REPLACED BY THIS 1 LINE + // ws.botGatewayData = snakeToCamel(await getGatewayBot()) + ws.botGatewayData.sessionStartLimit.total = data.session_start_limit.total; + ws.botGatewayData.sessionStartLimit.resetAfter = + data.session_start_limit.reset_after; + ws.botGatewayData.sessionStartLimit.remaining = + data.session_start_limit.remaining; + ws.botGatewayData.sessionStartLimit.maxConcurrency = + data.session_start_limit.max_concurrency; + ws.botGatewayData.shards = data.shards; + ws.botGatewayData.url = data.url; + + ws.spawnShards(ws.firstShardID); +} + +export function spawnShards(shardID: number) { + let skipChecks = 0; + + while (shardID <= ws.lastShardID) { + if (skipChecks) { + // Start The shard + ws.identify(shardID, ws.maxShards); + + shardID++; + skipChecks--; + continue; + } + + // Previous shards is still not fully ready. + if (!ws.createNextShard) continue; + + // Allows next iteration to create shard + ws.createNextShard = false; + // Set the amount of shards to start up be the bots max concurrency limit + skipChecks = ws.botGatewayData.sessionStartLimit.maxConcurrency; + } +} + +export interface StartGatewayOptions { + /** The bot token. */ + token: string; + /** Whether or not to use compression for gateway payloads. */ + compress?: boolean; + /** The intents you would like to enable. */ + intents: (Intents | keyof typeof Intents)[]; + /** The max amount of shards used for identifying. This can be useful for zero-downtime updates or resharding. */ + maxShards?: number; + /** The first shard ID for this group of shards. */ + firstShardID: number; + /** The last shard ID for this group. If none is provided, it will default to loading all shards. */ + lastShardID?: number; + /** The url to forward all payloads to. */ + url: string; +} diff --git a/src/ws/proxy/mod.ts b/src/ws/proxy/mod.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/ws/proxy/shard.ts b/src/ws/proxy/shard.ts new file mode 100644 index 000000000..64e5b1e0e --- /dev/null +++ b/src/ws/proxy/shard.ts @@ -0,0 +1,293 @@ +import { + DiscordHeartbeatPayload, + DiscordPayload, + GatewayOpcode, + ReadyPayload, +} from "../../types/discord.ts"; +import { decompressWith } from "./deps.ts"; +import { ws } from "./ws.ts"; + +export function resume(shardID: number) { + // TODO: Log that this is happening + + // CREATE A SHARD + const socket = ws.createShard(shardID); + + // NOW WE HANDLE RESUMING THIS SHARD + // Get the old data for this shard necessary for resuming + const oldShard = ws.shards.get(shardID); + // TODO: HOW TO CLOSE OLD SHARD SOCKET!!! + // TODO: STOP OLD HEARTBEAT + const sessionID = oldShard?.sessionID || ""; + const previousSequenceNumber = oldShard?.previousSequenceNumber || 0; + + ws.shards.set(shardID, { + id: shardID, + ws: socket, + resumeInterval: 0, + sessionID, + previousSequenceNumber, + resuming: false, + heartbeat: { + lastSentAt: 0, + lastReceivedAt: 0, + acknowledged: false, + keepAlive: false, + interval: 0, + intervalID: 0, + }, + }); + + // Resume on open + socket.onopen = () => { + socket.send(JSON.stringify({ + op: GatewayOpcode.Resume, + d: { + token: ws.identifyPayload.token, + session_id: sessionID, + seq: previousSequenceNumber, + }, + })); + }; +} + +export function identify(shardID: number, maxShards: number) { + // TODO: Log that this is happening + + // CREATE A SHARD + const socket = ws.createShard(shardID); + + // Identify can just set/reset the settings for the shard + ws.shards.set(shardID, { + id: shardID, + ws: socket, + resumeInterval: 0, + sessionID: "", + previousSequenceNumber: 0, + resuming: false, + heartbeat: { + lastSentAt: 0, + lastReceivedAt: 0, + acknowledged: false, + keepAlive: false, + interval: 0, + intervalID: 0, + }, + }); + + socket.send( + JSON.stringify( + { + op: GatewayOpcode.Identify, + d: { ...ws.identifyPayload, shard: [shardID, maxShards] }, + }, + ), + ); +} + +export function heartbeat(shardID: number, interval: number) { + // TODO: Log that this is happening + + const shard = ws.shards.get(shardID); + if (!shard) return; + + shard.heartbeat.keepAlive = true; + shard.heartbeat.acknowledged = false; + shard.heartbeat.lastSentAt = Date.now(); + shard.heartbeat.interval = interval; + + shard.heartbeat.intervalID = setInterval(() => { + // TODO: Log that this is happening + + const currentShard = ws.shards.get(shardID); + if (!currentShard) return; + + if ( + currentShard.ws.readyState === WebSocket.CLOSED || + !currentShard.heartbeat.keepAlive + ) { + // TODO: Log that this is happening + + // STOP THE HEARTBEAT + return clearInterval(currentShard.heartbeat.intervalID); + } + + currentShard.ws.send( + JSON.stringify( + { + op: GatewayOpcode.Heartbeat, + d: currentShard.previousSequenceNumber, + }, + ), + ); + }, interval); +} + +export function createShard(shardID: number) { + const socket = new WebSocket(ws.botGatewayData.url); + socket.binaryType = "arraybuffer"; + + socket.onerror = (errorEvent) => { + // TODO: Log that this is happening + + // eventHandlers.debug?.({ + // type: "wsError", + // data: { shardID, ...errorEvent }, + // }); + }; + + socket.onmessage = ({ data: message }) => { + if (message instanceof ArrayBuffer) { + message = new Uint8Array(message); + } + + if (message instanceof Uint8Array) { + message = decompressWith( + message, + 0, + (slice: Uint8Array) => ws.utf8decoder.decode(slice), + ); + } + + if (typeof message !== "string") return; + + const messageData = JSON.parse(message); + // TODO: Log that this is happening + // if (!messageData.t) eventHandlers.rawGateway?.(messageData); + switch (messageData.op) { + case GatewayOpcode.Hello: + ws.heartbeat( + shardID, + (messageData.d as DiscordHeartbeatPayload).heartbeat_interval, + ); + break; + case GatewayOpcode.HeartbeatACK: + if (ws.shards.has(shardID)) { + ws.shards.get(shardID)!.heartbeat.acknowledged = true; + } + break; + case GatewayOpcode.Reconnect: + // TODO: Log that this is happening + // eventHandlers.debug?.( + // { type: "gatewayReconnect", data: { shardID } }, + // ); + if (ws.shards.has(shardID)) { + ws.shards.get(shardID)!.resuming = true; + } + + resume(shardID); + break; + case GatewayOpcode.InvalidSession: + // TODO: Log that this is happening + // eventHandlers.debug?.( + // { + // type: "gatewayInvalidSession", + // data: { shardID, data }, + // }, + // ); + // When d is false we need to reidentify + if (!messageData.d) { + identify(shardID, ws.maxShards); + break; + } + + if (ws.shards.has(shardID)) { + ws.shards.get(shardID)!.resuming = true; + } + + resume(shardID); + break; + default: + if (messageData.t === "RESUMED") { + // TODO: Log that this is happening + // eventHandlers.debug?.( + // { type: "gatewayResumed", data: { shardID } }, + // ); + + if (ws.shards.has(shardID)) { + ws.shards.get(shardID)!.resuming = false; + } + break; + } + + // Important for RESUME + if (messageData.t === "READY") { + const shard = ws.shards.get(shardID); + if (shard) { + shard.sessionID = (messageData.d as ReadyPayload).session_id; + } + } + + // Update the sequence number if it is present + if (messageData.s) { + const shard = ws.shards.get(shardID); + if (shard) { + shard.previousSequenceNumber = messageData.s; + } + } + + ws.handleDiscordPayload(messageData, shardID); + break; + } + }; + + socket.onclose = ({ reason, code, wasClean }) => { + // TODO: Log that this is happening + // eventHandlers.debug?.( + // { + // type: "wsClose", + // data: { shardID, code, reason, wasClean }, + // }, + // ); + + // TODO: ENUM FOR THESE CODES? + switch (code) { + case 4001: + case 4002: + case 4004: + case 4005: + case 4010: + case 4011: + case 4012: + case 4013: + case 4014: + throw new Error( + reason || "Discord gave no reason! GG! You broke Discord!", + ); + // THESE ERRORS CAN NO BE RESUMED! THEY MUST RE-IDENTIFY! + case 4003: + case 4007: + case 4008: + case 4009: + // TODO: Log that this is happening + // eventHandlers.debug?.({ + // type: "wsReconnect", + // data: { shardID, code, reason, wasClean }, + // }); + identify(shardID, ws.maxShards); + break; + default: + resume(shardID); + break; + } + }; + + return socket; +} + +export async function handleDiscordPayload( + data: DiscordPayload, + shardID: number, +) { + // TODO: Log that this is happening + // eventHandlers.raw?.(data); + // await eventHandlers.dispatchRequirements?.(data, shardID); + + await fetch(ws.url, { + method: "post", + body: JSON.stringify({ + shardID, + data, + }), + }).catch(console.error); +} diff --git a/src/ws/proxy/ws.ts b/src/ws/proxy/ws.ts new file mode 100644 index 000000000..9113cd28d --- /dev/null +++ b/src/ws/proxy/ws.ts @@ -0,0 +1,95 @@ +import { Collection } from "../../util/collection.ts"; +import { spawnShards, startGateway } from "./manager.ts"; +import { + createShard, + handleDiscordPayload, + heartbeat, + identify, +} from "./shard.ts"; + +// CONTROLLER LIKE INTERFACE FOR WS HANDLING +export const ws = { + /** The url that all discord payloads for the dispatch type should be sent to. */ + url: "", + /** The maximum shard ID number. Useful for zero-downtime updates or resharding. */ + maxShards: 1, + /** The first shard ID to start spawning. */ + firstShardID: 0, + /** The last shard ID for this cluster. */ + lastShardID: 1, + /** This prop decides whether Discord allows our next shard to be started. When 1 starts, this is set to false until it is ready for the next one. */ + createNextShard: true, + /** The identify payload holds the necessary data to connect and stay connected with Discords WSS. */ + identifyPayload: { + token: "", + compress: false, + properties: { + $os: "linux", + $browser: "Discordeno", + $device: "Discordeno", + }, + intents: 0, + shard: [0, 0], + }, + botGatewayData: { + /** The WSS URL that can be used for connecting to the gateway. */ + url: "wss://gateway.discord.gg/?v=8&encoding=json", + /** The recommended number of shards to use when connecting. */ + shards: 1, + /** Info on the current start limit. */ + sessionStartLimit: { + /** The total number of session starts the current user is allowed. */ + total: 1000, + /** The remaining number of session starts the current user is allowed. */ + remaining: 1000, + /** Milliseconds left until limit is reset. */ + resetAfter: 0, + /** The number of identify requests allowed per 5 seconds. + * So, if you had a max concurrency of 16, and 16 shards for example, you could start them all up at the same time. + * Whereas if you had 32 shards, if you tried to start up shard 0 and 16 at the same time for example, it would not work. You can start shards 0-15 concurrently, then 16-31... + * */ + maxConcurrency: 1, + }, + }, + shards: new Collection(), + utf8decoder: new TextDecoder(), + + // METHODS + + /** The handler function that starts the gateway. */ + startGateway, + /** The handler for spawning ALL the shards. */ + spawnShards, + createShard, + identify, + heartbeat, + handleDiscordPayload, +}; + +export interface DiscordenoShard { + /** The shard id number */ + id: number; + /** The websocket for this shard */ + ws: WebSocket; + resumeInterval: number; + /** The session id important for resuming connections. */ + sessionID: string; + /** The previous sequence number, important for resuming connections. */ + previousSequenceNumber: number | null; + /** Whether the shard is currently resuming. */ + resuming: boolean; + heartbeat: { + /** The exact timestamp the last heartbeat was sent */ + lastSentAt: number; + /** The timestamp the last heartbeat ACK was received from discord. */ + lastReceivedAt: number; + /** Whether or not the heartbeat was acknowledged by discord in time. */ + acknowledged: boolean; + /** Whether or not to keep heartbeating. Useful for when needing to stop heartbeating. */ + keepAlive: boolean; + /** The interval between heartbeats requested by discord. */ + interval: number; + /** The id of the interval, useful for stopping the interval if ws closed. */ + intervalID: number; + }; +} diff --git a/src/ws/shard_manager.ts b/src/ws/shard_manager.ts index 4d92625fa..38fafb515 100644 --- a/src/ws/shard_manager.ts +++ b/src/ws/shard_manager.ts @@ -39,7 +39,7 @@ export async function spawnShards( shardID, data.shards > lastShardID ? data.shards : lastShardID, ]; - // Start The shard + // Startx The shard await createShard(data, payload, false, shardID); // Spawn next shard await spawnShards( From 0c2f940402b08cbb3d0cf2bac549d37580ff1e00 Mon Sep 17 00:00:00 2001 From: Skillz4Killz <23035000+Skillz4Killz@users.noreply.github.com> Date: Fri, 29 Jan 2021 21:59:40 +0000 Subject: [PATCH 03/34] remove comments --- src/ws/proxy/shard.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/ws/proxy/shard.ts b/src/ws/proxy/shard.ts index 64e5b1e0e..41b8da7db 100644 --- a/src/ws/proxy/shard.ts +++ b/src/ws/proxy/shard.ts @@ -279,10 +279,6 @@ export async function handleDiscordPayload( data: DiscordPayload, shardID: number, ) { - // TODO: Log that this is happening - // eventHandlers.raw?.(data); - // await eventHandlers.dispatchRequirements?.(data, shardID); - await fetch(ws.url, { method: "post", body: JSON.stringify({ From e88c49c325e8b18c02b6c9d23c0b16c0742278ac Mon Sep 17 00:00:00 2001 From: Skillz4Killz <23035000+Skillz4Killz@users.noreply.github.com> Date: Mon, 1 Feb 2021 13:03:20 -0500 Subject: [PATCH 04/34] Update Dockerfile --- .devcontainer/Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 2df86ac9c..2ddfa93f7 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -8,7 +8,8 @@ RUN mkdir -p /deno \ ENV PATH=${DENO_INSTALL}/bin:${PATH} \ DENO_DIR=${DENO_INSTALL}/.cache/deno -RUN deno cache deps.ts +# NOT WORKING ATM +# RUN deno cache deps.ts # [Optional] Uncomment this section to install additional OS packages. # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ From 048dd7a4f235f7c9c52112d10e239cf6a67a5ed2 Mon Sep 17 00:00:00 2001 From: Skillz Date: Wed, 10 Feb 2021 12:06:32 -0500 Subject: [PATCH 05/34] test queue system for shard spawinging --- src/ws/proxy/deps.ts | 1 + src/ws/proxy/manager.ts | 108 ++++++++++++++++++++++++++++++++++------ src/ws/proxy/ws.ts | 7 ++- 3 files changed, 99 insertions(+), 17 deletions(-) diff --git a/src/ws/proxy/deps.ts b/src/ws/proxy/deps.ts index fef4dd6c7..4a3715e57 100644 --- a/src/ws/proxy/deps.ts +++ b/src/ws/proxy/deps.ts @@ -1 +1,2 @@ export { decompress_with as decompressWith } from "https://unpkg.com/@evan/wasm@0.0.40/target/zlib/deno.js"; +export { default as Queue } from "https://esm.sh/denque@1.5.0"; diff --git a/src/ws/proxy/manager.ts b/src/ws/proxy/manager.ts index e66fe5795..da4052487 100644 --- a/src/ws/proxy/manager.ts +++ b/src/ws/proxy/manager.ts @@ -1,5 +1,7 @@ import { getGatewayBot } from "../../api/handlers/gateway.ts"; import { Intents } from "../../types/options.ts"; +import { Collection } from "../../util/collection.ts"; +import { Queue } from "./deps.ts"; import { ws } from "./ws.ts"; /** ADVANCED DEVS ONLY!!!!!! @@ -10,6 +12,8 @@ export async function startGateway(options: StartGatewayOptions) { ws.identifyPayload.token = `Bot ${options.token}`; ws.firstShardID = options.firstShardID; ws.url = options.url; + if (options.shardsPerCluster) ws.shardsPerCluster = options.shardsPerCluster; + if (options.maxClusters) ws.maxClusters = options.maxClusters; if (options.compress) { ws.identifyPayload.compress = options.compress; @@ -36,30 +40,98 @@ export async function startGateway(options: StartGatewayOptions) { ws.botGatewayData.shards = data.shards; ws.botGatewayData.url = data.url; + // TODO: LOG THIS IS HAPPENING ws.spawnShards(ws.firstShardID); } +/** Begin spawning shards. + * TODO: Put in a queue system and support clustering + */ export function spawnShards(shardID: number) { - let skipChecks = 0; + /** Stored as bucketID: [clusterID, Queue[ShardIDs]] */ + const buckets = new Collection(); + const maxShards = ws.maxShards || ws.botGatewayData.shards; + let cluster = 0; - while (shardID <= ws.lastShardID) { - if (skipChecks) { - // Start The shard - ws.identify(shardID, ws.maxShards); + for ( + let index = 0; + index < ws.botGatewayData.sessionStartLimit.maxConcurrency; + index++ + ) { + // ORGANIZE ALL SHARDS INTO THEIR OWN BUCKETS + for (let i = 0; i < maxShards; i++) { + const bucketID = i % ws.botGatewayData.sessionStartLimit.maxConcurrency; + const bucket = buckets.get(bucketID); - shardID++; - skipChecks--; - continue; + if (!bucket) { + const queue = new Queue(); + queue.push(i); + + // Create the bucket since it doesnt exist + buckets.set(bucketID, [[cluster, queue]]); + + if (cluster + 1 <= ws.maxClusters) cluster++; + else { + // TODO: LOG THIS HAS HAPPENED + } + } else { + // FIND A QUEUE IN THIS BUCKET THAT HAS SPACE + const queue = bucket.find((q) => q[1].length < ws.shardsPerCluster + 1); + if (queue) { + // IF THE QUEUE HAS SPACE JUST ADD IT TO THIS QUEUE + queue[1].push(i); + } else { + const newQueue = new Queue(); + newQueue.push(i); + + if (cluster + 1 <= ws.maxClusters) cluster++; + // ADD A NEW QUEUE FOR THIS SHARD + bucket.push([cluster, newQueue]); + } + } } - - // Previous shards is still not fully ready. - if (!ws.createNextShard) continue; - - // Allows next iteration to create shard - ws.createNextShard = false; - // Set the amount of shards to start up be the bots max concurrency limit - skipChecks = ws.botGatewayData.sessionStartLimit.maxConcurrency; } + + // SPREAD THIS OUT TO DIFFERENT CLUSTERS TO BEGIN STARTING UP + buckets.forEach(async (bucket, bucketID) => { + for (const [clusterID, queue] of bucket) { + let shardID = queue.shift(); + + while (shardID !== undefined) { + await ws.tellClusterToIdentify(clusterID as number, shardID, bucketID); + shardID = queue.shift(); + } + } + }); + // let skipChecks = 0; + + // while (shardID <= ws.lastShardID) { + // if (skipChecks) { + // // Start The shard + // ws.identify(shardID, ws.maxShards); + + // shardID++; + // skipChecks--; + // continue; + // } + + // // Previous shards is still not fully ready. + // if (!ws.createNextShard) continue; + + // // Allows next iteration to create shard + // ws.createNextShard = false; + // // Set the amount of shards to start up be the bots max concurrency limit + // skipChecks = ws.botGatewayData.sessionStartLimit.maxConcurrency; + // } +} + +/** Allows users to hook in and change to communicate to different clusters across different servers or anything they like. For example using redis pubsub to talk to other servers. */ +export async function tellClusterToIdentify( + clusterID: number, + shardID: number, + bucketID: number, +) { + await ws.identify(shardID, ws.maxShards); } export interface StartGatewayOptions { @@ -77,4 +149,8 @@ export interface StartGatewayOptions { lastShardID?: number; /** The url to forward all payloads to. */ url: string; + /** The amount of shards per cluster. By default this is 25. Use this to spread the load from shards to different CPU cores. */ + shardsPerCluster?: number; + /** The maximum amount of clusters available. By default this is 4. Another way to think of cluster is how many CPU cores does your server/machine have. */ + maxClusters?: number; } diff --git a/src/ws/proxy/ws.ts b/src/ws/proxy/ws.ts index 9113cd28d..b2c225e08 100644 --- a/src/ws/proxy/ws.ts +++ b/src/ws/proxy/ws.ts @@ -1,5 +1,5 @@ import { Collection } from "../../util/collection.ts"; -import { spawnShards, startGateway } from "./manager.ts"; +import { spawnShards, startGateway, tellClusterToIdentify } from "./manager.ts"; import { createShard, handleDiscordPayload, @@ -13,6 +13,10 @@ export const ws = { url: "", /** The maximum shard ID number. Useful for zero-downtime updates or resharding. */ maxShards: 1, + /** The amount of shards to load per cluster */ + shardsPerCluster: 25, + /** The maximum amount of clusters to use for your bot. */ + maxClusters: 4, /** The first shard ID to start spawning. */ firstShardID: 0, /** The last shard ID for this cluster. */ @@ -64,6 +68,7 @@ export const ws = { identify, heartbeat, handleDiscordPayload, + tellClusterToIdentify, }; export interface DiscordenoShard { From b56d0781cc57cf9781df8255b943b3fd6c8f5905 Mon Sep 17 00:00:00 2001 From: Skillz4Killz <23035000+Skillz4Killz@users.noreply.github.com> Date: Mon, 15 Feb 2021 03:41:31 +0000 Subject: [PATCH 06/34] add benefits --- src/ws/README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/ws/README.md b/src/ws/README.md index 8c031950c..ec8e5e01f 100644 --- a/src/ws/README.md +++ b/src/ws/README.md @@ -1,3 +1,23 @@ # Standalone WS / Proxy WS This WS service is meant for ADVANCED DEVELOPERS ONLY! + +## Benefits + +- **Zero Downtime Updates**: + - Your bot can be updated in a matter of seconds. With normal sharding, you have to restart which also has to process identifying all your shards with a 1/~5s rate limit. With WS handling moved to a proxy process, this allows you to instantly get the bot code restarted without any concerns of delays. If you have a bot on 200,000 servers normally this would mean a 20 minute delay to restart your bot if you made a small change and restarted. + +- **Zero Downtime Resharding**: + - Discord stops letting your bot get added to new servers at certain points in time. For example, suppose you had 150,000 servers running 150 shards. The maximum amount of servers your shards could hold is 150 * 2500 = 375,000. If your bot reaches this, it can no longer join new servers until it re-shards. + - DD proxy provides 2 types of re-sharding. Automated and manual. You can also have both. + - `Automated`: This system will automatically begin a Zero-downtime resharding process behind the scenes when you reach 80% of your maximum servers allowed by your shards. For example, since 375,000 was the max, at 300,000 we would begin re-sharding behind the scenes with `ZERO DOWNTIME`. + - 80% of maximum servers reached (The % of 80% is customizable.) + - Identify limits have room to allow re-sharding. (Also customizable) + - `Manual`: You can also trigger this manually should you choose. + +- **Horizontal Scaling**: + - The proxy system allows you to scale the bot horizontally. When you reach a huge size, you can either keep spending more money to keep beefing up your server or you can buy several cheaper servers and scale horizontally. The proxy means you can have WS handling on a completely separate system. + +- **No Loss Restarts**: + - When you restart a bot without the proxy system, normally you would lose many events. Users may be using commands or messages are sent that will not be filtered. As your bot's grow this number rises dramatically. Users may join who wont get the auto-roles or any other actions your bot should take. With the proxy system, you can keep restarting your bot and never lose any events. Events will be put into a queue while your bot is down(max size of queue is customizable), once the bot is available the queue will begin processing all events. + From fb127dabc7b8d58ca76021b22c3353e4aaf53089 Mon Sep 17 00:00:00 2001 From: Skillz4Killz <23035000+Skillz4Killz@users.noreply.github.com> Date: Mon, 15 Feb 2021 16:47:55 +0000 Subject: [PATCH 07/34] remove queue --- src/ws/proxy/deps.ts | 1 - src/ws/proxy/manager.ts | 42 ++++++++--------------------------------- 2 files changed, 8 insertions(+), 35 deletions(-) diff --git a/src/ws/proxy/deps.ts b/src/ws/proxy/deps.ts index 4a3715e57..fef4dd6c7 100644 --- a/src/ws/proxy/deps.ts +++ b/src/ws/proxy/deps.ts @@ -1,2 +1 @@ export { decompress_with as decompressWith } from "https://unpkg.com/@evan/wasm@0.0.40/target/zlib/deno.js"; -export { default as Queue } from "https://esm.sh/denque@1.5.0"; diff --git a/src/ws/proxy/manager.ts b/src/ws/proxy/manager.ts index da4052487..b56f7bb4a 100644 --- a/src/ws/proxy/manager.ts +++ b/src/ws/proxy/manager.ts @@ -1,7 +1,6 @@ import { getGatewayBot } from "../../api/handlers/gateway.ts"; import { Intents } from "../../types/options.ts"; import { Collection } from "../../util/collection.ts"; -import { Queue } from "./deps.ts"; import { ws } from "./ws.ts"; /** ADVANCED DEVS ONLY!!!!!! @@ -48,8 +47,8 @@ export async function startGateway(options: StartGatewayOptions) { * TODO: Put in a queue system and support clustering */ export function spawnShards(shardID: number) { - /** Stored as bucketID: [clusterID, Queue[ShardIDs]] */ - const buckets = new Collection(); + /** Stored as bucketID: [clusterID, [ShardIDs]] */ + const buckets = new Collection(); const maxShards = ws.maxShards || ws.botGatewayData.shards; let cluster = 0; @@ -64,11 +63,8 @@ export function spawnShards(shardID: number) { const bucket = buckets.get(bucketID); if (!bucket) { - const queue = new Queue(); - queue.push(i); - // Create the bucket since it doesnt exist - buckets.set(bucketID, [[cluster, queue]]); + buckets.set(bucketID, [[cluster, i]]); if (cluster + 1 <= ws.maxClusters) cluster++; else { @@ -76,17 +72,14 @@ export function spawnShards(shardID: number) { } } else { // FIND A QUEUE IN THIS BUCKET THAT HAS SPACE - const queue = bucket.find((q) => q[1].length < ws.shardsPerCluster + 1); + const queue = bucket.find((q) => q.length < ws.shardsPerCluster + 1); if (queue) { // IF THE QUEUE HAS SPACE JUST ADD IT TO THIS QUEUE - queue[1].push(i); + queue.push(i); } else { - const newQueue = new Queue(); - newQueue.push(i); - if (cluster + 1 <= ws.maxClusters) cluster++; // ADD A NEW QUEUE FOR THIS SHARD - bucket.push([cluster, newQueue]); + bucket.push([cluster, i]); } } } @@ -94,7 +87,7 @@ export function spawnShards(shardID: number) { // SPREAD THIS OUT TO DIFFERENT CLUSTERS TO BEGIN STARTING UP buckets.forEach(async (bucket, bucketID) => { - for (const [clusterID, queue] of bucket) { + for (const [clusterID, ...queue] of bucket) { let shardID = queue.shift(); while (shardID !== undefined) { @@ -103,26 +96,6 @@ export function spawnShards(shardID: number) { } } }); - // let skipChecks = 0; - - // while (shardID <= ws.lastShardID) { - // if (skipChecks) { - // // Start The shard - // ws.identify(shardID, ws.maxShards); - - // shardID++; - // skipChecks--; - // continue; - // } - - // // Previous shards is still not fully ready. - // if (!ws.createNextShard) continue; - - // // Allows next iteration to create shard - // ws.createNextShard = false; - // // Set the amount of shards to start up be the bots max concurrency limit - // skipChecks = ws.botGatewayData.sessionStartLimit.maxConcurrency; - // } } /** Allows users to hook in and change to communicate to different clusters across different servers or anything they like. For example using redis pubsub to talk to other servers. */ @@ -131,6 +104,7 @@ export async function tellClusterToIdentify( shardID: number, bucketID: number, ) { + // TODO: resolve promise 5 sec after ready await ws.identify(shardID, ws.maxShards); } From 3c99e8d74072a2ce661dcea9ce4482002e556baf Mon Sep 17 00:00:00 2001 From: Skillz4Killz <23035000+Skillz4Killz@users.noreply.github.com> Date: Mon, 15 Feb 2021 16:48:03 +0000 Subject: [PATCH 08/34] controller benefit --- src/ws/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ws/README.md b/src/ws/README.md index ec8e5e01f..3fbc6ff10 100644 --- a/src/ws/README.md +++ b/src/ws/README.md @@ -21,3 +21,5 @@ This WS service is meant for ADVANCED DEVELOPERS ONLY! - **No Loss Restarts**: - When you restart a bot without the proxy system, normally you would lose many events. Users may be using commands or messages are sent that will not be filtered. As your bot's grow this number rises dramatically. Users may join who wont get the auto-roles or any other actions your bot should take. With the proxy system, you can keep restarting your bot and never lose any events. Events will be put into a queue while your bot is down(max size of queue is customizable), once the bot is available the queue will begin processing all events. +- **Controllers**: + - The controller aspect gives you full control over everything inside the proxy. You can provide a function to simply override the handler. For example, if you would like a certain function to do something different, instead of having to fork and maintain your fork, you can just provide a function to override. \ No newline at end of file From 6d32d283b9ec6caa5edc529c423fdb782759c35f Mon Sep 17 00:00:00 2001 From: Skillz4Killz <23035000+Skillz4Killz@users.noreply.github.com> Date: Mon, 15 Feb 2021 19:08:08 +0000 Subject: [PATCH 09/34] add log event --- src/ws/proxy/events.ts | 21 ++++++++++ src/ws/proxy/shard.ts | 87 +++++++++++++++++------------------------- src/ws/proxy/ws.ts | 2 + 3 files changed, 59 insertions(+), 51 deletions(-) create mode 100644 src/ws/proxy/events.ts diff --git a/src/ws/proxy/events.ts b/src/ws/proxy/events.ts new file mode 100644 index 000000000..76efa8ec9 --- /dev/null +++ b/src/ws/proxy/events.ts @@ -0,0 +1,21 @@ +import { DiscordPayload } from "../../types/discord.ts"; +import { DiscordenoShard } from "./ws.ts"; + +/** The handler for logging different actions happening inside the ws. User can override and put custom handling per event. */ +export function log(type: "CLOSED", data: { shardID: number, payload: CloseEvent }): unknown; +export function log(type: "CLOSED_RECONNECT", data: { shardID: number, payload: CloseEvent }): unknown; +export function log(type: "ERROR", data: Record & { shardID: number }): unknown; +export function log(type: "HEARTBEATING", data: { shardID: number, shard: DiscordenoShard }): unknown; +export function log(type: "HEARTBEATING_CLOSED", data: { shardID: number, shard: DiscordenoShard }): unknown; +export function log(type: "HEARTBEATING_DETAILS", data: { shardID: number, interval: number, shard: DiscordenoShard }): unknown; +export function log(type: "HEARTBEATING_STARTED", data: { shardID: number, interval: number }): unknown; +export function log(type: "IDENTIFYING", data: { shardID: number, maxShards: number }): unknown; +export function log(type: "INVALID_SESSION", data: { shardID: number, payload: DiscordPayload }): unknown; +export function log(type: "RAW", data: Record): unknown; +export function log(type: "RECONNECT", data: { shardID: number }): unknown; +export function log(type: "RESUMED", data: { shardID: number }): unknown; +export function log(type: "RESUMING", data: { shardID: number }): unknown; +export function log(type: "CLOSED" | "CLOSED_RECONNECT" | "ERROR" | "HEARTBEATING" | "HEARTBEATING_CLOSED" | "HEARTBEATING_DETAILS" | "HEARTBEATING_STARTED" | "IDENTIFYING" | "INVALID_SESSION" | "RAW" | "RECONNECT" | "RESUMED" | "RESUMING", data: unknown) { + console.log(type, data); +} + diff --git a/src/ws/proxy/shard.ts b/src/ws/proxy/shard.ts index 41b8da7db..15a735584 100644 --- a/src/ws/proxy/shard.ts +++ b/src/ws/proxy/shard.ts @@ -7,17 +7,23 @@ import { import { decompressWith } from "./deps.ts"; import { ws } from "./ws.ts"; -export function resume(shardID: number) { - // TODO: Log that this is happening +export async function resume(shardID: number) { + ws.log("RESUMING", { shardID }); // CREATE A SHARD - const socket = ws.createShard(shardID); + const socket = await ws.createShard(shardID); // NOW WE HANDLE RESUMING THIS SHARD // Get the old data for this shard necessary for resuming const oldShard = ws.shards.get(shardID); - // TODO: HOW TO CLOSE OLD SHARD SOCKET!!! - // TODO: STOP OLD HEARTBEAT + + if (oldShard) { + // HOW TO CLOSE OLD SHARD SOCKET!!! + oldShard.ws.close(4009, "Resuming the shard, closing old shard."); + // STOP OLD HEARTBEAT + clearInterval(oldShard.heartbeat.intervalID); + } + const sessionID = oldShard?.sessionID || ""; const previousSequenceNumber = oldShard?.previousSequenceNumber || 0; @@ -51,11 +57,11 @@ export function resume(shardID: number) { }; } -export function identify(shardID: number, maxShards: number) { - // TODO: Log that this is happening +export async function identify(shardID: number, maxShards: number) { + ws.log("IDENTIFYING", { shardID, maxShards }) // CREATE A SHARD - const socket = ws.createShard(shardID); + const socket = await ws.createShard(shardID); // Identify can just set/reset the settings for the shard ws.shards.set(shardID, { @@ -86,27 +92,29 @@ export function identify(shardID: number, maxShards: number) { } export function heartbeat(shardID: number, interval: number) { - // TODO: Log that this is happening + ws.log("HEARTBEATING_STARTED", { shardID, interval }); const shard = ws.shards.get(shardID); if (!shard) return; + ws.log("HEARTBEATING_DETAILS", { shardID, interval, shard }); + shard.heartbeat.keepAlive = true; shard.heartbeat.acknowledged = false; shard.heartbeat.lastSentAt = Date.now(); shard.heartbeat.interval = interval; shard.heartbeat.intervalID = setInterval(() => { - // TODO: Log that this is happening - const currentShard = ws.shards.get(shardID); if (!currentShard) return; + ws.log("HEARTBEATING", { shardID, shard: currentShard }); + if ( currentShard.ws.readyState === WebSocket.CLOSED || !currentShard.heartbeat.keepAlive ) { - // TODO: Log that this is happening + ws.log("HEARTBEATING_CLOSED", { shardID, shard: currentShard }); // STOP THE HEARTBEAT return clearInterval(currentShard.heartbeat.intervalID); @@ -123,17 +131,13 @@ export function heartbeat(shardID: number, interval: number) { }, interval); } -export function createShard(shardID: number) { +// deno-lint-ignore require-await +export async function createShard(shardID: number) { const socket = new WebSocket(ws.botGatewayData.url); socket.binaryType = "arraybuffer"; socket.onerror = (errorEvent) => { - // TODO: Log that this is happening - - // eventHandlers.debug?.({ - // type: "wsError", - // data: { shardID, ...errorEvent }, - // }); + ws.log("ERROR", { shardID, error: errorEvent }); }; socket.onmessage = ({ data: message }) => { @@ -152,8 +156,8 @@ export function createShard(shardID: number) { if (typeof message !== "string") return; const messageData = JSON.parse(message); - // TODO: Log that this is happening - // if (!messageData.t) eventHandlers.rawGateway?.(messageData); + ws.log("RAW", messageData); + switch (messageData.op) { case GatewayOpcode.Hello: ws.heartbeat( @@ -167,10 +171,8 @@ export function createShard(shardID: number) { } break; case GatewayOpcode.Reconnect: - // TODO: Log that this is happening - // eventHandlers.debug?.( - // { type: "gatewayReconnect", data: { shardID } }, - // ); + ws.log("RECONNECT", { shardID }); + if (ws.shards.has(shardID)) { ws.shards.get(shardID)!.resuming = true; } @@ -178,13 +180,8 @@ export function createShard(shardID: number) { resume(shardID); break; case GatewayOpcode.InvalidSession: - // TODO: Log that this is happening - // eventHandlers.debug?.( - // { - // type: "gatewayInvalidSession", - // data: { shardID, data }, - // }, - // ); + ws.log("INVALID_SESSION", { shardID, payload: messageData }); + // When d is false we need to reidentify if (!messageData.d) { identify(shardID, ws.maxShards); @@ -199,10 +196,7 @@ export function createShard(shardID: number) { break; default: if (messageData.t === "RESUMED") { - // TODO: Log that this is happening - // eventHandlers.debug?.( - // { type: "gatewayResumed", data: { shardID } }, - // ); + ws.log("RESUMED", { shardID }); if (ws.shards.has(shardID)) { ws.shards.get(shardID)!.resuming = false; @@ -231,17 +225,11 @@ export function createShard(shardID: number) { } }; - socket.onclose = ({ reason, code, wasClean }) => { - // TODO: Log that this is happening - // eventHandlers.debug?.( - // { - // type: "wsClose", - // data: { shardID, code, reason, wasClean }, - // }, - // ); + socket.onclose = (event) => { + ws.log("CLOSED", { shardID, payload: event }); // TODO: ENUM FOR THESE CODES? - switch (code) { + switch (event.code) { case 4001: case 4002: case 4004: @@ -252,18 +240,14 @@ export function createShard(shardID: number) { case 4013: case 4014: throw new Error( - reason || "Discord gave no reason! GG! You broke Discord!", + event.reason || "Discord gave no reason! GG! You broke Discord!", ); // THESE ERRORS CAN NO BE RESUMED! THEY MUST RE-IDENTIFY! case 4003: case 4007: case 4008: case 4009: - // TODO: Log that this is happening - // eventHandlers.debug?.({ - // type: "wsReconnect", - // data: { shardID, code, reason, wasClean }, - // }); + ws.log("CLOSED_RECONNECT", { shardID, payload: event }); identify(shardID, ws.maxShards); break; default: @@ -275,6 +259,7 @@ export function createShard(shardID: number) { return socket; } +/** Handler for processing all dispatch payloads that should be sent/forwarded to another server/vps/process. */ export async function handleDiscordPayload( data: DiscordPayload, shardID: number, diff --git a/src/ws/proxy/ws.ts b/src/ws/proxy/ws.ts index b2c225e08..c1de500cd 100644 --- a/src/ws/proxy/ws.ts +++ b/src/ws/proxy/ws.ts @@ -6,6 +6,7 @@ import { heartbeat, identify, } from "./shard.ts"; +import { log } from "./events.ts"; // CONTROLLER LIKE INTERFACE FOR WS HANDLING export const ws = { @@ -69,6 +70,7 @@ export const ws = { heartbeat, handleDiscordPayload, tellClusterToIdentify, + log }; export interface DiscordenoShard { From 848a61cfd85472dcfe774e0839dbea2f01e357e5 Mon Sep 17 00:00:00 2001 From: Skillz4Killz <23035000+Skillz4Killz@users.noreply.github.com> Date: Mon, 15 Feb 2021 23:35:01 +0000 Subject: [PATCH 10/34] handle not loading and autoresharding --- src/types/util.ts | 2 +- src/ws/README.md | 51 ++++++++++++++++++++++++------ src/ws/proxy/events.ts | 66 ++++++++++++++++++++++++++++++++------- src/ws/proxy/manager.ts | 39 +++++++++++++++++------ src/ws/proxy/resharder.ts | 30 ++++++++++++++++++ src/ws/proxy/shard.ts | 20 ++++++++++-- src/ws/proxy/ws.ts | 25 +++++++++++++-- 7 files changed, 196 insertions(+), 37 deletions(-) create mode 100644 src/ws/proxy/resharder.ts diff --git a/src/types/util.ts b/src/types/util.ts index 43af12643..dd17960f5 100644 --- a/src/types/util.ts +++ b/src/types/util.ts @@ -6,4 +6,4 @@ export type CamelizeString = T extends string : T : T; -export type Camelize = { [K in keyof T as CamelizeString]: T[K] } +export type Camelize = { [K in keyof T]: T[K] }; diff --git a/src/ws/README.md b/src/ws/README.md index 3fbc6ff10..d7c332799 100644 --- a/src/ws/README.md +++ b/src/ws/README.md @@ -5,21 +5,52 @@ This WS service is meant for ADVANCED DEVELOPERS ONLY! ## Benefits - **Zero Downtime Updates**: - - Your bot can be updated in a matter of seconds. With normal sharding, you have to restart which also has to process identifying all your shards with a 1/~5s rate limit. With WS handling moved to a proxy process, this allows you to instantly get the bot code restarted without any concerns of delays. If you have a bot on 200,000 servers normally this would mean a 20 minute delay to restart your bot if you made a small change and restarted. + - Your bot can be updated in a matter of seconds. With normal sharding, you + have to restart which also has to process identifying all your shards with a + 1/~5s rate limit. With WS handling moved to a proxy process, this allows you + to instantly get the bot code restarted without any concerns of delays. If + you have a bot on 200,000 servers normally this would mean a 20 minute delay + to restart your bot if you made a small change and restarted. - **Zero Downtime Resharding**: - - Discord stops letting your bot get added to new servers at certain points in time. For example, suppose you had 150,000 servers running 150 shards. The maximum amount of servers your shards could hold is 150 * 2500 = 375,000. If your bot reaches this, it can no longer join new servers until it re-shards. - - DD proxy provides 2 types of re-sharding. Automated and manual. You can also have both. - - `Automated`: This system will automatically begin a Zero-downtime resharding process behind the scenes when you reach 80% of your maximum servers allowed by your shards. For example, since 375,000 was the max, at 300,000 we would begin re-sharding behind the scenes with `ZERO DOWNTIME`. - - 80% of maximum servers reached (The % of 80% is customizable.) - - Identify limits have room to allow re-sharding. (Also customizable) - - `Manual`: You can also trigger this manually should you choose. + - Discord stops letting your bot get added to new servers at certain points in + time. For example, suppose you had 150,000 servers running 150 shards. The + maximum amount of servers your shards could hold is 150 * 2500 = 375,000. If + your bot reaches this, it can no longer join new servers until it re-shards. + - DD proxy provides 2 types of re-sharding. Automated and manual. You can also + have both. + - `Automated`: This system will automatically begin a Zero-downtime + resharding process behind the scenes when you reach 80% of your maximum + servers allowed by your shards. For example, since 375,000 was the max, at + 300,000 we would begin re-sharding behind the scenes with `ZERO DOWNTIME`. + - 80% of maximum servers reached (The % of 80% is customizable.) + - Identify limits have room to allow re-sharding. (Also customizable) + - `Manual`: You can also trigger this manually should you choose. - **Horizontal Scaling**: - - The proxy system allows you to scale the bot horizontally. When you reach a huge size, you can either keep spending more money to keep beefing up your server or you can buy several cheaper servers and scale horizontally. The proxy means you can have WS handling on a completely separate system. + - The proxy system allows you to scale the bot horizontally. When you reach a + huge size, you can either keep spending more money to keep beefing up your + server or you can buy several cheaper servers and scale horizontally. The + proxy means you can have WS handling on a completely separate system. - **No Loss Restarts**: - - When you restart a bot without the proxy system, normally you would lose many events. Users may be using commands or messages are sent that will not be filtered. As your bot's grow this number rises dramatically. Users may join who wont get the auto-roles or any other actions your bot should take. With the proxy system, you can keep restarting your bot and never lose any events. Events will be put into a queue while your bot is down(max size of queue is customizable), once the bot is available the queue will begin processing all events. + - When you restart a bot without the proxy system, normally you would lose + many events. Users may be using commands or messages are sent that will not + be filtered. As your bot's grow this number rises dramatically. Users may + join who wont get the auto-roles or any other actions your bot should take. + With the proxy system, you can keep restarting your bot and never lose any + events. Events will be put into a queue while your bot is down(max size of + queue is customizable), once the bot is available the queue will begin + processing all events. - **Controllers**: - - The controller aspect gives you full control over everything inside the proxy. You can provide a function to simply override the handler. For example, if you would like a certain function to do something different, instead of having to fork and maintain your fork, you can just provide a function to override. \ No newline at end of file + - The controller aspect gives you full control over everything inside the + proxy. You can provide a function to simply override the handler. For + example, if you would like a certain function to do something different, + instead of having to fork and maintain your fork, you can just provide a + function to override. + +- **Clustering With Workers**: + - Take full advantage of all your CPU cores by using workers to spread the + load. Control how many shards per worker and how many workers to maximize + efficiency! diff --git a/src/ws/proxy/events.ts b/src/ws/proxy/events.ts index 76efa8ec9..1411ceee6 100644 --- a/src/ws/proxy/events.ts +++ b/src/ws/proxy/events.ts @@ -2,20 +2,62 @@ import { DiscordPayload } from "../../types/discord.ts"; import { DiscordenoShard } from "./ws.ts"; /** The handler for logging different actions happening inside the ws. User can override and put custom handling per event. */ -export function log(type: "CLOSED", data: { shardID: number, payload: CloseEvent }): unknown; -export function log(type: "CLOSED_RECONNECT", data: { shardID: number, payload: CloseEvent }): unknown; -export function log(type: "ERROR", data: Record & { shardID: number }): unknown; -export function log(type: "HEARTBEATING", data: { shardID: number, shard: DiscordenoShard }): unknown; -export function log(type: "HEARTBEATING_CLOSED", data: { shardID: number, shard: DiscordenoShard }): unknown; -export function log(type: "HEARTBEATING_DETAILS", data: { shardID: number, interval: number, shard: DiscordenoShard }): unknown; -export function log(type: "HEARTBEATING_STARTED", data: { shardID: number, interval: number }): unknown; -export function log(type: "IDENTIFYING", data: { shardID: number, maxShards: number }): unknown; -export function log(type: "INVALID_SESSION", data: { shardID: number, payload: DiscordPayload }): unknown; +export function log( + type: "CLOSED", + data: { shardID: number; payload: CloseEvent }, +): unknown; +export function log( + type: "CLOSED_RECONNECT", + data: { shardID: number; payload: CloseEvent }, +): unknown; +export function log( + type: "ERROR", + data: Record & { shardID: number }, +): unknown; +export function log( + type: "HEARTBEATING", + data: { shardID: number; shard: DiscordenoShard }, +): unknown; +export function log( + type: "HEARTBEATING_CLOSED", + data: { shardID: number; shard: DiscordenoShard }, +): unknown; +export function log( + type: "HEARTBEATING_DETAILS", + data: { shardID: number; interval: number; shard: DiscordenoShard }, +): unknown; +export function log( + type: "HEARTBEATING_STARTED", + data: { shardID: number; interval: number }, +): unknown; +export function log( + type: "IDENTIFYING", + data: { shardID: number; maxShards: number }, +): unknown; +export function log( + type: "INVALID_SESSION", + data: { shardID: number; payload: DiscordPayload }, +): unknown; export function log(type: "RAW", data: Record): unknown; export function log(type: "RECONNECT", data: { shardID: number }): unknown; export function log(type: "RESUMED", data: { shardID: number }): unknown; export function log(type: "RESUMING", data: { shardID: number }): unknown; -export function log(type: "CLOSED" | "CLOSED_RECONNECT" | "ERROR" | "HEARTBEATING" | "HEARTBEATING_CLOSED" | "HEARTBEATING_DETAILS" | "HEARTBEATING_STARTED" | "IDENTIFYING" | "INVALID_SESSION" | "RAW" | "RECONNECT" | "RESUMED" | "RESUMING", data: unknown) { - console.log(type, data); +export function log( + type: + | "CLOSED" + | "CLOSED_RECONNECT" + | "ERROR" + | "HEARTBEATING" + | "HEARTBEATING_CLOSED" + | "HEARTBEATING_DETAILS" + | "HEARTBEATING_STARTED" + | "IDENTIFYING" + | "INVALID_SESSION" + | "RAW" + | "RECONNECT" + | "RESUMED" + | "RESUMING", + data: unknown, +) { + console.log(type, data); } - diff --git a/src/ws/proxy/manager.ts b/src/ws/proxy/manager.ts index b56f7bb4a..b39025287 100644 --- a/src/ws/proxy/manager.ts +++ b/src/ws/proxy/manager.ts @@ -17,6 +17,9 @@ export async function startGateway(options: StartGatewayOptions) { if (options.compress) { ws.identifyPayload.compress = options.compress; } + if (options.reshard) ws.reshard = options.reshard; + // Once an hour check if resharding is necessary + setInterval(ws.resharder, 1000 * 60 * 60); ws.identifyPayload.intents = options.intents.reduce( (bits, next) => (bits |= typeof next === "string" ? Intents[next] : next), @@ -39,21 +42,19 @@ export async function startGateway(options: StartGatewayOptions) { ws.botGatewayData.shards = data.shards; ws.botGatewayData.url = data.url; - // TODO: LOG THIS IS HAPPENING ws.spawnShards(ws.firstShardID); + ws.cleanupLoadingShards(); } -/** Begin spawning shards. - * TODO: Put in a queue system and support clustering - */ -export function spawnShards(shardID: number) { +/** Begin spawning shards. */ +export function spawnShards(firstShardID = 0) { /** Stored as bucketID: [clusterID, [ShardIDs]] */ const buckets = new Collection(); const maxShards = ws.maxShards || ws.botGatewayData.shards; let cluster = 0; for ( - let index = 0; + let index = firstShardID; index < ws.botGatewayData.sessionStartLimit.maxConcurrency; index++ ) { @@ -67,9 +68,6 @@ export function spawnShards(shardID: number) { buckets.set(bucketID, [[cluster, i]]); if (cluster + 1 <= ws.maxClusters) cluster++; - else { - // TODO: LOG THIS HAS HAPPENED - } } else { // FIND A QUEUE IN THIS BUCKET THAT HAS SPACE const queue = bucket.find((q) => q.length < ws.shardsPerCluster + 1); @@ -104,8 +102,29 @@ export async function tellClusterToIdentify( shardID: number, bucketID: number, ) { + // When resharding + const oldShard = ws.shards.get(shardID); // TODO: resolve promise 5 sec after ready await ws.identify(shardID, ws.maxShards); + + if (oldShard) { + oldShard.ws.close(4009, "Resharded!"); + } +} + +/** The handler to clean up shards that identified but never received a READY. */ +export function cleanupLoadingShards() { + while (ws.loadingShards.size) { + const now = Date.now(); + ws.loadingShards.forEach((loadingShard) => { + // Not a minute yet. Max should be few seconds but do a minute to be safe. + if (loadingShard.startedAt + 60000 < now) return; + + loadingShard.reject( + `[Identify Failure] Shard ${loadingShard.shardID} has not received READY event in over a minute.`, + ); + }); + } } export interface StartGatewayOptions { @@ -127,4 +146,6 @@ export interface StartGatewayOptions { shardsPerCluster?: number; /** The maximum amount of clusters available. By default this is 4. Another way to think of cluster is how many CPU cores does your server/machine have. */ maxClusters?: number; + /** Whether or not you want to allow automated sharding. By default this is true. */ + reshard?: boolean; } diff --git a/src/ws/proxy/resharder.ts b/src/ws/proxy/resharder.ts new file mode 100644 index 000000000..b3a8ca4f2 --- /dev/null +++ b/src/ws/proxy/resharder.ts @@ -0,0 +1,30 @@ +import { getGatewayBot } from "../../api/handlers/gateway.ts"; +import { ws } from "./ws.ts"; + +/** The handler to automatically reshard when necessary. */ +export async function resharder() { + const data = await getGatewayBot(); + const percentage = (data.shards - ws.maxShards) / ws.maxShards * 100; + // Less than necessary% being used so do nothing + if (percentage < ws.reshardPercentage) return; + + // Don't have enough identify rate limits to reshard + if (data.session_start_limit.remaining < data.shards) return; + + // Begin resharding + ws.maxShards = data.shards; + + // TODO: ALL THE FOLLOWING CAN BE REPLACED BY THIS 1 LINE + // ws.botGatewayData = snakeToCamel(await getGatewayBot()) + ws.botGatewayData.sessionStartLimit.total = data.session_start_limit.total; + ws.botGatewayData.sessionStartLimit.resetAfter = + data.session_start_limit.reset_after; + ws.botGatewayData.sessionStartLimit.remaining = + data.session_start_limit.remaining; + ws.botGatewayData.sessionStartLimit.maxConcurrency = + data.session_start_limit.max_concurrency; + ws.botGatewayData.shards = data.shards; + ws.botGatewayData.url = data.url; + + ws.spawnShards(ws.firstShardID); +} diff --git a/src/ws/proxy/shard.ts b/src/ws/proxy/shard.ts index 15a735584..419bf9535 100644 --- a/src/ws/proxy/shard.ts +++ b/src/ws/proxy/shard.ts @@ -16,7 +16,7 @@ export async function resume(shardID: number) { // NOW WE HANDLE RESUMING THIS SHARD // Get the old data for this shard necessary for resuming const oldShard = ws.shards.get(shardID); - + if (oldShard) { // HOW TO CLOSE OLD SHARD SOCKET!!! oldShard.ws.close(4009, "Resuming the shard, closing old shard."); @@ -58,7 +58,7 @@ export async function resume(shardID: number) { } export async function identify(shardID: number, maxShards: number) { - ws.log("IDENTIFYING", { shardID, maxShards }) + ws.log("IDENTIFYING", { shardID, maxShards }); // CREATE A SHARD const socket = await ws.createShard(shardID); @@ -89,6 +89,17 @@ export async function identify(shardID: number, maxShards: number) { }, ), ); + + return new Promise((resolve, reject) => { + ws.loadingShards.set(shardID, { + shardID, + resolve, + reject, + startedAt: Date.now(), + }); + + ws.cleanupLoadingShards(); + }); } export function heartbeat(shardID: number, interval: number) { @@ -181,7 +192,7 @@ export async function createShard(shardID: number) { break; case GatewayOpcode.InvalidSession: ws.log("INVALID_SESSION", { shardID, payload: messageData }); - + // When d is false we need to reidentify if (!messageData.d) { identify(shardID, ws.maxShards); @@ -210,6 +221,9 @@ export async function createShard(shardID: number) { if (shard) { shard.sessionID = (messageData.d as ReadyPayload).session_id; } + + ws.loadingShards.get(shardID)?.resolve(true); + ws.loadingShards.delete(shardID); } // Update the sequence number if it is present diff --git a/src/ws/proxy/ws.ts b/src/ws/proxy/ws.ts index c1de500cd..7c7bc5a1f 100644 --- a/src/ws/proxy/ws.ts +++ b/src/ws/proxy/ws.ts @@ -1,5 +1,10 @@ import { Collection } from "../../util/collection.ts"; -import { spawnShards, startGateway, tellClusterToIdentify } from "./manager.ts"; +import { + cleanupLoadingShards, + spawnShards, + startGateway, + tellClusterToIdentify, +} from "./manager.ts"; import { createShard, handleDiscordPayload, @@ -7,11 +12,16 @@ import { identify, } from "./shard.ts"; import { log } from "./events.ts"; +import { resharder } from "./resharder.ts"; // CONTROLLER LIKE INTERFACE FOR WS HANDLING export const ws = { /** The url that all discord payloads for the dispatch type should be sent to. */ url: "", + /** Whether or not to automatically reshard. */ + reshard: true, + /** The percentage at which resharding should occur. */ + reshardPercentage: 80, /** The maximum shard ID number. Useful for zero-downtime updates or resharding. */ maxShards: 1, /** The amount of shards to load per cluster */ @@ -57,6 +67,15 @@ export const ws = { }, }, shards: new Collection(), + loadingShards: new Collection< + number, + { + shardID: number; + resolve: (value: unknown) => void; + reject: (reason?: unknown) => void; + startedAt: number; + } + >(), utf8decoder: new TextDecoder(), // METHODS @@ -70,7 +89,9 @@ export const ws = { heartbeat, handleDiscordPayload, tellClusterToIdentify, - log + log, + resharder, + cleanupLoadingShards, }; export interface DiscordenoShard { From 92854150b2a470c9835ce42504a6f5a2ed377ea8 Mon Sep 17 00:00:00 2001 From: Skillz4Killz <23035000+Skillz4Killz@users.noreply.github.com> Date: Mon, 15 Feb 2021 23:59:06 +0000 Subject: [PATCH 11/34] finalizing comments --- src/ws/proxy/manager.ts | 1 - src/ws/proxy/ws.ts | 15 ++++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/ws/proxy/manager.ts b/src/ws/proxy/manager.ts index b39025287..149399a79 100644 --- a/src/ws/proxy/manager.ts +++ b/src/ws/proxy/manager.ts @@ -104,7 +104,6 @@ export async function tellClusterToIdentify( ) { // When resharding const oldShard = ws.shards.get(shardID); - // TODO: resolve promise 5 sec after ready await ws.identify(shardID, ws.maxShards); if (oldShard) { diff --git a/src/ws/proxy/ws.ts b/src/ws/proxy/ws.ts index 7c7bc5a1f..2d52ffed0 100644 --- a/src/ws/proxy/ws.ts +++ b/src/ws/proxy/ws.ts @@ -60,9 +60,9 @@ export const ws = { /** Milliseconds left until limit is reset. */ resetAfter: 0, /** The number of identify requests allowed per 5 seconds. - * So, if you had a max concurrency of 16, and 16 shards for example, you could start them all up at the same time. - * Whereas if you had 32 shards, if you tried to start up shard 0 and 16 at the same time for example, it would not work. You can start shards 0-15 concurrently, then 16-31... - * */ + * So, if you had a max concurrency of 16, and 16 shards for example, you could start them all up at the same time. + * Whereas if you had 32 shards, if you tried to start up shard 0 and 16 at the same time for example, it would not work. You can start shards 0-15 concurrently, then 16-31... + */ maxConcurrency: 1, }, }, @@ -84,13 +84,21 @@ export const ws = { startGateway, /** The handler for spawning ALL the shards. */ spawnShards, + /** Create the websocket and adds the proper handlers to the websocket. */ createShard, + /** Begins identification of the shard to discord */ identify, + /** Begins heartbeating of the shard to keep it alive */ heartbeat, + /** Sends the discord payload to another server. */ handleDiscordPayload, + /** Tell the cluster/worker to begin identifying this shard */ tellClusterToIdentify, + /** Handle the different logs. Used for debugging. */ log, + /** Handles resharding the bot when necessary. */ resharder, + /** Cleanups loading shards that were unable to load. */ cleanupLoadingShards, }; @@ -99,6 +107,7 @@ export interface DiscordenoShard { id: number; /** The websocket for this shard */ ws: WebSocket; + /** The amount of milliseconds to wait between heartbeats */ resumeInterval: number; /** The session id important for resuming connections. */ sessionID: string; From b6955db96fb42cb436e7a9d33575baedac7a63b3 Mon Sep 17 00:00:00 2001 From: Skillz4Killz <23035000+Skillz4Killz@users.noreply.github.com> Date: Tue, 16 Feb 2021 02:16:32 +0000 Subject: [PATCH 12/34] almost done, only workers left --- src/ws/README.md | 148 ++++++++++++++++++++++++++++++++++++++++ src/ws/proxy/manager.ts | 6 +- 2 files changed, 152 insertions(+), 2 deletions(-) diff --git a/src/ws/README.md b/src/ws/README.md index d7c332799..77e1bb7a5 100644 --- a/src/ws/README.md +++ b/src/ws/README.md @@ -54,3 +54,151 @@ This WS service is meant for ADVANCED DEVELOPERS ONLY! - Take full advantage of all your CPU cores by using workers to spread the load. Control how many shards per worker and how many workers to maximize efficiency! + +## Usage + +```ts +startGateway({ + /** The bot token. */ + token: "BOT_TOKEN_HERE", + /** Whether or not to use compression for gateway payloads. */ + compress: true, + /** The intents you would like to enable. */ + intents: ["GUILDS", "GUILD_MESSAGES"], + /** The max amount of shards used for identifying. This can be useful for zero-downtime updates or resharding. */ + maxShards: 885, + /** The first shard ID for this group of shards. */ + firstShardID: 100, + /** The last shard ID for this group. If none is provided, it will default to loading all shards. */ + lastShardID: 124, + /** The url to forward all payloads to. */ + url: "http://urlToYourServerHere", + /** The amount of shards per cluster. By default this is 25. Use this to spread the load from shards to different CPU cores. */ + shardsPerCluster: 25, + /** The maximum amount of clusters available. By default this is 4. Another way to think of cluster is how many CPU cores does your server/machine have. */ + maxClusters: 46, + /** Whether or not you want to allow automated sharding. By default this is true. */ + reshard: true; +}); +``` + +## API / Docs + +```ts +// CONTROLLER LIKE INTERFACE FOR WS HANDLING +export const ws = { + /** The url that all discord payloads for the dispatch type should be sent to. */ + url: "", + /** Whether or not to automatically reshard. */ + reshard: true, + /** The percentage at which resharding should occur. */ + reshardPercentage: 80, + /** The maximum shard ID number. Useful for zero-downtime updates or resharding. */ + maxShards: 1, + /** The amount of shards to load per cluster */ + shardsPerCluster: 25, + /** The maximum amount of clusters to use for your bot. */ + maxClusters: 4, + /** The first shard ID to start spawning. */ + firstShardID: 0, + /** The last shard ID for this cluster. */ + lastShardID: 1, + /** This prop decides whether Discord allows our next shard to be started. When 1 starts, this is set to false until it is ready for the next one. */ + createNextShard: true, + /** The identify payload holds the necessary data to connect and stay connected with Discords WSS. */ + identifyPayload: { + token: "", + compress: false, + properties: { + $os: "linux", + $browser: "Discordeno", + $device: "Discordeno", + }, + intents: 0, + shard: [0, 0], + }, + botGatewayData: { + /** The WSS URL that can be used for connecting to the gateway. */ + url: "wss://gateway.discord.gg/?v=8&encoding=json", + /** The recommended number of shards to use when connecting. */ + shards: 1, + /** Info on the current start limit. */ + sessionStartLimit: { + /** The total number of session starts the current user is allowed. */ + total: 1000, + /** The remaining number of session starts the current user is allowed. */ + remaining: 1000, + /** Milliseconds left until limit is reset. */ + resetAfter: 0, + /** The number of identify requests allowed per 5 seconds. + * So, if you had a max concurrency of 16, and 16 shards for example, you could start them all up at the same time. + * Whereas if you had 32 shards, if you tried to start up shard 0 and 16 at the same time for example, it would not work. You can start shards 0-15 concurrently, then 16-31... + */ + maxConcurrency: 1, + }, + }, + shards: new Collection(), + loadingShards: new Collection< + number, + { + shardID: number; + resolve: (value: unknown) => void; + reject: (reason?: unknown) => void; + startedAt: number; + } + >(), + utf8decoder: new TextDecoder(), + + // METHODS + + /** The handler function that starts the gateway. */ + startGateway, + /** The handler for spawning ALL the shards. */ + spawnShards, + /** Create the websocket and adds the proper handlers to the websocket. */ + createShard, + /** Begins identification of the shard to discord */ + identify, + /** Begins heartbeating of the shard to keep it alive */ + heartbeat, + /** Sends the discord payload to another server. */ + handleDiscordPayload, + /** Tell the cluster/worker to begin identifying this shard */ + tellClusterToIdentify, + /** Handle the different logs. Used for debugging. */ + log, + /** Handles resharding the bot when necessary. */ + resharder, + /** Cleanups loading shards that were unable to load. */ + cleanupLoadingShards, +}; + +export interface DiscordenoShard { + /** The shard id number */ + id: number; + /** The websocket for this shard */ + ws: WebSocket; + /** The amount of milliseconds to wait between heartbeats */ + resumeInterval: number; + /** The session id important for resuming connections. */ + sessionID: string; + /** The previous sequence number, important for resuming connections. */ + previousSequenceNumber: number | null; + /** Whether the shard is currently resuming. */ + resuming: boolean; + heartbeat: { + /** The exact timestamp the last heartbeat was sent */ + lastSentAt: number; + /** The timestamp the last heartbeat ACK was received from discord. */ + lastReceivedAt: number; + /** Whether or not the heartbeat was acknowledged by discord in time. */ + acknowledged: boolean; + /** Whether or not to keep heartbeating. Useful for when needing to stop heartbeating. */ + keepAlive: boolean; + /** The interval between heartbeats requested by discord. */ + interval: number; + /** The id of the interval, useful for stopping the interval if ws closed. */ + intervalID: number; + }; +} +``` \ No newline at end of file diff --git a/src/ws/proxy/manager.ts b/src/ws/proxy/manager.ts index 149399a79..349e7b11e 100644 --- a/src/ws/proxy/manager.ts +++ b/src/ws/proxy/manager.ts @@ -98,12 +98,14 @@ export function spawnShards(firstShardID = 0) { /** Allows users to hook in and change to communicate to different clusters across different servers or anything they like. For example using redis pubsub to talk to other servers. */ export async function tellClusterToIdentify( - clusterID: number, + workerID: number, shardID: number, bucketID: number, ) { - // When resharding + // When resharding this may exist already const oldShard = ws.shards.get(shardID); + + // TODO: Use workers await ws.identify(shardID, ws.maxShards); if (oldShard) { From 0e82d1cf2ee417ec1091195bfd50af7f634ce009 Mon Sep 17 00:00:00 2001 From: Skillz Date: Tue, 23 Feb 2021 12:50:47 -0500 Subject: [PATCH 13/34] exports --- src/ws/proxy/mod.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/ws/proxy/mod.ts b/src/ws/proxy/mod.ts index e69de29bb..b345c0567 100644 --- a/src/ws/proxy/mod.ts +++ b/src/ws/proxy/mod.ts @@ -0,0 +1,5 @@ +export * from "./events.ts"; +export * from "./manager.ts"; +export * from "./resharder.ts"; +export * from "./shard.ts"; +export * from "./ws.ts"; From f1d4f35e46e8518e0c406db5025d0f5b05290ff0 Mon Sep 17 00:00:00 2001 From: Skillz Date: Thu, 25 Feb 2021 15:28:49 -0500 Subject: [PATCH 14/34] moves random function --- mod.ts | 1 + src/util/collection.ts | 2 +- src/util/random.ts | 3 +++ src/util/utils.ts | 4 ---- 4 files changed, 5 insertions(+), 5 deletions(-) create mode 100644 src/util/random.ts diff --git a/mod.ts b/mod.ts index d7d9a2bd5..31d9383a8 100644 --- a/mod.ts +++ b/mod.ts @@ -27,4 +27,5 @@ export * from "./src/util/cache.ts"; export * from "./src/util/collection.ts"; export * from "./src/util/permissions.ts"; export * from "./src/util/utils.ts"; +export * from "./src/util/random.ts"; export * from "./src/ws/mod.ts"; diff --git a/src/util/collection.ts b/src/util/collection.ts index 01489432e..35080e237 100644 --- a/src/util/collection.ts +++ b/src/util/collection.ts @@ -1,4 +1,4 @@ -import { chooseRandom } from "./utils.ts"; +import { chooseRandom } from "./random.ts"; export class Collection extends Map { maxSize?: number; diff --git a/src/util/random.ts b/src/util/random.ts new file mode 100644 index 000000000..1ec366baa --- /dev/null +++ b/src/util/random.ts @@ -0,0 +1,3 @@ +export function chooseRandom(array: T[]) { + return array[Math.floor(Math.random() * array.length)]; +} diff --git a/src/util/utils.ts b/src/util/utils.ts index a2febf9b3..00b6e4d64 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -27,10 +27,6 @@ export function editBotsStatus( sendGatewayCommand("EDIT_BOTS_STATUS", { status, game: { name, type } }); } -export function chooseRandom(array: T[]) { - return array[Math.floor(Math.random() * array.length)]; -} - export async function urlToBase64(url: string) { const buffer = await fetch(url).then((res) => res.arrayBuffer()); const imageStr = encode(buffer); From 5f7269e0d1b1d0dae20f47054941e4d173d2ae88 Mon Sep 17 00:00:00 2001 From: Skillz Date: Thu, 25 Feb 2021 15:29:11 -0500 Subject: [PATCH 15/34] i have no idea why i did this --- src/ws/proxy/shard.ts | 196 ++++++++++++++++++++++-------------------- 1 file changed, 101 insertions(+), 95 deletions(-) diff --git a/src/ws/proxy/shard.ts b/src/ws/proxy/shard.ts index 419bf9535..9f8b60796 100644 --- a/src/ws/proxy/shard.ts +++ b/src/ws/proxy/shard.ts @@ -81,14 +81,16 @@ export async function identify(shardID: number, maxShards: number) { }, }); - socket.send( - JSON.stringify( - { - op: GatewayOpcode.Identify, - d: { ...ws.identifyPayload, shard: [shardID, maxShards] }, - }, - ), - ); + socket.onopen = () => { + socket.send( + JSON.stringify( + { + op: GatewayOpcode.Identify, + d: { ...ws.identifyPayload, shard: [shardID, maxShards] }, + }, + ), + ); + }; return new Promise((resolve, reject) => { ws.loadingShards.set(shardID, { @@ -151,93 +153,7 @@ export async function createShard(shardID: number) { ws.log("ERROR", { shardID, error: errorEvent }); }; - socket.onmessage = ({ data: message }) => { - if (message instanceof ArrayBuffer) { - message = new Uint8Array(message); - } - - if (message instanceof Uint8Array) { - message = decompressWith( - message, - 0, - (slice: Uint8Array) => ws.utf8decoder.decode(slice), - ); - } - - if (typeof message !== "string") return; - - const messageData = JSON.parse(message); - ws.log("RAW", messageData); - - switch (messageData.op) { - case GatewayOpcode.Hello: - ws.heartbeat( - shardID, - (messageData.d as DiscordHeartbeatPayload).heartbeat_interval, - ); - break; - case GatewayOpcode.HeartbeatACK: - if (ws.shards.has(shardID)) { - ws.shards.get(shardID)!.heartbeat.acknowledged = true; - } - break; - case GatewayOpcode.Reconnect: - ws.log("RECONNECT", { shardID }); - - if (ws.shards.has(shardID)) { - ws.shards.get(shardID)!.resuming = true; - } - - resume(shardID); - break; - case GatewayOpcode.InvalidSession: - ws.log("INVALID_SESSION", { shardID, payload: messageData }); - - // When d is false we need to reidentify - if (!messageData.d) { - identify(shardID, ws.maxShards); - break; - } - - if (ws.shards.has(shardID)) { - ws.shards.get(shardID)!.resuming = true; - } - - resume(shardID); - break; - default: - if (messageData.t === "RESUMED") { - ws.log("RESUMED", { shardID }); - - if (ws.shards.has(shardID)) { - ws.shards.get(shardID)!.resuming = false; - } - break; - } - - // Important for RESUME - if (messageData.t === "READY") { - const shard = ws.shards.get(shardID); - if (shard) { - shard.sessionID = (messageData.d as ReadyPayload).session_id; - } - - ws.loadingShards.get(shardID)?.resolve(true); - ws.loadingShards.delete(shardID); - } - - // Update the sequence number if it is present - if (messageData.s) { - const shard = ws.shards.get(shardID); - if (shard) { - shard.previousSequenceNumber = messageData.s; - } - } - - ws.handleDiscordPayload(messageData, shardID); - break; - } - }; + socket.onmessage = ({ data: message }) => handleOnMessage(message, shardID); socket.onclose = (event) => { ws.log("CLOSED", { shardID, payload: event }); @@ -273,6 +189,96 @@ export async function createShard(shardID: number) { return socket; } +/** Handler for handling every message event from websocket. */ +// deno-lint-ignore no-explicit-any +export function handleOnMessage(message: any, shardID: number) { + if (message instanceof ArrayBuffer) { + message = new Uint8Array(message); + } + + if (message instanceof Uint8Array) { + message = decompressWith( + message, + 0, + (slice: Uint8Array) => ws.utf8decoder.decode(slice), + ); + } + + if (typeof message !== "string") return; + + const messageData = JSON.parse(message); + ws.log("RAW", messageData); + + switch (messageData.op) { + case GatewayOpcode.Hello: + ws.heartbeat( + shardID, + (messageData.d as DiscordHeartbeatPayload).heartbeat_interval, + ); + break; + case GatewayOpcode.HeartbeatACK: + if (ws.shards.has(shardID)) { + ws.shards.get(shardID)!.heartbeat.acknowledged = true; + } + break; + case GatewayOpcode.Reconnect: + ws.log("RECONNECT", { shardID }); + + if (ws.shards.has(shardID)) { + ws.shards.get(shardID)!.resuming = true; + } + + resume(shardID); + break; + case GatewayOpcode.InvalidSession: + ws.log("INVALID_SESSION", { shardID, payload: messageData }); + + // When d is false we need to reidentify + if (!messageData.d) { + identify(shardID, ws.maxShards); + break; + } + + if (ws.shards.has(shardID)) { + ws.shards.get(shardID)!.resuming = true; + } + + resume(shardID); + break; + default: + if (messageData.t === "RESUMED") { + ws.log("RESUMED", { shardID }); + + if (ws.shards.has(shardID)) { + ws.shards.get(shardID)!.resuming = false; + } + break; + } + + // Important for RESUME + if (messageData.t === "READY") { + const shard = ws.shards.get(shardID); + if (shard) { + shard.sessionID = (messageData.d as ReadyPayload).session_id; + } + + ws.loadingShards.get(shardID)?.resolve(true); + ws.loadingShards.delete(shardID); + } + + // Update the sequence number if it is present + if (messageData.s) { + const shard = ws.shards.get(shardID); + if (shard) { + shard.previousSequenceNumber = messageData.s; + } + } + + ws.handleDiscordPayload(messageData, shardID); + break; + } +} + /** Handler for processing all dispatch payloads that should be sent/forwarded to another server/vps/process. */ export async function handleDiscordPayload( data: DiscordPayload, From cc6a93c1800f54302586d15c4b83aa5240fb97b7 Mon Sep 17 00:00:00 2001 From: Skillz Date: Thu, 25 Feb 2021 15:29:34 -0500 Subject: [PATCH 16/34] bug fixes for proxy ws --- src/bot.ts | 44 ++++++++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/src/bot.ts b/src/bot.ts index 111f58426..8c5b27241 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -10,7 +10,7 @@ import { baseEndpoints, GATEWAY_VERSION } from "./util/constants.ts"; import { spawnShards } from "./ws/shard_manager.ts"; export let authorization = ""; -export let restAuthorization = ""; +export let secretKey = ""; export let botID = ""; export let applicationID = ""; @@ -95,10 +95,9 @@ export async function startBigBrainBot(data: BigBrainBotConfig) { authorization = `Bot ${data.token}`; identifyPayload.token = `Bot ${data.token}`; - if (data.restAuthorization) restAuthorization = data.restAuthorization; + if (data.secretKey) secretKey = data.secretKey; if (data.restURL) baseEndpoints.BASE_URL = data.restURL; if (data.cdnURL) baseEndpoints.CDN_URL = data.cdnURL; - if (data.wsURL) proxyWSURL = data.wsURL; if (data.eventHandlers) eventHandlers = data.eventHandlers; if (data.compress) { identifyPayload.compress = data.compress; @@ -109,19 +108,24 @@ export async function startBigBrainBot(data: BigBrainBotConfig) { 0, ); - // Initial API connection to get info about bots connection - botGatewayData = await getGatewayBot(); - - if (!data.wsURL) proxyWSURL = botGatewayData.url; - await spawnShards( - botGatewayData, - identifyPayload, - data.firstShardID, - data.lastShardID || - (botGatewayData.shards >= 25 - ? (data.firstShardID + 25) - : botGatewayData.shards), - ); + // PROXY DOESNT NEED US SPAWNING SHARDS + if (data.wsPort) { + // Need HTTP Server to listen to proxy + console.log("TODO: make http"); + } else { + // Initial API connection to get info about bots connection + botGatewayData = await getGatewayBot(); + proxyWSURL = botGatewayData.url; + await spawnShards( + botGatewayData, + identifyPayload, + data.firstShardID, + data.lastShardID || + (botGatewayData.shards >= 25 + ? (data.firstShardID + 25) + : botGatewayData.shards), + ); + } } export interface BigBrainBotConfig extends BotConfig { @@ -129,12 +133,12 @@ export interface BigBrainBotConfig extends BotConfig { firstShardID: number; /** The last shard to start for this worker. By default it will be 25 + the firstShardID. */ lastShardID?: number; - /** This can be used to forward the ws handling to a proxy. */ - wsURL?: string; + /** This can be used to forward the ws handling to a proxy. It will disable the sharding done by the bot side. */ + wsPort?: number; /** This can be used to forward the REST handling to a proxy. */ restURL?: string; /** This can be used to forward the CDN handling to a proxy. */ cdnURL?: string; - /** This is the authorization header that your rest proxy will validate */ - restAuthorization?: string; + /** This is the authorization header that your servers will send. Helpful to prevent DDOS attacks and such. */ + secretKey?: string; } From 8aac0f708fd8e913b84734a7e6ab8d47ec446216 Mon Sep 17 00:00:00 2001 From: Skillz Date: Thu, 25 Feb 2021 15:29:47 -0500 Subject: [PATCH 17/34] dude secret key is the shit --- src/rest/request_manager.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/rest/request_manager.ts b/src/rest/request_manager.ts index af77cb559..6e9ca69c5 100644 --- a/src/rest/request_manager.ts +++ b/src/rest/request_manager.ts @@ -1,4 +1,4 @@ -import { authorization, eventHandlers, restAuthorization } from "../bot.ts"; +import { authorization, eventHandlers, secretKey } from "../bot.ts"; import { Errors, FileContent, @@ -224,7 +224,7 @@ function runMethod( return fetch(url, { body: JSON.stringify(body || {}), headers: { - authorization: restAuthorization, + authorization: secretKey, }, method: method.toUpperCase(), }) From bf151ec1193de540bd1043dd7a57683607756a92 Mon Sep 17 00:00:00 2001 From: Skillz Date: Thu, 25 Feb 2021 15:30:07 -0500 Subject: [PATCH 18/34] so freaking shitty dumnb aefiuhesr error fix --- src/ws/proxy/manager.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/ws/proxy/manager.ts b/src/ws/proxy/manager.ts index 349e7b11e..f484ce47b 100644 --- a/src/ws/proxy/manager.ts +++ b/src/ws/proxy/manager.ts @@ -1,4 +1,4 @@ -import { getGatewayBot } from "../../api/handlers/gateway.ts"; +import { DiscordBotGatewayData } from "../../types/discord.ts"; import { Intents } from "../../types/options.ts"; import { Collection } from "../../util/collection.ts"; import { ws } from "./ws.ts"; @@ -26,7 +26,11 @@ export async function startGateway(options: StartGatewayOptions) { 0, ); - const data = await getGatewayBot(); + const data = await fetch( + `https://discord.com/api/gateway/bot`, + { headers: { Authorization: ws.identifyPayload.token } }, + ).then((res) => res.json()) as DiscordBotGatewayData; + ws.maxShards = options.maxShards || data.shards; ws.lastShardID = options.lastShardID || data.shards - 1; From 55bf6e90d7024522033bb854aa33f6b67c70f991 Mon Sep 17 00:00:00 2001 From: Skillz Date: Thu, 25 Feb 2021 15:30:20 -0500 Subject: [PATCH 19/34] this shit i have i no idea --- src/ws/proxy/ws.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ws/proxy/ws.ts b/src/ws/proxy/ws.ts index 2d52ffed0..5deb44412 100644 --- a/src/ws/proxy/ws.ts +++ b/src/ws/proxy/ws.ts @@ -8,6 +8,7 @@ import { import { createShard, handleDiscordPayload, + handleOnMessage, heartbeat, identify, } from "./shard.ts"; @@ -100,6 +101,8 @@ export const ws = { resharder, /** Cleanups loading shards that were unable to load. */ cleanupLoadingShards, + /** Handles the message events from websocket */ + handleOnMessage, }; export interface DiscordenoShard { From cdd043778cc77b4b22b1fafe5d0402aebedccba0 Mon Sep 17 00:00:00 2001 From: Skillz Date: Thu, 25 Feb 2021 16:57:52 -0500 Subject: [PATCH 20/34] this is why while sucks --- src/ws/proxy/manager.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/ws/proxy/manager.ts b/src/ws/proxy/manager.ts index f484ce47b..59f72a88a 100644 --- a/src/ws/proxy/manager.ts +++ b/src/ws/proxy/manager.ts @@ -2,6 +2,7 @@ import { DiscordBotGatewayData } from "../../types/discord.ts"; import { Intents } from "../../types/options.ts"; import { Collection } from "../../util/collection.ts"; import { ws } from "./ws.ts"; +import { delay } from "../../util/utils.ts"; /** ADVANCED DEVS ONLY!!!!!! * Starts the standalone gateway. @@ -9,6 +10,7 @@ import { ws } from "./ws.ts"; */ export async function startGateway(options: StartGatewayOptions) { ws.identifyPayload.token = `Bot ${options.token}`; + ws.secretKey = options.secretKey; ws.firstShardID = options.firstShardID; ws.url = options.url; if (options.shardsPerCluster) ws.shardsPerCluster = options.shardsPerCluster; @@ -118,17 +120,24 @@ export async function tellClusterToIdentify( } /** The handler to clean up shards that identified but never received a READY. */ -export function cleanupLoadingShards() { +export async function cleanupLoadingShards() { while (ws.loadingShards.size) { const now = Date.now(); ws.loadingShards.forEach((loadingShard) => { + console.log( + now > loadingShard.startedAt + 60000, + now, + loadingShard.startedAt, + ); // Not a minute yet. Max should be few seconds but do a minute to be safe. - if (loadingShard.startedAt + 60000 < now) return; + if (now < loadingShard.startedAt + 60000) return; loadingShard.reject( `[Identify Failure] Shard ${loadingShard.shardID} has not received READY event in over a minute.`, ); }); + + await delay(1000); } } @@ -153,4 +162,6 @@ export interface StartGatewayOptions { maxClusters?: number; /** Whether or not you want to allow automated sharding. By default this is true. */ reshard?: boolean; + /** The authorization key that the bot http server will expect. */ + secretKey: string; } From 94bc5be370e14a3c26bb63b4bee596ffdc203afd Mon Sep 17 00:00:00 2001 From: Skillz Date: Thu, 25 Feb 2021 16:58:10 -0500 Subject: [PATCH 21/34] let end user do server --- src/bot.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/bot.ts b/src/bot.ts index 8c5b27241..ed1dd04e2 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -109,10 +109,7 @@ export async function startBigBrainBot(data: BigBrainBotConfig) { ); // PROXY DOESNT NEED US SPAWNING SHARDS - if (data.wsPort) { - // Need HTTP Server to listen to proxy - console.log("TODO: make http"); - } else { + if (!data.wsPort) { // Initial API connection to get info about bots connection botGatewayData = await getGatewayBot(); proxyWSURL = botGatewayData.url; From f38ba603e89ce20f184fb2ac56b1fee961c58a0e Mon Sep 17 00:00:00 2001 From: Skillz Date: Thu, 25 Feb 2021 16:58:46 -0500 Subject: [PATCH 22/34] fix authorization --- src/ws/proxy/shard.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ws/proxy/shard.ts b/src/ws/proxy/shard.ts index 9f8b60796..c46c3f887 100644 --- a/src/ws/proxy/shard.ts +++ b/src/ws/proxy/shard.ts @@ -285,6 +285,9 @@ export async function handleDiscordPayload( shardID: number, ) { await fetch(ws.url, { + headers: { + authorization: ws.secretKey, + }, method: "post", body: JSON.stringify({ shardID, From edd04e850b2847bc33a92acaee1ad7aedcd83a5b Mon Sep 17 00:00:00 2001 From: Skillz Date: Thu, 25 Feb 2021 16:58:56 -0500 Subject: [PATCH 23/34] fix secret key --- src/ws/proxy/ws.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ws/proxy/ws.ts b/src/ws/proxy/ws.ts index 5deb44412..728855e14 100644 --- a/src/ws/proxy/ws.ts +++ b/src/ws/proxy/ws.ts @@ -17,6 +17,8 @@ import { resharder } from "./resharder.ts"; // CONTROLLER LIKE INTERFACE FOR WS HANDLING export const ws = { + /** The secret key authorization header the bot will expect when sending payloads */ + secretKey: "", /** The url that all discord payloads for the dispatch type should be sent to. */ url: "", /** Whether or not to automatically reshard. */ From 12ab29fd86ad55efc1775e7ad86cd32466278010 Mon Sep 17 00:00:00 2001 From: ITOH <72305210+itohatweb@users.noreply.github.com> Date: Sat, 3 Apr 2021 17:56:05 +0200 Subject: [PATCH 24/34] idk --- src/types/emojis/guild_emojis_update.ts | 4 ++-- src/types/gateway/identify.ts | 4 ++-- src/types/gateway/identify_connection_properties.ts | 2 +- src/types/gateway/resume.ts | 4 ++-- .../gateway/{update_status.ts => status_update.ts} | 4 ++-- src/types/guilds/guild_ban_add_remove.ts | 4 ++-- src/types/guilds/request_guild_members.ts | 2 +- .../application_command_create_update_delete.ts | 13 +++++++++++++ src/types/messages/message_reaction_add.ts | 1 + src/types/misc/activity.ts | 4 ++-- src/types/misc/presence_update.ts | 2 +- src/types/webhooks/webhooks_update.ts | 6 +++--- 12 files changed, 32 insertions(+), 18 deletions(-) rename src/types/gateway/{update_status.ts => status_update.ts} (84%) create mode 100644 src/types/interactions/application_command_create_update_delete.ts diff --git a/src/types/emojis/guild_emojis_update.ts b/src/types/emojis/guild_emojis_update.ts index 2889278ad..d67c0e9dc 100644 --- a/src/types/emojis/guild_emojis_update.ts +++ b/src/types/emojis/guild_emojis_update.ts @@ -1,12 +1,12 @@ import { Emoji } from "../emojis/emoji.ts"; import { SnakeCaseProps } from "../util.ts"; -/** https://discord.com/developers/docs/topics/gateway#guild-emojis-update */ export interface GuildEmojisUpdate { /** id of the guild */ - guild_id: string; + guildId: string; /** Array of emojis */ emojis: Emoji[]; } +/** https://discord.com/developers/docs/topics/gateway#guild-emojis-update */ export type DiscordGuildEmojisUpdate = SnakeCaseProps; diff --git a/src/types/gateway/identify.ts b/src/types/gateway/identify.ts index 3832bb2ae..2b060b8e4 100644 --- a/src/types/gateway/identify.ts +++ b/src/types/gateway/identify.ts @@ -12,11 +12,11 @@ export interface Identify { /** Value between 50 and 250, total number of members where the gateway will stop sending offline members in the guild member list */ largeThreshold?: number; /** Used for Guild Sharding */ - shard?: [number, number]; + shard?: [shardId: number, numberOfShards: number]; /** Presence structure for initial presence information */ presence?: UpdateStatus; /** Enables dispatching of guild subscription events (presence and typing events) */ - guild_subscriptions?: boolean; + guildSubscriptions?: boolean; /** The Gateway Intents you wish to receive */ intents: number; } diff --git a/src/types/gateway/identify_connection_properties.ts b/src/types/gateway/identify_connection_properties.ts index 15704712b..1039de134 100644 --- a/src/types/gateway/identify_connection_properties.ts +++ b/src/types/gateway/identify_connection_properties.ts @@ -1,4 +1,3 @@ -/** https://discord.com/developers/docs/topics/gateway#identify-identify-connection-properties */ export interface IdentifyConnectionProperties { /** Operating system */ $os: string; @@ -8,4 +7,5 @@ export interface IdentifyConnectionProperties { $device: string; } +/** https://discord.com/developers/docs/topics/gateway#identify-identify-connection-properties */ export type DiscordIdentifyConnectionProperties = IdentifyConnectionProperties; diff --git a/src/types/gateway/resume.ts b/src/types/gateway/resume.ts index cf0a2327b..bd6b626c3 100644 --- a/src/types/gateway/resume.ts +++ b/src/types/gateway/resume.ts @@ -1,11 +1,11 @@ -/** https://discord.com/developers/docs/topics/gateway#resume */ export interface Resume { /** Session token */ token: string; /** Session id */ - session_id: string; + sessionId: string; /** Last sequence number received */ seq: number; } +/** https://discord.com/developers/docs/topics/gateway#resume */ export type DiscordResume = Resume; diff --git a/src/types/gateway/update_status.ts b/src/types/gateway/status_update.ts similarity index 84% rename from src/types/gateway/update_status.ts rename to src/types/gateway/status_update.ts index 17f7e6137..c1afb2dc0 100644 --- a/src/types/gateway/update_status.ts +++ b/src/types/gateway/status_update.ts @@ -2,7 +2,7 @@ import { Activity } from "../misc/activity.ts"; import { SnakeCaseProps } from "../util.ts"; import { DiscordStatusTypes } from "./status_types.ts"; -export interface UpdateStatus { +export interface StatusUpdate { /** Unix time (in milliseconds) of when the client went idle, or null if the client is not idle */ since: number | null; /** null, or the user's activities */ @@ -14,4 +14,4 @@ export interface UpdateStatus { } /** https://discord.com/developers/docs/topics/gateway#update-status */ -export type DiscordUpdateStatus = SnakeCaseProps; +export type DiscordStatusUpdate = SnakeCaseProps; diff --git a/src/types/guilds/guild_ban_add_remove.ts b/src/types/guilds/guild_ban_add_remove.ts index 73b37edd6..fecb00a97 100644 --- a/src/types/guilds/guild_ban_add_remove.ts +++ b/src/types/guilds/guild_ban_add_remove.ts @@ -1,7 +1,7 @@ import { User } from "../users/user.ts"; import { SnakeCaseProps } from "../util.ts"; -export interface GuildBanAdd { +export interface GuildBanAddRemove { /** id of the guild */ guildId: string; /** The banned user */ @@ -9,4 +9,4 @@ export interface GuildBanAdd { } /** https://discord.com/developers/docs/topics/gateway#guild-ban-add */ -export type DiscordGuildBanAdd = SnakeCaseProps; +export type DiscordGuildBanAddRemove = SnakeCaseProps; diff --git a/src/types/guilds/request_guild_members.ts b/src/types/guilds/request_guild_members.ts index 0ecadd076..270c810e3 100644 --- a/src/types/guilds/request_guild_members.ts +++ b/src/types/guilds/request_guild_members.ts @@ -2,7 +2,7 @@ import { SnakeCaseProps } from "../util.ts"; export interface RequestGuildMembers { /** id of the guild to get members for */ - guild_id: string; + guildId: string; /** String that username starts with, or an empty string to return all members */ query?: string; /** Maximum number of members to send matching the query; a limit of 0 can be used with an empty string query to return all members */ diff --git a/src/types/interactions/application_command_create_update_delete.ts b/src/types/interactions/application_command_create_update_delete.ts new file mode 100644 index 000000000..e831daaba --- /dev/null +++ b/src/types/interactions/application_command_create_update_delete.ts @@ -0,0 +1,13 @@ +import { SnakeCaseProps } from "../util.ts"; +import { ApplicationCommand } from "./application_command.ts"; + +export interface ApplicationCommandCreateUpdateDelete + extends ApplicationCommand { + /** Id of the guild the command is in */ + guildId?: string; +} + +/** https://discord.com/developers/docs/topics/gateway#application-command-delete-application-command-extra-fields */ +export type DiscordApplicationCommandCreateUpdateDelete = SnakeCaseProps< + ApplicationCommandCreateUpdateDelete +>; diff --git a/src/types/messages/message_reaction_add.ts b/src/types/messages/message_reaction_add.ts index 9168f9f0e..f8fbcb930 100644 --- a/src/types/messages/message_reaction_add.ts +++ b/src/types/messages/message_reaction_add.ts @@ -1,4 +1,5 @@ import { Emoji } from "../emojis/emoji.ts"; +import { GuildMember } from "../guilds/guild_member.ts"; import { SnakeCaseProps } from "../util.ts"; export interface MessageReactionAdd { diff --git a/src/types/misc/activity.ts b/src/types/misc/activity.ts index c15d4760b..89b05d355 100644 --- a/src/types/misc/activity.ts +++ b/src/types/misc/activity.ts @@ -14,11 +14,11 @@ export interface Activity { /** Stream url, is validated when type is 1 */ url?: string | null; /** Unix timestamp of when the activity was added to the user's session */ - created_at: number; + createdAt: number; /** Unix timestamps for start and/or end of the game */ timestamps?: ActivityTimestamps; /** Application id for the game */ - application_id?: string; + applicationId?: string; /** What the player is currently doing */ details?: string | null; /** The user's current party status */ diff --git a/src/types/misc/presence_update.ts b/src/types/misc/presence_update.ts index 9e21defb2..e1c3f2695 100644 --- a/src/types/misc/presence_update.ts +++ b/src/types/misc/presence_update.ts @@ -7,7 +7,7 @@ export interface PresenceUpdate { /** The user presence is being updated for */ user: User; /** id of the guild */ - guild_id: string; + guildId: string; /** Either "idle", "dnd", "online", or "offline" */ status: "idle" | "dnd" | "online" | "offline"; /** User's current activities */ diff --git a/src/types/webhooks/webhooks_update.ts b/src/types/webhooks/webhooks_update.ts index e96e8bde5..73407b963 100644 --- a/src/types/webhooks/webhooks_update.ts +++ b/src/types/webhooks/webhooks_update.ts @@ -1,11 +1,11 @@ import { SnakeCaseProps } from "../util.ts"; -export interface WebhooksUpdate { +export interface WebhookUpdate { /** id of the guild */ guildId: string; /** id of the channel */ channelId: string; } -/** https://discord.com/developers/docs/topics/gateway#webhooks-update */ -export type DiscordWebhooksUpdate = SnakeCaseProps; +/** https://discord.com/developers/docs/topics/gateway#webhooks-update-webhook-update-event-fields */ +export type DiscordWebhookUpdate = SnakeCaseProps; From 1b981df70c51b4d7006b85a350764603b31a8936 Mon Sep 17 00:00:00 2001 From: TriForMine Date: Sun, 4 Apr 2021 06:59:52 +0200 Subject: [PATCH 25/34] fix(helpers/members) Remove duplicated import --- src/helpers/members/fetch_members.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/helpers/members/fetch_members.ts b/src/helpers/members/fetch_members.ts index 173a65b3d..f894a61dd 100644 --- a/src/helpers/members/fetch_members.ts +++ b/src/helpers/members/fetch_members.ts @@ -4,8 +4,6 @@ import { DiscordGatewayIntents } from "../../types/gateway/gateway_intents.ts"; import { Errors } from "../../types/misc/errors.ts"; import { Collection } from "../../util/collection.ts"; import { requestAllMembers } from "../../ws/shard_manager.ts"; -import { DiscordGatewayIntents } from "../../types/gateway/gateway_intents.ts"; -import { Errors } from "../../types/misc/errors.ts"; /** * ⚠️ BEGINNER DEVS!! YOU SHOULD ALMOST NEVER NEED THIS AND YOU CAN GET FROM cache.members.get() From c6279c79c056d61016120f7ca4fd22c2cf90db8b Mon Sep 17 00:00:00 2001 From: Skillz4Killz <23035000+Skillz4Killz@users.noreply.github.com> Date: Sun, 4 Apr 2021 12:20:55 +0000 Subject: [PATCH 26/34] fix: guildBannerURL no longer accepts cached guild --- src/helpers/guilds/guild_banner_url.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/helpers/guilds/guild_banner_url.ts b/src/helpers/guilds/guild_banner_url.ts index 5b5db57c8..7b4866915 100644 --- a/src/helpers/guilds/guild_banner_url.ts +++ b/src/helpers/guilds/guild_banner_url.ts @@ -1,18 +1,16 @@ -import { Guild } from "../../structures/mod.ts"; +import { DiscordImageFormat } from "../../types/misc/image_format.ts"; +import { DiscordImageSize } from "../../types/misc/image_size.ts"; import { endpoints } from "../../util/constants.ts"; import { formatImageURL } from "../../util/utils.ts"; /** The full URL of the banner from Discords CDN. Undefined if no banner is set. */ export function guildBannerURL( - guild: Guild, - size: ImageSize = 128, - format?: ImageFormats, + id: string, + banner: string, + size: DiscordImageSize = 128, + format?: DiscordImageFormat ) { - return guild.banner - ? formatImageURL( - endpoints.GUILD_BANNER(guild.id, guild.banner), - size, - format, - ) + return banner + ? formatImageURL(endpoints.GUILD_BANNER(id, banner), size, format) : undefined; } From fbfd3455b521745fecd5594bf7f55b0b528ebcb9 Mon Sep 17 00:00:00 2001 From: Skillz4Killz <23035000+Skillz4Killz@users.noreply.github.com> Date: Sun, 4 Apr 2021 12:21:04 +0000 Subject: [PATCH 27/34] fix: guildIconURL no longer accepts cached guild --- src/helpers/guilds/guild_icon_url.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/helpers/guilds/guild_icon_url.ts b/src/helpers/guilds/guild_icon_url.ts index f1f348010..5acb8408f 100644 --- a/src/helpers/guilds/guild_icon_url.ts +++ b/src/helpers/guilds/guild_icon_url.ts @@ -1,14 +1,16 @@ -import { Guild } from "../../structures/mod.ts"; +import { DiscordImageFormat } from "../../types/misc/image_format.ts"; +import { DiscordImageSize } from "../../types/misc/image_size.ts"; import { endpoints } from "../../util/constants.ts"; import { formatImageURL } from "../../util/utils.ts"; /** The full URL of the icon from Discords CDN. Undefined when no icon is set. */ export function guildIconURL( - guild: Guild, - size: ImageSize = 128, - format?: ImageFormats, + id: string, + icon: string, + size: DiscordImageSize = 128, + format?: DiscordImageFormat, ) { - return guild.icon - ? formatImageURL(endpoints.GUILD_ICON(guild.id, guild.icon), size, format) + return icon + ? formatImageURL(endpoints.GUILD_ICON(id, icon), size, format) : undefined; } From 8f4dd7197358c76420e8c4077f1711cf5221f9ef Mon Sep 17 00:00:00 2001 From: Skillz4Killz <23035000+Skillz4Killz@users.noreply.github.com> Date: Sun, 4 Apr 2021 12:21:15 +0000 Subject: [PATCH 28/34] fix: guildSplashURL no longer accepts cached guild --- src/helpers/guilds/guild_splash_url.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/helpers/guilds/guild_splash_url.ts b/src/helpers/guilds/guild_splash_url.ts index 2e38524ac..0c51fb682 100644 --- a/src/helpers/guilds/guild_splash_url.ts +++ b/src/helpers/guilds/guild_splash_url.ts @@ -1,16 +1,18 @@ -import { Guild } from "../../structures/mod.ts"; +import { DiscordImageFormat } from "../../types/misc/image_format.ts"; +import { DiscordImageSize } from "../../types/misc/image_size.ts"; import { endpoints } from "../../util/constants.ts"; import { formatImageURL } from "../../util/utils.ts"; /** The full URL of the splash from Discords CDN. Undefined if no splash is set. */ export function guildSplashURL( - guild: Guild, - size: ImageSize = 128, - format?: ImageFormats, + id: string, + splash: string, + size: DiscordImageSize = 128, + format?: DiscordImageFormat, ) { - return guild.splash + return splash ? formatImageURL( - endpoints.GUILD_SPLASH(guild.id, guild.splash), + endpoints.GUILD_SPLASH(id, splash), size, format, ) From 5d3b01b3e7f5dfbef78150790c5de43089903882 Mon Sep 17 00:00:00 2001 From: Skillz4Killz <23035000+Skillz4Killz@users.noreply.github.com> Date: Sun, 4 Apr 2021 12:21:30 +0000 Subject: [PATCH 29/34] fix: typings names & imports --- src/helpers/members/avatar_url.ts | 6 ++++-- src/util/utils.ts | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/helpers/members/avatar_url.ts b/src/helpers/members/avatar_url.ts index ae6c90a2a..3bbc160de 100644 --- a/src/helpers/members/avatar_url.ts +++ b/src/helpers/members/avatar_url.ts @@ -1,3 +1,5 @@ +import { DiscordImageFormat } from "../../types/misc/image_format.ts"; +import { DiscordImageSize } from "../../types/misc/image_size.ts"; import { endpoints } from "../../util/constants.ts"; import { formatImageURL } from "../../util/utils.ts"; @@ -6,8 +8,8 @@ export function avatarURL( userId: string, discriminator: string, avatar?: string | null, - size: ImageSize = 128, - format?: ImageFormats, + size: DiscordImageSize = 128, + format?: DiscordImageFormat, ) { return avatar ? formatImageURL(endpoints.USER_AVATAR(userId, avatar), size, format) diff --git a/src/util/utils.ts b/src/util/utils.ts index 18041d85b..c27e75bcb 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -1,6 +1,8 @@ import { encode } from "../../deps.ts"; import { DiscordGatewayOpcodes } from "../types/codes/gateway_opcodes.ts"; import { Errors } from "../types/misc/errors.ts"; +import { DiscordImageFormat } from "../types/misc/image_format.ts"; +import { DiscordImageSize } from "../types/misc/image_size.ts"; import { basicShards, sendWS } from "../ws/shard.ts"; import { SLASH_COMMANDS_NAME_REGEX } from "./constants.ts"; @@ -46,8 +48,8 @@ export function delay(ms: number): Promise { export const formatImageURL = ( url: string, - size: ImageSize = 128, - format?: ImageFormats, + size: DiscordImageSize = 128, + format?: DiscordImageFormat, ) => { return `${url}.${format || (url.includes("/a_") ? "gif" : "jpg")}?size=${size}`; From 658db44476fea4772e5c345f2e46e4203cc51797 Mon Sep 17 00:00:00 2001 From: Skillz4Killz <23035000+Skillz4Killz@users.noreply.github.com> Date: Sun, 4 Apr 2021 12:21:43 +0000 Subject: [PATCH 30/34] fix: getters for cached versions of server images --- src/structures/guild.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/structures/guild.ts b/src/structures/guild.ts index 82070249e..2153aedc7 100644 --- a/src/structures/guild.ts +++ b/src/structures/guild.ts @@ -7,6 +7,7 @@ import { getBan } from "../helpers/guilds/get_ban.ts"; import { getBans } from "../helpers/guilds/get_bans.ts"; import { guildBannerURL } from "../helpers/guilds/guild_banner_url.ts"; import { guildIconURL } from "../helpers/guilds/guild_icon_url.ts"; +import { guildSplashURL } from "../helpers/guilds/guild_splash_url.ts"; import { leaveGuild } from "../helpers/guilds/leave_guild.ts"; import { getInvites } from "../helpers/invites/get_invites.ts"; import { banMember } from "../helpers/members/ban_member.ts"; @@ -64,7 +65,10 @@ const baseGuild: Partial = { return Boolean(this.features?.includes(DiscordGuildFeatures.VERIFIED)); }, bannerURL(size, format) { - return guildBannerURL(this as unknown as Guild, size, format); + return guildBannerURL(this.id!, this.banner!, size, format); + }, + splashURL(size, format) { + return guildSplashURL(this.id!, this.splash!, size, format); }, delete() { return deleteServer(this.id!); @@ -91,7 +95,7 @@ const baseGuild: Partial = { return getInvites(this.id!); }, iconURL(size, format) { - return guildIconURL(this as unknown as Guild, size, format); + return guildIconURL(this.id!, this.icon!, size, format); }, leave() { return leaveGuild(this.id!); @@ -216,6 +220,11 @@ export interface GuildStruct extends bannerURL( size?: DiscordImageSize, format?: DiscordImageFormat, + ): string | undefined; + /** The splash url for this server */ + splashURL( + size?: DiscordImageSize, + format?: DiscordImageFormat, ): string | undefined; /** The full URL of the icon from Discords CDN. Undefined when no icon is set. */ iconURL( From 8de3b305fe6ba10278be3e34728fcb82941a13b4 Mon Sep 17 00:00:00 2001 From: Skillz4Killz <23035000+Skillz4Killz@users.noreply.github.com> Date: Sun, 4 Apr 2021 12:24:25 +0000 Subject: [PATCH 31/34] fix: more broken imports --- src/structures/message.ts | 2 +- src/types/gateway/identify.ts | 4 ++-- src/types/mod.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/structures/message.ts b/src/structures/message.ts index 526fb6fe5..328bebaf1 100644 --- a/src/structures/message.ts +++ b/src/structures/message.ts @@ -9,7 +9,7 @@ import { removeAllReactions } from "../helpers/messages/remove_all_reactions.ts" import { removeReaction } from "../helpers/messages/remove_reaction.ts"; import { removeReactionEmoji } from "../helpers/messages/remove_reaction_emoji.ts"; import { sendMessage } from "../helpers/messages/send_message.ts"; -import { CHANNEL_MENTION_REGEX } from "../util/constants"; +import { CHANNEL_MENTION_REGEX } from "../util/constants.ts"; import { createNewProp } from "../util/utils.ts"; const baseMessage: Partial = { diff --git a/src/types/gateway/identify.ts b/src/types/gateway/identify.ts index 2b060b8e4..223d775ef 100644 --- a/src/types/gateway/identify.ts +++ b/src/types/gateway/identify.ts @@ -1,6 +1,6 @@ import { SnakeCaseProps } from "../util.ts"; import { IdentifyConnectionProperties } from "./identify_connection_properties.ts"; -import { UpdateStatus } from "./update_status.ts"; +import { StatusUpdate } from "./status_update.ts"; export interface Identify { /** Authentication token */ @@ -14,7 +14,7 @@ export interface Identify { /** Used for Guild Sharding */ shard?: [shardId: number, numberOfShards: number]; /** Presence structure for initial presence information */ - presence?: UpdateStatus; + presence?: StatusUpdate; /** Enables dispatching of guild subscription events (presence and typing events) */ guildSubscriptions?: boolean; /** The Gateway Intents you wish to receive */ diff --git a/src/types/mod.ts b/src/types/mod.ts index 503dc4b85..8b388fdcd 100644 --- a/src/types/mod.ts +++ b/src/types/mod.ts @@ -46,7 +46,7 @@ export * from "./gateway/ready.ts"; export * from "./gateway/resume.ts"; export * from "./gateway/session_start_limit.ts"; export * from "./gateway/status_types.ts"; -export * from "./gateway/update_status.ts"; +export * from "./gateway/status_update.ts"; export * from "./guilds/ban.ts"; export * from "./guilds/begin_guild_prune.ts"; export * from "./guilds/create_guild.ts"; From 535f27c734447acdc99091cceef3436740cf08ad Mon Sep 17 00:00:00 2001 From: Skillz4Killz <23035000+Skillz4Killz@users.noreply.github.com> Date: Sun, 4 Apr 2021 12:47:02 +0000 Subject: [PATCH 32/34] refactor: merge both shard systems --- src/ws/cleanup_loading_shards.ts | 24 +++ src/ws/create_shard.ts | 49 +++++ src/ws/{proxy => }/events.ts | 1 - src/ws/handle_discord_payload.ts | 18 ++ src/ws/handle_on_message.ts | 94 +++++++++ src/ws/heartbeat.ts | 40 ++++ src/ws/identify.ts | 47 +++++ src/ws/mod.ts | 16 +- src/ws/proxy/deps.ts | 1 - src/ws/proxy/manager.ts | 167 ---------------- src/ws/proxy/mod.ts | 5 - src/ws/proxy/shard.ts | 297 ----------------------------- src/ws/{proxy => }/resharder.ts | 2 +- src/ws/resume.ts | 54 ++++++ src/ws/shard_manager.ts | 100 ---------- src/ws/spawn_shards.ts | 52 +++++ src/ws/start_gateway.ts | 51 +++++ src/ws/start_gateway_options.ts | 26 +++ src/ws/tell_cluster_to_identify.ts | 18 ++ src/ws/{proxy => }/ws.ts | 0 20 files changed, 488 insertions(+), 574 deletions(-) create mode 100644 src/ws/cleanup_loading_shards.ts create mode 100644 src/ws/create_shard.ts rename src/ws/{proxy => }/events.ts (96%) create mode 100644 src/ws/handle_discord_payload.ts create mode 100644 src/ws/handle_on_message.ts create mode 100644 src/ws/heartbeat.ts create mode 100644 src/ws/identify.ts delete mode 100644 src/ws/proxy/deps.ts delete mode 100644 src/ws/proxy/manager.ts delete mode 100644 src/ws/proxy/mod.ts delete mode 100644 src/ws/proxy/shard.ts rename src/ws/{proxy => }/resharder.ts (94%) create mode 100644 src/ws/resume.ts delete mode 100644 src/ws/shard_manager.ts create mode 100644 src/ws/spawn_shards.ts create mode 100644 src/ws/start_gateway.ts create mode 100644 src/ws/start_gateway_options.ts create mode 100644 src/ws/tell_cluster_to_identify.ts rename src/ws/{proxy => }/ws.ts (100%) diff --git a/src/ws/cleanup_loading_shards.ts b/src/ws/cleanup_loading_shards.ts new file mode 100644 index 000000000..7b194e8b2 --- /dev/null +++ b/src/ws/cleanup_loading_shards.ts @@ -0,0 +1,24 @@ +import { delay } from "../util/utils.ts"; +import { ws } from "./ws.ts"; + +/** The handler to clean up shards that identified but never received a READY. */ +export async function cleanupLoadingShards() { + while (ws.loadingShards.size) { + const now = Date.now(); + ws.loadingShards.forEach((loadingShard) => { + console.log( + now > loadingShard.startedAt + 60000, + now, + loadingShard.startedAt + ); + // Not a minute yet. Max should be few seconds but do a minute to be safe. + if (now < loadingShard.startedAt + 60000) return; + + loadingShard.reject( + `[Identify Failure] Shard ${loadingShard.shardID} has not received READY event in over a minute.` + ); + }); + + await delay(1000); + } +} diff --git a/src/ws/create_shard.ts b/src/ws/create_shard.ts new file mode 100644 index 000000000..268a6da58 --- /dev/null +++ b/src/ws/create_shard.ts @@ -0,0 +1,49 @@ +import { identify } from "./identify.ts"; +import { handleOnMessage } from "./proxy/shard.ts"; +import { resume } from "./resume.ts"; +import { ws } from "./ws.ts"; + +// deno-lint-ignore require-await +export async function createShard(shardID: number) { + const socket = new WebSocket(ws.botGatewayData.url); + socket.binaryType = "arraybuffer"; + + socket.onerror = (errorEvent) => { + ws.log("ERROR", { shardID, error: errorEvent }); + }; + + socket.onmessage = ({ data: message }) => handleOnMessage(message, shardID); + + socket.onclose = (event) => { + ws.log("CLOSED", { shardID, payload: event }); + + // TODO: ENUM FOR THESE CODES? + switch (event.code) { + case 4001: + case 4002: + case 4004: + case 4005: + case 4010: + case 4011: + case 4012: + case 4013: + case 4014: + throw new Error( + event.reason || "Discord gave no reason! GG! You broke Discord!" + ); + // THESE ERRORS CAN NO BE RESUMED! THEY MUST RE-IDENTIFY! + case 4003: + case 4007: + case 4008: + case 4009: + ws.log("CLOSED_RECONNECT", { shardID, payload: event }); + identify(shardID, ws.maxShards); + break; + default: + resume(shardID); + break; + } + }; + + return socket; +} diff --git a/src/ws/proxy/events.ts b/src/ws/events.ts similarity index 96% rename from src/ws/proxy/events.ts rename to src/ws/events.ts index 1411ceee6..6932cecf5 100644 --- a/src/ws/proxy/events.ts +++ b/src/ws/events.ts @@ -1,4 +1,3 @@ -import { DiscordPayload } from "../../types/discord.ts"; import { DiscordenoShard } from "./ws.ts"; /** The handler for logging different actions happening inside the ws. User can override and put custom handling per event. */ diff --git a/src/ws/handle_discord_payload.ts b/src/ws/handle_discord_payload.ts new file mode 100644 index 000000000..80b7562ef --- /dev/null +++ b/src/ws/handle_discord_payload.ts @@ -0,0 +1,18 @@ +import { ws } from "./ws.ts"; + +/** Handler for processing all dispatch payloads that should be sent/forwarded to another server/vps/process. */ +export async function handleDiscordPayload( + data: DiscordPayload, + shardID: number +) { + await fetch(ws.url, { + headers: { + authorization: ws.secretKey, + }, + method: "post", + body: JSON.stringify({ + shardID, + data, + }), + }).catch(console.error); +} diff --git a/src/ws/handle_on_message.ts b/src/ws/handle_on_message.ts new file mode 100644 index 000000000..aacdd4b57 --- /dev/null +++ b/src/ws/handle_on_message.ts @@ -0,0 +1,94 @@ +import { identify } from "./identify.ts"; +import { resume } from "./resume.ts"; +import { ws } from "./ws.ts"; +import { decompressWith } from "./deps.ts"; +import { DiscordGatewayOpcodes } from "../types/codes/gateway_opcodes.ts"; +import { DiscordReady } from "../types/gateway/ready.ts"; + +/** Handler for handling every message event from websocket. */ +// deno-lint-ignore no-explicit-any +export function handleOnMessage(message: any, shardID: number) { + if (message instanceof ArrayBuffer) { + message = new Uint8Array(message); + } + + if (message instanceof Uint8Array) { + message = decompressWith(message, 0, (slice: Uint8Array) => + ws.utf8decoder.decode(slice) + ); + } + + if (typeof message !== "string") return; + + const messageData = JSON.parse(message); + ws.log("RAW", messageData); + + switch (messageData.op) { + case DiscordGatewayOpcodes.Hello: + ws.heartbeat( + shardID, + (messageData.d as DiscordHeartbeat).heartbeat_interval + ); + break; + case DiscordGatewayOpcodes.HeartbeatACK: + if (ws.shards.has(shardID)) { + ws.shards.get(shardID)!.heartbeat.acknowledged = true; + } + break; + case DiscordGatewayOpcodes.Reconnect: + ws.log("RECONNECT", { shardID }); + + if (ws.shards.has(shardID)) { + ws.shards.get(shardID)!.resuming = true; + } + + resume(shardID); + break; + case DiscordGatewayOpcodes.InvalidSession: + ws.log("INVALID_SESSION", { shardID, payload: messageData }); + + // When d is false we need to reidentify + if (!messageData.d) { + identify(shardID, ws.maxShards); + break; + } + + if (ws.shards.has(shardID)) { + ws.shards.get(shardID)!.resuming = true; + } + + resume(shardID); + break; + default: + if (messageData.t === "RESUMED") { + ws.log("RESUMED", { shardID }); + + if (ws.shards.has(shardID)) { + ws.shards.get(shardID)!.resuming = false; + } + break; + } + + // Important for RESUME + if (messageData.t === "READY") { + const shard = ws.shards.get(shardID); + if (shard) { + shard.sessionID = (messageData.d as DiscordReady).session_id; + } + + ws.loadingShards.get(shardID)?.resolve(true); + ws.loadingShards.delete(shardID); + } + + // Update the sequence number if it is present + if (messageData.s) { + const shard = ws.shards.get(shardID); + if (shard) { + shard.previousSequenceNumber = messageData.s; + } + } + + ws.handleDiscordPayload(messageData, shardID); + break; + } +} diff --git a/src/ws/heartbeat.ts b/src/ws/heartbeat.ts new file mode 100644 index 000000000..b9a399678 --- /dev/null +++ b/src/ws/heartbeat.ts @@ -0,0 +1,40 @@ +import { ws } from "./ws.ts"; +import { DiscordGatewayOpcodes } from "../types/codes/gateway_opcodes.ts"; + +export function heartbeat(shardID: number, interval: number) { + ws.log("HEARTBEATING_STARTED", { shardID, interval }); + + const shard = ws.shards.get(shardID); + if (!shard) return; + + ws.log("HEARTBEATING_DETAILS", { shardID, interval, shard }); + + shard.heartbeat.keepAlive = true; + shard.heartbeat.acknowledged = false; + shard.heartbeat.lastSentAt = Date.now(); + shard.heartbeat.interval = interval; + + shard.heartbeat.intervalID = setInterval(() => { + const currentShard = ws.shards.get(shardID); + if (!currentShard) return; + + ws.log("HEARTBEATING", { shardID, shard: currentShard }); + + if ( + currentShard.ws.readyState === WebSocket.CLOSED || + !currentShard.heartbeat.keepAlive + ) { + ws.log("HEARTBEATING_CLOSED", { shardID, shard: currentShard }); + + // STOP THE HEARTBEAT + return clearInterval(currentShard.heartbeat.intervalID); + } + + currentShard.ws.send( + JSON.stringify({ + op: DiscordGatewayOpcodes.Heartbeat, + d: currentShard.previousSequenceNumber, + }) + ); + }, interval); +} diff --git a/src/ws/identify.ts b/src/ws/identify.ts new file mode 100644 index 000000000..6b438debd --- /dev/null +++ b/src/ws/identify.ts @@ -0,0 +1,47 @@ +import { DiscordGatewayOpcodes } from "../types/codes/gateway_opcodes.ts"; +import { ws } from "./ws.ts"; + +export async function identify(shardID: number, maxShards: number) { + ws.log("IDENTIFYING", { shardID, maxShards }); + + // CREATE A SHARD + const socket = await ws.createShard(shardID); + + // Identify can just set/reset the settings for the shard + ws.shards.set(shardID, { + id: shardID, + ws: socket, + resumeInterval: 0, + sessionID: "", + previousSequenceNumber: 0, + resuming: false, + heartbeat: { + lastSentAt: 0, + lastReceivedAt: 0, + acknowledged: false, + keepAlive: false, + interval: 0, + intervalID: 0, + }, + }); + + socket.onopen = () => { + socket.send( + JSON.stringify({ + op: DiscordGatewayOpcodes.Identify, + d: { ...ws.identifyPayload, shard: [shardID, maxShards] }, + }) + ); + }; + + return new Promise((resolve, reject) => { + ws.loadingShards.set(shardID, { + shardID, + resolve, + reject, + startedAt: Date.now(), + }); + + ws.cleanupLoadingShards(); + }); +} diff --git a/src/ws/mod.ts b/src/ws/mod.ts index 907ca87b3..dba9b02e8 100644 --- a/src/ws/mod.ts +++ b/src/ws/mod.ts @@ -1,2 +1,14 @@ -export * from "./shard.ts"; -export * from "./shard_manager.ts"; +export * from "./cleanup_loading_shards.ts"; +export * from "./create_shard.ts"; +export * from "./events.ts"; +export * from "./handle_discord_payload.ts"; +export * from "./handle_on_message.ts"; +export * from "./heartbeat.ts"; +export * from "./identify.ts"; +export * from "./resharder.ts"; +export * from "./resume.ts"; +export * from "./spawn_shards.ts"; +export * from "./start_gateway_options.ts"; +export * from "./start_gateway.ts"; +export * from "./tell_cluster_to_identify.ts"; +export * from "./ws.ts"; diff --git a/src/ws/proxy/deps.ts b/src/ws/proxy/deps.ts deleted file mode 100644 index fef4dd6c7..000000000 --- a/src/ws/proxy/deps.ts +++ /dev/null @@ -1 +0,0 @@ -export { decompress_with as decompressWith } from "https://unpkg.com/@evan/wasm@0.0.40/target/zlib/deno.js"; diff --git a/src/ws/proxy/manager.ts b/src/ws/proxy/manager.ts deleted file mode 100644 index 59f72a88a..000000000 --- a/src/ws/proxy/manager.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { DiscordBotGatewayData } from "../../types/discord.ts"; -import { Intents } from "../../types/options.ts"; -import { Collection } from "../../util/collection.ts"; -import { ws } from "./ws.ts"; -import { delay } from "../../util/utils.ts"; - -/** ADVANCED DEVS ONLY!!!!!! - * Starts the standalone gateway. - * This will require starting the bot separately. - */ -export async function startGateway(options: StartGatewayOptions) { - ws.identifyPayload.token = `Bot ${options.token}`; - ws.secretKey = options.secretKey; - ws.firstShardID = options.firstShardID; - ws.url = options.url; - if (options.shardsPerCluster) ws.shardsPerCluster = options.shardsPerCluster; - if (options.maxClusters) ws.maxClusters = options.maxClusters; - - if (options.compress) { - ws.identifyPayload.compress = options.compress; - } - if (options.reshard) ws.reshard = options.reshard; - // Once an hour check if resharding is necessary - setInterval(ws.resharder, 1000 * 60 * 60); - - ws.identifyPayload.intents = options.intents.reduce( - (bits, next) => (bits |= typeof next === "string" ? Intents[next] : next), - 0, - ); - - const data = await fetch( - `https://discord.com/api/gateway/bot`, - { headers: { Authorization: ws.identifyPayload.token } }, - ).then((res) => res.json()) as DiscordBotGatewayData; - - ws.maxShards = options.maxShards || data.shards; - ws.lastShardID = options.lastShardID || data.shards - 1; - - // TODO: ALL THE FOLLOWING CAN BE REPLACED BY THIS 1 LINE - // ws.botGatewayData = snakeToCamel(await getGatewayBot()) - ws.botGatewayData.sessionStartLimit.total = data.session_start_limit.total; - ws.botGatewayData.sessionStartLimit.resetAfter = - data.session_start_limit.reset_after; - ws.botGatewayData.sessionStartLimit.remaining = - data.session_start_limit.remaining; - ws.botGatewayData.sessionStartLimit.maxConcurrency = - data.session_start_limit.max_concurrency; - ws.botGatewayData.shards = data.shards; - ws.botGatewayData.url = data.url; - - ws.spawnShards(ws.firstShardID); - ws.cleanupLoadingShards(); -} - -/** Begin spawning shards. */ -export function spawnShards(firstShardID = 0) { - /** Stored as bucketID: [clusterID, [ShardIDs]] */ - const buckets = new Collection(); - const maxShards = ws.maxShards || ws.botGatewayData.shards; - let cluster = 0; - - for ( - let index = firstShardID; - index < ws.botGatewayData.sessionStartLimit.maxConcurrency; - index++ - ) { - // ORGANIZE ALL SHARDS INTO THEIR OWN BUCKETS - for (let i = 0; i < maxShards; i++) { - const bucketID = i % ws.botGatewayData.sessionStartLimit.maxConcurrency; - const bucket = buckets.get(bucketID); - - if (!bucket) { - // Create the bucket since it doesnt exist - buckets.set(bucketID, [[cluster, i]]); - - if (cluster + 1 <= ws.maxClusters) cluster++; - } else { - // FIND A QUEUE IN THIS BUCKET THAT HAS SPACE - const queue = bucket.find((q) => q.length < ws.shardsPerCluster + 1); - if (queue) { - // IF THE QUEUE HAS SPACE JUST ADD IT TO THIS QUEUE - queue.push(i); - } else { - if (cluster + 1 <= ws.maxClusters) cluster++; - // ADD A NEW QUEUE FOR THIS SHARD - bucket.push([cluster, i]); - } - } - } - } - - // SPREAD THIS OUT TO DIFFERENT CLUSTERS TO BEGIN STARTING UP - buckets.forEach(async (bucket, bucketID) => { - for (const [clusterID, ...queue] of bucket) { - let shardID = queue.shift(); - - while (shardID !== undefined) { - await ws.tellClusterToIdentify(clusterID as number, shardID, bucketID); - shardID = queue.shift(); - } - } - }); -} - -/** Allows users to hook in and change to communicate to different clusters across different servers or anything they like. For example using redis pubsub to talk to other servers. */ -export async function tellClusterToIdentify( - workerID: number, - shardID: number, - bucketID: number, -) { - // When resharding this may exist already - const oldShard = ws.shards.get(shardID); - - // TODO: Use workers - await ws.identify(shardID, ws.maxShards); - - if (oldShard) { - oldShard.ws.close(4009, "Resharded!"); - } -} - -/** The handler to clean up shards that identified but never received a READY. */ -export async function cleanupLoadingShards() { - while (ws.loadingShards.size) { - const now = Date.now(); - ws.loadingShards.forEach((loadingShard) => { - console.log( - now > loadingShard.startedAt + 60000, - now, - loadingShard.startedAt, - ); - // Not a minute yet. Max should be few seconds but do a minute to be safe. - if (now < loadingShard.startedAt + 60000) return; - - loadingShard.reject( - `[Identify Failure] Shard ${loadingShard.shardID} has not received READY event in over a minute.`, - ); - }); - - await delay(1000); - } -} - -export interface StartGatewayOptions { - /** The bot token. */ - token: string; - /** Whether or not to use compression for gateway payloads. */ - compress?: boolean; - /** The intents you would like to enable. */ - intents: (Intents | keyof typeof Intents)[]; - /** The max amount of shards used for identifying. This can be useful for zero-downtime updates or resharding. */ - maxShards?: number; - /** The first shard ID for this group of shards. */ - firstShardID: number; - /** The last shard ID for this group. If none is provided, it will default to loading all shards. */ - lastShardID?: number; - /** The url to forward all payloads to. */ - url: string; - /** The amount of shards per cluster. By default this is 25. Use this to spread the load from shards to different CPU cores. */ - shardsPerCluster?: number; - /** The maximum amount of clusters available. By default this is 4. Another way to think of cluster is how many CPU cores does your server/machine have. */ - maxClusters?: number; - /** Whether or not you want to allow automated sharding. By default this is true. */ - reshard?: boolean; - /** The authorization key that the bot http server will expect. */ - secretKey: string; -} diff --git a/src/ws/proxy/mod.ts b/src/ws/proxy/mod.ts deleted file mode 100644 index b345c0567..000000000 --- a/src/ws/proxy/mod.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./events.ts"; -export * from "./manager.ts"; -export * from "./resharder.ts"; -export * from "./shard.ts"; -export * from "./ws.ts"; diff --git a/src/ws/proxy/shard.ts b/src/ws/proxy/shard.ts deleted file mode 100644 index c46c3f887..000000000 --- a/src/ws/proxy/shard.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { - DiscordHeartbeatPayload, - DiscordPayload, - GatewayOpcode, - ReadyPayload, -} from "../../types/discord.ts"; -import { decompressWith } from "./deps.ts"; -import { ws } from "./ws.ts"; - -export async function resume(shardID: number) { - ws.log("RESUMING", { shardID }); - - // CREATE A SHARD - const socket = await ws.createShard(shardID); - - // NOW WE HANDLE RESUMING THIS SHARD - // Get the old data for this shard necessary for resuming - const oldShard = ws.shards.get(shardID); - - if (oldShard) { - // HOW TO CLOSE OLD SHARD SOCKET!!! - oldShard.ws.close(4009, "Resuming the shard, closing old shard."); - // STOP OLD HEARTBEAT - clearInterval(oldShard.heartbeat.intervalID); - } - - const sessionID = oldShard?.sessionID || ""; - const previousSequenceNumber = oldShard?.previousSequenceNumber || 0; - - ws.shards.set(shardID, { - id: shardID, - ws: socket, - resumeInterval: 0, - sessionID, - previousSequenceNumber, - resuming: false, - heartbeat: { - lastSentAt: 0, - lastReceivedAt: 0, - acknowledged: false, - keepAlive: false, - interval: 0, - intervalID: 0, - }, - }); - - // Resume on open - socket.onopen = () => { - socket.send(JSON.stringify({ - op: GatewayOpcode.Resume, - d: { - token: ws.identifyPayload.token, - session_id: sessionID, - seq: previousSequenceNumber, - }, - })); - }; -} - -export async function identify(shardID: number, maxShards: number) { - ws.log("IDENTIFYING", { shardID, maxShards }); - - // CREATE A SHARD - const socket = await ws.createShard(shardID); - - // Identify can just set/reset the settings for the shard - ws.shards.set(shardID, { - id: shardID, - ws: socket, - resumeInterval: 0, - sessionID: "", - previousSequenceNumber: 0, - resuming: false, - heartbeat: { - lastSentAt: 0, - lastReceivedAt: 0, - acknowledged: false, - keepAlive: false, - interval: 0, - intervalID: 0, - }, - }); - - socket.onopen = () => { - socket.send( - JSON.stringify( - { - op: GatewayOpcode.Identify, - d: { ...ws.identifyPayload, shard: [shardID, maxShards] }, - }, - ), - ); - }; - - return new Promise((resolve, reject) => { - ws.loadingShards.set(shardID, { - shardID, - resolve, - reject, - startedAt: Date.now(), - }); - - ws.cleanupLoadingShards(); - }); -} - -export function heartbeat(shardID: number, interval: number) { - ws.log("HEARTBEATING_STARTED", { shardID, interval }); - - const shard = ws.shards.get(shardID); - if (!shard) return; - - ws.log("HEARTBEATING_DETAILS", { shardID, interval, shard }); - - shard.heartbeat.keepAlive = true; - shard.heartbeat.acknowledged = false; - shard.heartbeat.lastSentAt = Date.now(); - shard.heartbeat.interval = interval; - - shard.heartbeat.intervalID = setInterval(() => { - const currentShard = ws.shards.get(shardID); - if (!currentShard) return; - - ws.log("HEARTBEATING", { shardID, shard: currentShard }); - - if ( - currentShard.ws.readyState === WebSocket.CLOSED || - !currentShard.heartbeat.keepAlive - ) { - ws.log("HEARTBEATING_CLOSED", { shardID, shard: currentShard }); - - // STOP THE HEARTBEAT - return clearInterval(currentShard.heartbeat.intervalID); - } - - currentShard.ws.send( - JSON.stringify( - { - op: GatewayOpcode.Heartbeat, - d: currentShard.previousSequenceNumber, - }, - ), - ); - }, interval); -} - -// deno-lint-ignore require-await -export async function createShard(shardID: number) { - const socket = new WebSocket(ws.botGatewayData.url); - socket.binaryType = "arraybuffer"; - - socket.onerror = (errorEvent) => { - ws.log("ERROR", { shardID, error: errorEvent }); - }; - - socket.onmessage = ({ data: message }) => handleOnMessage(message, shardID); - - socket.onclose = (event) => { - ws.log("CLOSED", { shardID, payload: event }); - - // TODO: ENUM FOR THESE CODES? - switch (event.code) { - case 4001: - case 4002: - case 4004: - case 4005: - case 4010: - case 4011: - case 4012: - case 4013: - case 4014: - throw new Error( - event.reason || "Discord gave no reason! GG! You broke Discord!", - ); - // THESE ERRORS CAN NO BE RESUMED! THEY MUST RE-IDENTIFY! - case 4003: - case 4007: - case 4008: - case 4009: - ws.log("CLOSED_RECONNECT", { shardID, payload: event }); - identify(shardID, ws.maxShards); - break; - default: - resume(shardID); - break; - } - }; - - return socket; -} - -/** Handler for handling every message event from websocket. */ -// deno-lint-ignore no-explicit-any -export function handleOnMessage(message: any, shardID: number) { - if (message instanceof ArrayBuffer) { - message = new Uint8Array(message); - } - - if (message instanceof Uint8Array) { - message = decompressWith( - message, - 0, - (slice: Uint8Array) => ws.utf8decoder.decode(slice), - ); - } - - if (typeof message !== "string") return; - - const messageData = JSON.parse(message); - ws.log("RAW", messageData); - - switch (messageData.op) { - case GatewayOpcode.Hello: - ws.heartbeat( - shardID, - (messageData.d as DiscordHeartbeatPayload).heartbeat_interval, - ); - break; - case GatewayOpcode.HeartbeatACK: - if (ws.shards.has(shardID)) { - ws.shards.get(shardID)!.heartbeat.acknowledged = true; - } - break; - case GatewayOpcode.Reconnect: - ws.log("RECONNECT", { shardID }); - - if (ws.shards.has(shardID)) { - ws.shards.get(shardID)!.resuming = true; - } - - resume(shardID); - break; - case GatewayOpcode.InvalidSession: - ws.log("INVALID_SESSION", { shardID, payload: messageData }); - - // When d is false we need to reidentify - if (!messageData.d) { - identify(shardID, ws.maxShards); - break; - } - - if (ws.shards.has(shardID)) { - ws.shards.get(shardID)!.resuming = true; - } - - resume(shardID); - break; - default: - if (messageData.t === "RESUMED") { - ws.log("RESUMED", { shardID }); - - if (ws.shards.has(shardID)) { - ws.shards.get(shardID)!.resuming = false; - } - break; - } - - // Important for RESUME - if (messageData.t === "READY") { - const shard = ws.shards.get(shardID); - if (shard) { - shard.sessionID = (messageData.d as ReadyPayload).session_id; - } - - ws.loadingShards.get(shardID)?.resolve(true); - ws.loadingShards.delete(shardID); - } - - // Update the sequence number if it is present - if (messageData.s) { - const shard = ws.shards.get(shardID); - if (shard) { - shard.previousSequenceNumber = messageData.s; - } - } - - ws.handleDiscordPayload(messageData, shardID); - break; - } -} - -/** Handler for processing all dispatch payloads that should be sent/forwarded to another server/vps/process. */ -export async function handleDiscordPayload( - data: DiscordPayload, - shardID: number, -) { - await fetch(ws.url, { - headers: { - authorization: ws.secretKey, - }, - method: "post", - body: JSON.stringify({ - shardID, - data, - }), - }).catch(console.error); -} diff --git a/src/ws/proxy/resharder.ts b/src/ws/resharder.ts similarity index 94% rename from src/ws/proxy/resharder.ts rename to src/ws/resharder.ts index b3a8ca4f2..20edb87c3 100644 --- a/src/ws/proxy/resharder.ts +++ b/src/ws/resharder.ts @@ -1,5 +1,5 @@ -import { getGatewayBot } from "../../api/handlers/gateway.ts"; import { ws } from "./ws.ts"; +import { getGatewayBot } from "../helpers/misc/get_gateway_bot.ts"; /** The handler to automatically reshard when necessary. */ export async function resharder() { diff --git a/src/ws/resume.ts b/src/ws/resume.ts new file mode 100644 index 000000000..6f0c204a1 --- /dev/null +++ b/src/ws/resume.ts @@ -0,0 +1,54 @@ +import { DiscordGatewayOpcodes } from "../types/codes/gateway_opcodes.ts"; +import { ws } from "./ws.ts"; + +export async function resume(shardID: number) { + ws.log("RESUMING", { shardID }); + + // CREATE A SHARD + const socket = await ws.createShard(shardID); + + // NOW WE HANDLE RESUMING THIS SHARD + // Get the old data for this shard necessary for resuming + const oldShard = ws.shards.get(shardID); + + if (oldShard) { + // HOW TO CLOSE OLD SHARD SOCKET!!! + oldShard.ws.close(4009, "Resuming the shard, closing old shard."); + // STOP OLD HEARTBEAT + clearInterval(oldShard.heartbeat.intervalID); + } + + const sessionID = oldShard?.sessionID || ""; + const previousSequenceNumber = oldShard?.previousSequenceNumber || 0; + + ws.shards.set(shardID, { + id: shardID, + ws: socket, + resumeInterval: 0, + sessionID, + previousSequenceNumber, + resuming: false, + heartbeat: { + lastSentAt: 0, + lastReceivedAt: 0, + acknowledged: false, + keepAlive: false, + interval: 0, + intervalID: 0, + }, + }); + + // Resume on open + socket.onopen = () => { + socket.send( + JSON.stringify({ + op: DiscordGatewayOpcodes.Resume, + d: { + token: ws.identifyPayload.token, + session_id: sessionID, + seq: previousSequenceNumber, + }, + }) + ); + }; +} diff --git a/src/ws/shard_manager.ts b/src/ws/shard_manager.ts deleted file mode 100644 index e02a93c21..000000000 --- a/src/ws/shard_manager.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { eventHandlers } from "../bot.ts"; -import { cache } from "../cache.ts"; -import { handlers } from "../handlers/mod.ts"; -import { Member } from "../structures/mod.ts"; -import { DiscordGatewayOpcodes } from "../types/codes/gateway_opcodes.ts"; -import { Collection } from "../util/collection.ts"; -import { delay } from "../util/utils.ts"; -import { createShard, requestGuildMembers } from "./mod.ts"; - -let createNextShard = true; - -/** This function is meant to be used on the ready event to alert the library to start the next shard. */ -export function allowNextShard(enabled = true) { - createNextShard = enabled; -} - -export async function spawnShards( - data: DiscordBotGatewayData, - payload: DiscordIdentify, - shardId: number, - lastShardId: number, - skipChecks?: number, -) { - // All shards on this worker have started! Cancel out. - if (shardId >= lastShardId) return; - - if (skipChecks) { - payload.shard = [ - shardId, - data.shards > lastShardId ? data.shards : lastShardId, - ]; - // Start The shard - createShard(data, payload, false, shardId); - // Spawn next shard - await spawnShards( - data, - payload, - shardId + 1, - lastShardId, - skipChecks - 1, - ); - return; - } - - // Make sure we can create a shard or we are waiting for shards to connect still. - if (createNextShard) { - createNextShard = false; - // Start the next few shards based on max concurrency - await spawnShards( - data, - payload, - shardId, - lastShardId, - data.session_start_limit.max_concurrency, - ); - return; - } - - await delay(1000); - await spawnShards(data, payload, shardId, lastShardId, skipChecks); -} - -export async function handleDiscordPayload( - data: DiscordPayload, - shardId: number, -) { - eventHandlers.raw?.(data); - await eventHandlers.dispatchRequirements?.(data, shardId); - - switch (data.op) { - case DiscordGatewayOpcodes.HeartbeatACK: - // In case the user wants to listen to heartbeat responses - return eventHandlers.heartbeat?.(); - case DiscordGatewayOpcodes.Dispatch: - if (!data.t) return; - // Run the appropriate handler for this event. - return handlers[data.t]?.(data, shardId); - default: - return; - } -} - -export async function requestAllMembers( - guildId: string, - shardId: number, - resolve: ( - value: Collection | PromiseLike>, - ) => void, - options?: FetchMembersOptions, -) { - const nonce = `${guildId}-${Date.now()}`; - cache.fetchAllMembersProcessingRequests.set(nonce, resolve); - - await requestGuildMembers( - guildId, - shardId, - nonce, - options, - ); -} diff --git a/src/ws/spawn_shards.ts b/src/ws/spawn_shards.ts new file mode 100644 index 000000000..bbefd18af --- /dev/null +++ b/src/ws/spawn_shards.ts @@ -0,0 +1,52 @@ +import { Collection } from "../util/collection.ts"; +import { ws } from "./ws.ts"; + +/** Begin spawning shards. */ +export function spawnShards(firstShardID = 0) { + /** Stored as bucketID: [clusterID, [ShardIDs]] */ + const buckets = new Collection(); + const maxShards = ws.maxShards || ws.botGatewayData.shards; + let cluster = 0; + + for ( + let index = firstShardID; + index < ws.botGatewayData.sessionStartLimit.maxConcurrency; + index++ + ) { + // ORGANIZE ALL SHARDS INTO THEIR OWN BUCKETS + for (let i = 0; i < maxShards; i++) { + const bucketID = i % ws.botGatewayData.sessionStartLimit.maxConcurrency; + const bucket = buckets.get(bucketID); + + if (!bucket) { + // Create the bucket since it doesnt exist + buckets.set(bucketID, [[cluster, i]]); + + if (cluster + 1 <= ws.maxClusters) cluster++; + } else { + // FIND A QUEUE IN THIS BUCKET THAT HAS SPACE + const queue = bucket.find((q) => q.length < ws.shardsPerCluster + 1); + if (queue) { + // IF THE QUEUE HAS SPACE JUST ADD IT TO THIS QUEUE + queue.push(i); + } else { + if (cluster + 1 <= ws.maxClusters) cluster++; + // ADD A NEW QUEUE FOR THIS SHARD + bucket.push([cluster, i]); + } + } + } + } + + // SPREAD THIS OUT TO DIFFERENT CLUSTERS TO BEGIN STARTING UP + buckets.forEach(async (bucket, bucketID) => { + for (const [clusterID, ...queue] of bucket) { + let shardID = queue.shift(); + + while (shardID !== undefined) { + await ws.tellClusterToIdentify(clusterID as number, shardID, bucketID); + shardID = queue.shift(); + } + } + }); +} diff --git a/src/ws/start_gateway.ts b/src/ws/start_gateway.ts new file mode 100644 index 000000000..6efbb9e94 --- /dev/null +++ b/src/ws/start_gateway.ts @@ -0,0 +1,51 @@ +import { StartGatewayOptions } from "./start_gateway_options.ts"; +import { DiscordGatewayIntents } from "../types/gateway/gateway_intents.ts"; +import { ws } from "./ws.ts"; + +/** ADVANCED DEVS ONLY!!!!!! + * Starts the standalone gateway. + * This will require starting the bot separately. + */ +export async function startGateway(options: StartGatewayOptions) { + ws.identifyPayload.token = `Bot ${options.token}`; + ws.secretKey = options.secretKey; + ws.firstShardID = options.firstShardID; + ws.url = options.url; + if (options.shardsPerCluster) ws.shardsPerCluster = options.shardsPerCluster; + if (options.maxClusters) ws.maxClusters = options.maxClusters; + + if (options.compress) { + ws.identifyPayload.compress = options.compress; + } + if (options.reshard) ws.reshard = options.reshard; + // Once an hour check if resharding is necessary + setInterval(ws.resharder, 1000 * 60 * 60); + + ws.identifyPayload.intents = options.intents.reduce( + (bits, next) => + (bits |= typeof next === "string" ? DiscordGatewayIntents[next] : next), + 0 + ); + + const data = (await fetch(`https://discord.com/api/gateway/bot`, { + headers: { Authorization: ws.identifyPayload.token }, + }).then((res) => res.json())) as DiscordBotGatewayData; + + ws.maxShards = options.maxShards || data.shards; + ws.lastShardID = options.lastShardID || data.shards - 1; + + // TODO: ALL THE FOLLOWING CAN BE REPLACED BY THIS 1 LINE + // ws.botGatewayData = snakeToCamel(await getGatewayBot()) + ws.botGatewayData.sessionStartLimit.total = data.session_start_limit.total; + ws.botGatewayData.sessionStartLimit.resetAfter = + data.session_start_limit.reset_after; + ws.botGatewayData.sessionStartLimit.remaining = + data.session_start_limit.remaining; + ws.botGatewayData.sessionStartLimit.maxConcurrency = + data.session_start_limit.max_concurrency; + ws.botGatewayData.shards = data.shards; + ws.botGatewayData.url = data.url; + + ws.spawnShards(ws.firstShardID); + ws.cleanupLoadingShards(); +} diff --git a/src/ws/start_gateway_options.ts b/src/ws/start_gateway_options.ts new file mode 100644 index 000000000..2d016a8ff --- /dev/null +++ b/src/ws/start_gateway_options.ts @@ -0,0 +1,26 @@ +import { DiscordGatewayIntents } from "../types/gateway/gateway_intents.ts"; + +export interface StartGatewayOptions { + /** The bot token. */ + token: string; + /** Whether or not to use compression for gateway payloads. */ + compress?: boolean; + /** The intents you would like to enable. */ + intents: (DiscordGatewayIntents | keyof typeof DiscordGatewayIntents)[]; + /** The max amount of shards used for identifying. This can be useful for zero-downtime updates or resharding. */ + maxShards?: number; + /** The first shard ID for this group of shards. */ + firstShardID: number; + /** The last shard ID for this group. If none is provided, it will default to loading all shards. */ + lastShardID?: number; + /** The url to forward all payloads to. */ + url: string; + /** The amount of shards per cluster. By default this is 25. Use this to spread the load from shards to different CPU cores. */ + shardsPerCluster?: number; + /** The maximum amount of clusters available. By default this is 4. Another way to think of cluster is how many CPU cores does your server/machine have. */ + maxClusters?: number; + /** Whether or not you want to allow automated sharding. By default this is true. */ + reshard?: boolean; + /** The authorization key that the bot http server will expect. */ + secretKey: string; +} diff --git a/src/ws/tell_cluster_to_identify.ts b/src/ws/tell_cluster_to_identify.ts new file mode 100644 index 000000000..7ef75968a --- /dev/null +++ b/src/ws/tell_cluster_to_identify.ts @@ -0,0 +1,18 @@ +import { ws } from "./ws.ts"; + +/** Allows users to hook in and change to communicate to different clusters across different servers or anything they like. For example using redis pubsub to talk to other servers. */ +export async function tellClusterToIdentify( + workerID: number, + shardID: number, + bucketID: number +) { + // When resharding this may exist already + const oldShard = ws.shards.get(shardID); + + // TODO: Use workers + await ws.identify(shardID, ws.maxShards); + + if (oldShard) { + oldShard.ws.close(4009, "Resharded!"); + } +} diff --git a/src/ws/proxy/ws.ts b/src/ws/ws.ts similarity index 100% rename from src/ws/proxy/ws.ts rename to src/ws/ws.ts From 1b8161e85b7e97568ed9d5d4c394713b6fc1c1e0 Mon Sep 17 00:00:00 2001 From: Skillz4Killz <23035000+Skillz4Killz@users.noreply.github.com> Date: Sun, 4 Apr 2021 12:54:33 +0000 Subject: [PATCH 33/34] refactor: cleanup and fmt --- src/bot.ts | 1 - src/cache.ts | 46 +-- src/handlers/guilds/GUILD_CREATE.ts | 1 - src/handlers/guilds/GUILD_DELETE.ts | 1 - src/helpers/channels/create_channel.ts | 4 +- src/helpers/guilds/guild_banner_url.ts | 2 +- src/helpers/members/fetch_members.ts | 1 - src/helpers/members/get_member.ts | 9 +- src/helpers/members/get_members_by_query.ts | 1 - src/structures/guild.ts | 12 +- src/structures/message.ts | 42 +-- src/types/users/connection.ts | 2 +- src/util/constants.ts | 2 +- src/util/utils.ts | 1 - src/ws/README.md | 2 +- src/ws/cleanup_loading_shards.ts | 4 +- src/ws/create_shard.ts | 3 +- src/ws/handle_discord_payload.ts | 2 +- src/ws/handle_on_message.ts | 8 +- src/ws/heartbeat.ts | 2 +- src/ws/identify.ts | 2 +- src/ws/resume.ts | 2 +- src/ws/shard.ts | 383 -------------------- src/ws/start_gateway.ts | 10 +- src/ws/tell_cluster_to_identify.ts | 2 +- src/ws/ws.ts | 24 +- 26 files changed, 89 insertions(+), 480 deletions(-) delete mode 100644 src/ws/shard.ts diff --git a/src/bot.ts b/src/bot.ts index 12be79451..3507cfc47 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -2,7 +2,6 @@ import { getGatewayBot } from "./helpers/misc/get_gateway_bot.ts"; import { DiscordGatewayIntents } from "./types/gateway/gateway_intents.ts"; import { DiscordGetGatewayBot } from "./types/gateway/get_gateway_bot.ts"; import { baseEndpoints, GATEWAY_VERSION } from "./util/constants.ts"; -import { spawnShards } from "./ws/shard_manager.ts"; export let authorization = ""; export let secretKey = ""; diff --git a/src/cache.ts b/src/cache.ts index ed6193880..2bab9d370 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -23,7 +23,7 @@ export const cache = { ( value: | Collection - | PromiseLike> + | PromiseLike>, ) => void >(), executedSlashCommands: new Collection(), @@ -31,8 +31,8 @@ export const cache = { return new Collection( this.guilds.reduce( (a, b) => [...a, ...b.emojis.map((e) => [e.id, e])], - [] as any[] - ) + [] as any[], + ), ); }, }; @@ -78,32 +78,32 @@ export type TableName = function set( table: "guilds", key: string, - value: Guild + value: Guild, ): Promise>; function set( table: "channels", key: string, - value: Channel + value: Channel, ): Promise>; function set( table: "messages", key: string, - value: Message + value: Message, ): Promise>; function set( table: "members", key: string, - value: Member + value: Member, ): Promise>; function set( table: "presences", key: string, - value: PresenceUpdatePayload + value: PresenceUpdatePayload, ): Promise>; function set( table: "unavailableGuilds", key: string, - value: number + value: number, ): Promise>; async function set(table: TableName, key: string, value: any) { return cache[table].set(key, value); @@ -115,11 +115,11 @@ function get(table: "messages", key: string): Promise; function get(table: "members", key: string): Promise; function get( table: "presences", - key: string + key: string, ): Promise; function get( table: "unavailableGuilds", - key: string + key: string, ): Promise; async function get(table: TableName, key: string) { return cache[table].get(key); @@ -127,54 +127,54 @@ async function get(table: TableName, key: string) { function forEach( table: "guilds", - callback: (value: Guild, key: string, map: Map) => unknown + callback: (value: Guild, key: string, map: Map) => unknown, ): void; function forEach( table: "unavailableGuilds", - callback: (value: Guild, key: string, map: Map) => unknown + callback: (value: Guild, key: string, map: Map) => unknown, ): void; function forEach( table: "channels", - callback: (value: Channel, key: string, map: Map) => unknown + callback: (value: Channel, key: string, map: Map) => unknown, ): void; function forEach( table: "messages", - callback: (value: Message, key: string, map: Map) => unknown + callback: (value: Message, key: string, map: Map) => unknown, ): void; function forEach( table: "members", - callback: (value: Member, key: string, map: Map) => unknown + callback: (value: Member, key: string, map: Map) => unknown, ): void; function forEach( table: TableName, - callback: (value: any, key: string, map: Map) => unknown + callback: (value: any, key: string, map: Map) => unknown, ) { return cache[table].forEach(callback); } function filter( table: "guilds", - callback: (value: Guild, key: string) => boolean + callback: (value: Guild, key: string) => boolean, ): Promise>; function filter( table: "unavailableGuilds", - callback: (value: Guild, key: string) => boolean + callback: (value: Guild, key: string) => boolean, ): Promise>; function filter( table: "channels", - callback: (value: Channel, key: string) => boolean + callback: (value: Channel, key: string) => boolean, ): Promise>; function filter( table: "messages", - callback: (value: Message, key: string) => boolean + callback: (value: Message, key: string) => boolean, ): Promise>; function filter( table: "members", - callback: (value: Member, key: string) => boolean + callback: (value: Member, key: string) => boolean, ): Promise>; async function filter( table: TableName, - callback: (value: any, key: string) => boolean + callback: (value: any, key: string) => boolean, ) { return cache[table].filter(callback); } diff --git a/src/handlers/guilds/GUILD_CREATE.ts b/src/handlers/guilds/GUILD_CREATE.ts index d298b39a8..968388b9d 100644 --- a/src/handlers/guilds/GUILD_CREATE.ts +++ b/src/handlers/guilds/GUILD_CREATE.ts @@ -1,7 +1,6 @@ import { eventHandlers } from "../../bot.ts"; import { cache, cacheHandlers } from "../../cache.ts"; import { structures } from "../../structures/mod.ts"; -import { basicShards } from "../../ws/shard.ts"; import { DiscordGatewayPayload } from "../../types/gateway/gateway_payload.ts"; import { DiscordGuild } from "../../types/guilds/guild.ts"; diff --git a/src/handlers/guilds/GUILD_DELETE.ts b/src/handlers/guilds/GUILD_DELETE.ts index a1ab0dd59..2ffd989e8 100644 --- a/src/handlers/guilds/GUILD_DELETE.ts +++ b/src/handlers/guilds/GUILD_DELETE.ts @@ -1,6 +1,5 @@ import { eventHandlers } from "../../bot.ts"; import { cacheHandlers } from "../../cache.ts"; -import { basicShards } from "../../ws/shard.ts"; import { DiscordGatewayPayload } from "../../types/gateway/gateway_payload.ts"; import { DiscordUnavailableGuild } from "../../types/guilds/unavailable_guild.ts"; diff --git a/src/helpers/channels/create_channel.ts b/src/helpers/channels/create_channel.ts index 6292308fa..57ebc3106 100644 --- a/src/helpers/channels/create_channel.ts +++ b/src/helpers/channels/create_channel.ts @@ -15,7 +15,7 @@ import { export async function createChannel( guildId: string, name: string, - options?: CreateGuildChannel + options?: CreateGuildChannel, ) { const requiredPerms: Set = new Set(["MANAGE_CHANNELS"]); @@ -39,7 +39,7 @@ export async function createChannel( deny: calculateBits(perm.deny), })), type: options?.type || DiscordChannelTypes.GUILD_TEXT, - } + }, )) as DiscordChannel; const channelStruct = await structures.createChannelStruct(result); diff --git a/src/helpers/guilds/guild_banner_url.ts b/src/helpers/guilds/guild_banner_url.ts index 7b4866915..89e07efe4 100644 --- a/src/helpers/guilds/guild_banner_url.ts +++ b/src/helpers/guilds/guild_banner_url.ts @@ -8,7 +8,7 @@ export function guildBannerURL( id: string, banner: string, size: DiscordImageSize = 128, - format?: DiscordImageFormat + format?: DiscordImageFormat, ) { return banner ? formatImageURL(endpoints.GUILD_BANNER(id, banner), size, format) diff --git a/src/helpers/members/fetch_members.ts b/src/helpers/members/fetch_members.ts index f894a61dd..05c526d89 100644 --- a/src/helpers/members/fetch_members.ts +++ b/src/helpers/members/fetch_members.ts @@ -3,7 +3,6 @@ import { Member } from "../../structures/mod.ts"; import { DiscordGatewayIntents } from "../../types/gateway/gateway_intents.ts"; import { Errors } from "../../types/misc/errors.ts"; import { Collection } from "../../util/collection.ts"; -import { requestAllMembers } from "../../ws/shard_manager.ts"; /** * ⚠️ BEGINNER DEVS!! YOU SHOULD ALMOST NEVER NEED THIS AND YOU CAN GET FROM cache.members.get() diff --git a/src/helpers/members/get_member.ts b/src/helpers/members/get_member.ts index e98ca9320..52d529adb 100644 --- a/src/helpers/members/get_member.ts +++ b/src/helpers/members/get_member.ts @@ -15,11 +15,10 @@ export async function getMember( const guild = await cacheHandlers.get("guilds", guildId); if (!guild && !options?.force) return; - const data = - (await rest.runMethod( - "get", - endpoints.GUILD_MEMBER(guildId, id), - )) as MemberCreatePayload; + const data = (await rest.runMethod( + "get", + endpoints.GUILD_MEMBER(guildId, id), + )) as MemberCreatePayload; const memberStruct = await structures.createMemberStruct(data, guildId); await cacheHandlers.set("members", memberStruct.id, memberStruct); diff --git a/src/helpers/members/get_members_by_query.ts b/src/helpers/members/get_members_by_query.ts index 770f8147d..0750ca694 100644 --- a/src/helpers/members/get_members_by_query.ts +++ b/src/helpers/members/get_members_by_query.ts @@ -1,7 +1,6 @@ import { cacheHandlers } from "../../cache.ts"; import { Member } from "../../structures/mod.ts"; import { Collection } from "../../util/collection.ts"; -import { requestAllMembers } from "../../ws/shard_manager.ts"; /** Returns guild member objects for the specified user by their nickname/username. * diff --git a/src/structures/guild.ts b/src/structures/guild.ts index 2153aedc7..83ad2f66a 100644 --- a/src/structures/guild.ts +++ b/src/structures/guild.ts @@ -118,7 +118,7 @@ export async function createGuildStruct( } = snakeKeysToCamelCase(data) as Guild; const roles = await Promise.all( - data.roles.map((role) => structures.createRoleStruct(role)) + data.roles.map((role) => structures.createRoleStruct(role)), ); await Promise.all(channels.map(async (channel) => { @@ -148,7 +148,7 @@ export async function createGuildStruct( ), memberCount: createNewProp(memberCount), emojis: createNewProp( - new Collection(emojis.map((emoji) => [emoji.id ?? emoji.name, emoji])) + new Collection(emojis.map((emoji) => [emoji.id ?? emoji.name, emoji])), ), voiceStates: createNewProp( new Collection( @@ -165,11 +165,11 @@ export async function createGuildStruct( members.map(async (member) => { const memberStruct = await structures.createMemberStruct( member, - guild.id + guild.id, ); return cacheHandlers.set("members", memberStruct.id, memberStruct); - }) + }), ); } @@ -221,8 +221,8 @@ export interface GuildStruct extends size?: DiscordImageSize, format?: DiscordImageFormat, ): string | undefined; - /** The splash url for this server */ - splashURL( + /** The splash url for this server */ + splashURL( size?: DiscordImageSize, format?: DiscordImageFormat, ): string | undefined; diff --git a/src/structures/message.ts b/src/structures/message.ts index 328bebaf1..577b965c1 100644 --- a/src/structures/message.ts +++ b/src/structures/message.ts @@ -30,9 +30,8 @@ const baseMessage: Partial = { return this.member?.guilds.get(this.guildId); }, get link() { - return `https://discord.com/channels/${this.guildId || "@me"}/${ - this.channelId - }/${this.id}`; + return `https://discord.com/channels/${this.guildId || + "@me"}/${this.channelId}/${this.id}`; }, get mentionedRoles() { return this.mentionRoleIds?.map((id) => this.guild?.roles.get(id)) || []; @@ -61,20 +60,19 @@ const baseMessage: Partial = { return addReactions(this.channelId!, this.id!, reactions, ordered); }, reply(content) { - const contentWithMention = - typeof content === "string" - ? { - content, - mentions: { repliedUser: true }, - replyMessageId: this.id, - failReplyIfNotExists: false, - } - : { - ...content, - mentions: { ...(content.mentions || {}), repliedUser: true }, - replyMessageId: this.id, - failReplyIfNotExists: content.failReplyIfNotExists === true, - }; + const contentWithMention = typeof content === "string" + ? { + content, + mentions: { repliedUser: true }, + replyMessageId: this.id, + failReplyIfNotExists: false, + } + : { + ...content, + mentions: { ...(content.mentions || {}), repliedUser: true }, + replyMessageId: this.id, + failReplyIfNotExists: content.failReplyIfNotExists === true, + }; if (this.guildId) return sendMessage(this.channelId!, contentWithMention); return sendDirectMessage(this.author!.id, contentWithMention); @@ -132,8 +130,8 @@ export async function createMessageStruct(data: MessageCreateOptions) { } // Discord doesnt give guild id for getMessage() so this will fill it in - const guildIdFinal = - guildId || (await cacheHandlers.get("channels", channelId))?.guildId || ""; + const guildIdFinal = guildId || + (await cacheHandlers.get("channels", channelId))?.guildId || ""; const message = Object.create(baseMessage, { ...restProps, @@ -150,16 +148,16 @@ export async function createMessageStruct(data: MessageCreateOptions) { ...mentionChannelIds, // Add any other ids that can be validated in a channel mention format ...(rest.content.match(CHANNEL_MENTION_REGEX) || []).map((text) => - // converts the <#123> into 123 + // converts the <#123> into 123 text.substring(2, text.length - 1) ), - ].map((m) => m.id) + ].map((m) => m.id), ), webhookId: createNewProp(webhookId), messageReference: createNewProp(messageReference), timestamp: createNewProp(Date.parse(data.timestamp)), editedTimestamp: createNewProp( - editedTimestamp ? Date.parse(editedTimestamp) : undefined + editedTimestamp ? Date.parse(editedTimestamp) : undefined, ), }); diff --git a/src/types/users/connection.ts b/src/types/users/connection.ts index 13a78c17a..b86c6f4fb 100644 --- a/src/types/users/connection.ts +++ b/src/types/users/connection.ts @@ -1,6 +1,6 @@ import { SnakeCaseProps } from "../util.ts"; import { DiscordVisibilityTypes } from "./visibility_types.ts"; -import { Integration } from "../guilds/integration.ts" +import { Integration } from "../guilds/integration.ts"; export interface Connection { /** id of the connection account */ diff --git a/src/util/constants.ts b/src/util/constants.ts index d8701e5c1..28e0125c4 100644 --- a/src/util/constants.ts +++ b/src/util/constants.ts @@ -177,4 +177,4 @@ export const endpoints = { }; export const SLASH_COMMANDS_NAME_REGEX = /^[\w-]{1,32}$/; -export const CHANNEL_MENTION_REGEX = /<#[0-9]+>/g; \ No newline at end of file +export const CHANNEL_MENTION_REGEX = /<#[0-9]+>/g; diff --git a/src/util/utils.ts b/src/util/utils.ts index c27e75bcb..ec4ece452 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -3,7 +3,6 @@ import { DiscordGatewayOpcodes } from "../types/codes/gateway_opcodes.ts"; import { Errors } from "../types/misc/errors.ts"; import { DiscordImageFormat } from "../types/misc/image_format.ts"; import { DiscordImageSize } from "../types/misc/image_size.ts"; -import { basicShards, sendWS } from "../ws/shard.ts"; import { SLASH_COMMANDS_NAME_REGEX } from "./constants.ts"; export const sleep = (timeout: number) => { diff --git a/src/ws/README.md b/src/ws/README.md index 77e1bb7a5..b2088533b 100644 --- a/src/ws/README.md +++ b/src/ws/README.md @@ -201,4 +201,4 @@ export interface DiscordenoShard { intervalID: number; }; } -``` \ No newline at end of file +``` diff --git a/src/ws/cleanup_loading_shards.ts b/src/ws/cleanup_loading_shards.ts index 7b194e8b2..92ed6788d 100644 --- a/src/ws/cleanup_loading_shards.ts +++ b/src/ws/cleanup_loading_shards.ts @@ -9,13 +9,13 @@ export async function cleanupLoadingShards() { console.log( now > loadingShard.startedAt + 60000, now, - loadingShard.startedAt + loadingShard.startedAt, ); // Not a minute yet. Max should be few seconds but do a minute to be safe. if (now < loadingShard.startedAt + 60000) return; loadingShard.reject( - `[Identify Failure] Shard ${loadingShard.shardID} has not received READY event in over a minute.` + `[Identify Failure] Shard ${loadingShard.shardID} has not received READY event in over a minute.`, ); }); diff --git a/src/ws/create_shard.ts b/src/ws/create_shard.ts index 268a6da58..1f7efd446 100644 --- a/src/ws/create_shard.ts +++ b/src/ws/create_shard.ts @@ -1,5 +1,4 @@ import { identify } from "./identify.ts"; -import { handleOnMessage } from "./proxy/shard.ts"; import { resume } from "./resume.ts"; import { ws } from "./ws.ts"; @@ -29,7 +28,7 @@ export async function createShard(shardID: number) { case 4013: case 4014: throw new Error( - event.reason || "Discord gave no reason! GG! You broke Discord!" + event.reason || "Discord gave no reason! GG! You broke Discord!", ); // THESE ERRORS CAN NO BE RESUMED! THEY MUST RE-IDENTIFY! case 4003: diff --git a/src/ws/handle_discord_payload.ts b/src/ws/handle_discord_payload.ts index 80b7562ef..35490d79a 100644 --- a/src/ws/handle_discord_payload.ts +++ b/src/ws/handle_discord_payload.ts @@ -3,7 +3,7 @@ import { ws } from "./ws.ts"; /** Handler for processing all dispatch payloads that should be sent/forwarded to another server/vps/process. */ export async function handleDiscordPayload( data: DiscordPayload, - shardID: number + shardID: number, ) { await fetch(ws.url, { headers: { diff --git a/src/ws/handle_on_message.ts b/src/ws/handle_on_message.ts index aacdd4b57..3bf17ec13 100644 --- a/src/ws/handle_on_message.ts +++ b/src/ws/handle_on_message.ts @@ -13,8 +13,10 @@ export function handleOnMessage(message: any, shardID: number) { } if (message instanceof Uint8Array) { - message = decompressWith(message, 0, (slice: Uint8Array) => - ws.utf8decoder.decode(slice) + message = decompressWith( + message, + 0, + (slice: Uint8Array) => ws.utf8decoder.decode(slice), ); } @@ -27,7 +29,7 @@ export function handleOnMessage(message: any, shardID: number) { case DiscordGatewayOpcodes.Hello: ws.heartbeat( shardID, - (messageData.d as DiscordHeartbeat).heartbeat_interval + (messageData.d as DiscordHeartbeat).heartbeat_interval, ); break; case DiscordGatewayOpcodes.HeartbeatACK: diff --git a/src/ws/heartbeat.ts b/src/ws/heartbeat.ts index b9a399678..ab9122aee 100644 --- a/src/ws/heartbeat.ts +++ b/src/ws/heartbeat.ts @@ -34,7 +34,7 @@ export function heartbeat(shardID: number, interval: number) { JSON.stringify({ op: DiscordGatewayOpcodes.Heartbeat, d: currentShard.previousSequenceNumber, - }) + }), ); }, interval); } diff --git a/src/ws/identify.ts b/src/ws/identify.ts index 6b438debd..f020a3f2c 100644 --- a/src/ws/identify.ts +++ b/src/ws/identify.ts @@ -30,7 +30,7 @@ export async function identify(shardID: number, maxShards: number) { JSON.stringify({ op: DiscordGatewayOpcodes.Identify, d: { ...ws.identifyPayload, shard: [shardID, maxShards] }, - }) + }), ); }; diff --git a/src/ws/resume.ts b/src/ws/resume.ts index 6f0c204a1..78f03365b 100644 --- a/src/ws/resume.ts +++ b/src/ws/resume.ts @@ -48,7 +48,7 @@ export async function resume(shardID: number) { session_id: sessionID, seq: previousSequenceNumber, }, - }) + }), ); }; } diff --git a/src/ws/shard.ts b/src/ws/shard.ts deleted file mode 100644 index 2926d6710..000000000 --- a/src/ws/shard.ts +++ /dev/null @@ -1,383 +0,0 @@ -import { botGatewayData, eventHandlers, proxyWSURL } from "../bot.ts"; -import { DiscordGatewayOpcodes } from "../types/codes/gateway_opcodes.ts"; -import { Collection } from "../util/collection.ts"; -import { delay } from "../util/utils.ts"; -import { decompressWith } from "./deps.ts"; -import { handleDiscordPayload } from "./shard_manager.ts"; - -export const basicShards = new Collection(); -const heartbeating = new Map(); -const utf8decoder = new TextDecoder(); -const RequestMembersQueue: RequestMemberQueuedRequest[] = []; -let processQueue = false; - -export function createShard( - data: DiscordBotGatewayData, - identifyPayload: DiscordIdentify, - resuming = false, - shardId = 0, -) { - const oldShard = basicShards.get(shardId); - - const ws = new WebSocket(proxyWSURL); - ws.binaryType = "arraybuffer"; - const basicShard: BasicShard = { - id: shardId, - ws, - resumeInterval: 0, - sessionId: oldShard?.sessionId || "", - previousSequenceNumber: oldShard?.previousSequenceNumber || 0, - needToResume: false, - ready: false, - unavailableGuildIds: new Set(), - }; - - basicShards.set(basicShard.id, basicShard); - - ws.onopen = () => { - if (!resuming) { - // Initial identify with the gateway - identify(basicShard, identifyPayload); - } else { - resume(basicShard, identifyPayload); - } - }; - - ws.onerror = (errorEvent) => { - eventHandlers.debug?.({ - type: "wsError", - data: { shardId: basicShard.id, ...errorEvent }, - }); - }; - - ws.onmessage = async ({ data: message }) => { - if (message instanceof ArrayBuffer) { - message = new Uint8Array(message); - } - - if (message instanceof Uint8Array) { - message = decompressWith( - message, - 0, - (slice: Uint8Array) => utf8decoder.decode(slice), - ); - } - - if (typeof message === "string") { - const messageData = JSON.parse(message); - if (!messageData.t) eventHandlers.rawGateway?.(messageData); - switch (messageData.op) { - case DiscordGatewayOpcodes.Hello: - if (!heartbeating.has(basicShard.id)) { - await heartbeat( - basicShard, - (messageData.d as DiscordHello).heartbeat_interval, - identifyPayload, - data, - ); - } - break; - case DiscordGatewayOpcodes.HeartbeatACK: - heartbeating.set(shardId, true); - break; - case DiscordGatewayOpcodes.Reconnect: - eventHandlers.debug?.( - { type: "gatewayReconnect", data: { shardId: basicShard.id } }, - ); - basicShard.needToResume = true; - await resumeConnection(data, identifyPayload, basicShard.id); - break; - case DiscordGatewayOpcodes.InvalidSession: - eventHandlers.debug?.( - { - type: "gatewayInvalidSession", - data: { shardId: basicShard.id, data }, - }, - ); - // When d is false we need to reidentify - if (!messageData.d) { - createShard(data, identifyPayload, false, shardId); - break; - } - basicShard.needToResume = true; - await resumeConnection(data, identifyPayload, basicShard.id); - break; - default: - if (messageData.t === "RESUMED") { - eventHandlers.debug?.( - { type: "gatewayResumed", data: { shardId: basicShard.id } }, - ); - - basicShard.needToResume = false; - break; - } - // Important for RESUME - if (messageData.t === "READY") { - basicShard.sessionId = (messageData.d as ReadyPayload).session_id; - } - - // Update the sequence number if it is present - if (messageData.s) basicShard.previousSequenceNumber = messageData.s; - - await handleDiscordPayload(messageData, basicShard.id); - break; - } - } - }; - - ws.onclose = async ({ reason, code, wasClean }) => { - eventHandlers.debug?.( - { - type: "wsClose", - data: { shardId: basicShard.id, code, reason, wasClean }, - }, - ); - - if ([4001, 4002, 4004, 4005, 4010, 4011, 4012, 4013, 4014].includes(code)) { - throw new Error(reason); - } else if ([4000, 4003, 4007, 4008, 4009].includes(code)) { - eventHandlers.debug?.({ - type: "wsReconnect", - data: { shardId: basicShard.id, code, reason, wasClean }, - }); - createShard(data, identifyPayload, false, shardId); - } else if (code === 3069 && reason === "[discordeno] requested closure") { - return; - } else { - basicShard.needToResume = true; - await resumeConnection(botGatewayData, identifyPayload, shardId); - } - }; -} - -function identify(shard: BasicShard, payload: DiscordIdentify) { - eventHandlers.debug?.( - { - type: "gatewayIdentify", - data: { - shardId: shard.id, - }, - }, - ); - - sendWS({ - op: DiscordGatewayOpcodes.Identify, - d: { ...payload, shard: [shard.id, payload.shard[1]] }, - }, shard.id); -} - -function resume(shard: BasicShard, payload: DiscordIdentify) { - sendWS({ - op: DiscordGatewayOpcodes.Resume, - d: { - token: payload.token, - session_id: shard.sessionId, - seq: shard.previousSequenceNumber, - }, - }, shard.id); -} - -async function heartbeat( - shard: BasicShard, - interval: number, - payload: DiscordIdentify, - data: DiscordGetGatewayBot, -) { - // We lost socket connection between heartbeats, resume connection - if (shard.ws.readyState === WebSocket.CLOSED) { - shard.needToResume = true; - await resumeConnection(data, payload, shard.id); - heartbeating.delete(shard.id); - return; - } - - if (heartbeating.has(shard.id)) { - const receivedACK = heartbeating.get(shard.id); - // If a ACK response was not received since last heartbeat, issue invalid session close - if (!receivedACK) { - eventHandlers.debug?.( - { - type: "gatewayHeartbeatStopped", - data: { - interval, - previousSequenceNumber: shard.previousSequenceNumber, - shardId: shard.id, - }, - }, - ); - - return shard.ws.close(4009, "Session timed out"); - } - } - - // Set it to false as we are issuing a new heartbeat - heartbeating.set(shard.id, false); - - sendWS( - { op: DiscordGatewayOpcodes.Heartbeat, d: shard.previousSequenceNumber }, - shard.id, - ); - eventHandlers.debug?.( - { - type: "gatewayHeartbeat", - data: { - interval, - previousSequenceNumber: shard.previousSequenceNumber, - shardId: shard.id, - }, - }, - ); - await delay(interval); - await heartbeat(shard, interval, payload, data); -} - -async function resumeConnection( - data: DiscordGetGatewayBot, - payload: DiscordIdentify, - shardId: number, -) { - const shard = basicShards.get(shardId); - if (!shard) { - eventHandlers.debug?.( - { type: "missingShard", data: { shardId: shardId } }, - ); - return; - } - - if (!shard.needToResume) return; - - eventHandlers.debug?.({ type: "gatewayResume", data: { shardId: shard.id } }); - // Run it once - createShard(data, payload, true, shard.id); - // Then retry every 15 seconds - await delay(1000 * 15); - if (shard.needToResume) await resumeConnection(data, payload, shardId); -} - -export async function requestGuildMembers( - guildId: string, - shardId: number, - nonce: string, - options?: FetchMembersOptions, - queuedRequest = false, -) { - const shard = basicShards.get(shardId); - - // This request was not from this queue so we add it to queue first - if (!queuedRequest) { - RequestMembersQueue.push({ - guildId, - shardId, - nonce, - options, - }); - - if (!processQueue) { - processQueue = true; - return processGatewayQueue(); - } - return; - } - - // If its closed add back to queue to redo on resume - if (shard?.ws.readyState === WebSocket.CLOSED) { - await requestGuildMembers(guildId, shardId, nonce, options); - return; - } - - sendWS({ - op: DiscordGatewayOpcodes.RequestGuildMembers, - d: { - guild_id: guildId, - // If a query is provided use it, OR if a limit is NOT provided use "" - query: options?.query || (options?.limit ? undefined : ""), - limit: options?.limit || 0, - presences: options?.presences || false, - user_ids: options?.userIds, - nonce, - }, - }, shard?.id); -} - -async function processGatewayQueue() { - if (!RequestMembersQueue.length) { - processQueue = false; - return; - } - - await Promise.all(basicShards.map(async (shard) => { - const index = RequestMembersQueue.findIndex((q) => q.shardId === shard.id); - // 2 events per second is the rate limit. - const request = RequestMembersQueue[index]; - if (request) { - eventHandlers.debug?.( - { - type: "requestMembersProcessing", - data: { - remaining: RequestMembersQueue.length, - request, - }, - }, - ); - await requestGuildMembers( - request.guildId, - request.shardId, - request.nonce, - request.options, - true, - ); - // Remove item from queue - RequestMembersQueue.splice(index, 1); - - const secondIndex = RequestMembersQueue.findIndex((q) => - q.shardId === shard.id - ); - const secondRequest = RequestMembersQueue[secondIndex]; - if (secondRequest) { - eventHandlers.debug?.( - { - type: "requestMembersProcessing", - data: { - remaining: RequestMembersQueue.length, - request, - }, - }, - ); - await requestGuildMembers( - secondRequest.guildId, - secondRequest.shardId, - secondRequest.nonce, - secondRequest.options, - true, - ); - // Remove item from queue - RequestMembersQueue.splice(secondIndex, 1); - } - } - })); - - await delay(1500); - - await processGatewayQueue(); -} - -/** Enqueues the specified data to be transmitted to the server over the WebSocket connection, */ -export function sendWS(payload: DiscordGatewayPayload, shardId = 0) { - const shard = basicShards.get(shardId); - if (!shard) return false; - - const serialized = JSON.stringify(payload); - shard.ws.send(serialized); - - return true; -} - -/** Closes the WebSocket connection or connection attempt */ -export function closeWS(shardId = 0) { - const shard = basicShards.get(shardId); - if (!shard) return false; - - shard.ws.close(3069, "[discordeno] requested closure"); - - return true; -} diff --git a/src/ws/start_gateway.ts b/src/ws/start_gateway.ts index 6efbb9e94..72b3149ec 100644 --- a/src/ws/start_gateway.ts +++ b/src/ws/start_gateway.ts @@ -22,9 +22,13 @@ export async function startGateway(options: StartGatewayOptions) { setInterval(ws.resharder, 1000 * 60 * 60); ws.identifyPayload.intents = options.intents.reduce( - (bits, next) => - (bits |= typeof next === "string" ? DiscordGatewayIntents[next] : next), - 0 + ( + bits, + next, + ) => (bits |= typeof next === "string" + ? DiscordGatewayIntents[next] + : next), + 0, ); const data = (await fetch(`https://discord.com/api/gateway/bot`, { diff --git a/src/ws/tell_cluster_to_identify.ts b/src/ws/tell_cluster_to_identify.ts index 7ef75968a..2e211b959 100644 --- a/src/ws/tell_cluster_to_identify.ts +++ b/src/ws/tell_cluster_to_identify.ts @@ -4,7 +4,7 @@ import { ws } from "./ws.ts"; export async function tellClusterToIdentify( workerID: number, shardID: number, - bucketID: number + bucketID: number, ) { // When resharding this may exist already const oldShard = ws.shards.get(shardID); diff --git a/src/ws/ws.ts b/src/ws/ws.ts index 728855e14..76ae2307a 100644 --- a/src/ws/ws.ts +++ b/src/ws/ws.ts @@ -1,19 +1,15 @@ -import { Collection } from "../../util/collection.ts"; -import { - cleanupLoadingShards, - spawnShards, - startGateway, - tellClusterToIdentify, -} from "./manager.ts"; -import { - createShard, - handleDiscordPayload, - handleOnMessage, - heartbeat, - identify, -} from "./shard.ts"; +import { Collection } from "../util/collection.ts"; import { log } from "./events.ts"; import { resharder } from "./resharder.ts"; +import { startGateway } from "./start_gateway.ts"; +import { spawnShards } from "./spawn_shards.ts"; +import { createShard } from "./create_shard.ts"; +import { identify } from "./identify.ts"; +import { heartbeat } from "./heartbeat.ts"; +import { handleDiscordPayload } from "./handle_discord_payload.ts"; +import { tellClusterToIdentify } from "./tell_cluster_to_identify.ts"; +import { cleanupLoadingShards } from "./cleanup_loading_shards.ts"; +import { handleOnMessage } from "./handle_on_message.ts"; // CONTROLLER LIKE INTERFACE FOR WS HANDLING export const ws = { From 57540567a5336da07a7bd188866e8a4f004a3204 Mon Sep 17 00:00:00 2001 From: Skillz4Killz <23035000+Skillz4Killz@users.noreply.github.com> Date: Sun, 4 Apr 2021 10:59:22 -0400 Subject: [PATCH 34/34] chore: removes random util (#752) --- mod.ts | 1 - src/util/random.ts | 3 --- 2 files changed, 4 deletions(-) delete mode 100644 src/util/random.ts diff --git a/mod.ts b/mod.ts index 406dc41ab..a7ece14d0 100644 --- a/mod.ts +++ b/mod.ts @@ -14,5 +14,4 @@ export * from "./src/util/collection.ts"; export * from "./src/util/constants.ts"; export * from "./src/util/permissions.ts"; export * from "./src/util/utils.ts"; -export * from "./src/util/random.ts"; export * from "./src/ws/mod.ts"; diff --git a/src/util/random.ts b/src/util/random.ts deleted file mode 100644 index 1ec366baa..000000000 --- a/src/util/random.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function chooseRandom(array: T[]) { - return array[Math.floor(Math.random() * array.length)]; -}