diff --git a/rome.json b/biome.json similarity index 88% rename from rome.json rename to biome.json index f0db417..becafb4 100644 --- a/rome.json +++ b/biome.json @@ -10,15 +10,12 @@ "suspicious": { "noExplicitAny": "off", "noAssignInExpressions": "off" - }, - "nursery": { - "useCamelCase": "off" } } }, "formatter": { "enabled": true, - "indentSize": 2, + "indentWidth": 2, "indentStyle": "space", "lineWidth": 140, "formatWithErrors": true diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..55716bb Binary files /dev/null and b/bun.lockb differ diff --git a/package.json b/package.json index 04c550b..6a004e6 100644 --- a/package.json +++ b/package.json @@ -1,70 +1,70 @@ { - "name": "@biscuitland/core", - "workspaces": [ - "packages/*" - ], - "scripts": { - "build": "turbo run build", - "clean": "turbo run clean", - "check": "rome check ./packages/", - "check:apply": "rome check --apply --max-diagnostics 200 --quote-style single --trailing-comma none ./packages/", - "lint": "rome format --write --quote-style single --trailing-comma none ./packages/", - "dev": "turbo run dev --parallel" - }, - "engines": { - "npm": ">=7.0.0", - "node": ">=16.0.0" - }, - "devDependencies": { - "@changesets/cli": "^2.24.1", - "@types/node": "^18.6.3", - "rome": "^12.0.0", - "turbo": "^1.4.2", - "typescript": "^5.0.4" - }, - "packageManager": "npm@8.19.4", - "bugs": { - "url": "https://github.com/oasisjs/biscuit" - }, - "keywords": [ - "api", - "discord", - "bots", - "typescript", - "botdev" - ], - "license": "Apache-2.0", - "author": "Yuzuru ", - "contributors": [ - { - "name": "Yuzuru", - "url": "https://github.com/yuzudev", - "author": true - }, - { - "name": "miia", - "url": "https://github.com/dragurimu" - }, - { - "name": "n128", - "url": "https://github.com/nicolito128" - }, - { - "name": "socram03", - "url": "https://github.com/socram03" - }, - { - "name": "Drylozu", - "url": "https://github.com/Drylozu" - }, - { - "name": "FreeAoi", - "url": "https://github.com/FreeAoi" - } - ], - "homepage": "https://biscuitjs.com", - "repository": { - "type": "git", - "url": "git+https://github.com/oasisjs/biscuit.git" - } + "name": "@biscuitland/core", + "workspaces": [ + "packages/*" + ], + "scripts": { + "build": "turbo run build", + "clean": "turbo run clean", + "check": "biome check ./packages/", + "check:apply": "biome check --apply --max-diagnostics 200 --quote-style single --trailing-comma none ./packages/", + "lint": "biome format --write --quote-style single --trailing-comma none ./packages/", + "dev": "turbo run dev --parallel" + }, + "engines": { + "npm": ">=7.0.0", + "node": ">=16.0.0" + }, + "devDependencies": { + "@biomejs/biome": "^1.3.3", + "@changesets/cli": "^2.24.1", + "@types/node": "^18.6.3", + "turbo": "^1.4.2", + "typescript": "^5.0.4" + }, + "packageManager": "npm@8.19.4", + "bugs": { + "url": "https://github.com/oasisjs/biscuit" + }, + "keywords": [ + "api", + "discord", + "bots", + "typescript", + "botdev" + ], + "license": "Apache-2.0", + "author": "Yuzuru ", + "contributors": [ + { + "name": "Yuzuru", + "url": "https://github.com/yuzudev", + "author": true + }, + { + "name": "miia", + "url": "https://github.com/dragurimu" + }, + { + "name": "n128", + "url": "https://github.com/nicolito128" + }, + { + "name": "socram03", + "url": "https://github.com/socram03" + }, + { + "name": "Drylozu", + "url": "https://github.com/Drylozu" + }, + { + "name": "FreeAoi", + "url": "https://github.com/FreeAoi" + } + ], + "homepage": "https://biscuitjs.com", + "repository": { + "type": "git", + "url": "git+https://github.com/oasisjs/biscuit.git" + } } diff --git a/packages/common/src/Collection.ts b/packages/common/src/Collection.ts index bb15a2a..48bf42b 100644 --- a/packages/common/src/Collection.ts +++ b/packages/common/src/Collection.ts @@ -6,7 +6,7 @@ export class Collection extends Map { */ maxSize: number | undefined; /** Handler to remove items from the collection every so often. */ - sweeper: (CollectionSweeper & { intervalId?: NodeJS.Timer }) | undefined; + sweeper: (CollectionSweeper & { intervalId?: NodeJS.Timeout }) | undefined; constructor(entries?: (ReadonlyArray | null) | Map, options?: CollectionOptions) { super(entries ?? []); @@ -23,6 +23,7 @@ export class Collection extends Map { this.sweeper = options; this.sweeper.intervalId = setInterval(() => { + // biome-ignore lint/complexity/noForEach: this.forEach((value, key) => { if (!this.sweeper?.filter(value, key)) return; @@ -98,6 +99,7 @@ export class Collection extends Map { /** Find all elements in this collection that match the given pattern. */ filter(callback: (value: V, key: K) => boolean): Collection { const relevant = new Collection(); + // biome-ignore lint/complexity/noForEach: this.forEach((value, key) => { if (callback(value, key)) relevant.set(key, value); }); diff --git a/packages/common/src/Util.ts b/packages/common/src/Util.ts index 65e2910..042620d 100644 --- a/packages/common/src/Util.ts +++ b/packages/common/src/Util.ts @@ -1,51 +1,27 @@ -import { setTimeout } from "node:timers/promises"; -import { ObjectToLower, ObjectToSnake } from "./Types"; +import { setTimeout } from 'node:timers/promises'; +import { ObjectToLower, ObjectToSnake } from './Types'; -const isPlainObject = (value: any) => { - return ( - (value !== null && - typeof value === "object" && - typeof value.constructor === "function" && - // rome-ignore lint/suspicious/noPrototypeBuiltins: js tricks - (value.constructor.prototype.hasOwnProperty("isPrototypeOf") || Object.getPrototypeOf(value.constructor.prototype) === null)) || - (value !== undefined && Object.getPrototypeOf(value) === null) +export function isObject(o: any) { + return o && typeof o === 'object' && !Array.isArray(o); +} + +export function Options(defaults: any, ...options: any[]): T { + const option = options.shift(); + if (!option) return defaults; + + return Options( + { + ...option, + ...Object.fromEntries( + Object.entries(defaults).map(([key, value]) => [ + key, + isObject(value) ? Options(value, option?.[key] || {}) : option?.[key] || value + ]) + ) + }, + ...options ); -}; - -const isObject = (o: any) => { - return !!o && typeof o === "object" && !Array.isArray(o); -}; - -export const Options = (defaults: any, ...options: any[]): T => { - if (!options.length) { - return defaults; - } - - const source = options.shift(); - - // This prevents default options from being intercepted by `Object.assign` - const $ = { ...defaults }; - - if (isObject($) && isPlainObject(source)) { - Object.entries(source).forEach(([key, value]) => { - if (typeof value === "undefined") { - return; - } - - if (isPlainObject(value)) { - if (!(key in $)) { - Object.assign($, { [key]: {} }); - } - - Options($[key], value); - } else { - Object.assign($, { [key]: value }); - } - }); - } - - return Options($, ...options); -}; +} /** * Convert a camelCase object to snake_case. * @param target The object to convert. @@ -55,18 +31,18 @@ export function toSnakeCase>(target: Obj): Objec const result = {}; for (const [key, value] of Object.entries(target)) { switch (typeof value) { - case "string": - case "bigint": - case "boolean": - case "function": - case "number": - case "symbol": - case "undefined": + case 'string': + case 'bigint': + case 'boolean': + case 'function': + case 'number': + case 'symbol': + case 'undefined': result[ReplaceRegex.snake(key)] = value; break; - case "object": + case 'object': if (Array.isArray(value)) { - result[ReplaceRegex.snake(key)] = value.map((prop) => (typeof prop === "object" && prop ? toSnakeCase(prop) : prop)); + result[ReplaceRegex.snake(key)] = value.map((prop) => (typeof prop === 'object' && prop ? toSnakeCase(prop) : prop)); break; } if (isObject(value)) { @@ -93,18 +69,18 @@ export function toCamelCase>(target: Obj): Objec const result = {}; for (const [key, value] of Object.entries(target)) { switch (typeof value) { - case "string": - case "bigint": - case "boolean": - case "function": - case "symbol": - case "number": - case "undefined": + case 'string': + case 'bigint': + case 'boolean': + case 'function': + case 'symbol': + case 'number': + case 'undefined': result[ReplaceRegex.camel(key)] = value; break; - case "object": + case 'object': if (Array.isArray(value)) { - result[ReplaceRegex.camel(key)] = value.map((prop) => (typeof prop === "object" && prop ? toCamelCase(prop) : prop)); + result[ReplaceRegex.camel(key)] = value.map((prop) => (typeof prop === 'object' && prop ? toCamelCase(prop) : prop)); break; } if (isObject(value)) { @@ -128,7 +104,7 @@ export const ReplaceRegex = { }, snake: (s: string) => { return s.replace(/[A-Z]/g, (a) => `_${a.toLowerCase()}`); - }, + } }; // https://github.com/discordeno/discordeno/blob/main/packages/utils/src/colors.ts @@ -168,9 +144,9 @@ export function getColorEnabled(): boolean { */ function code(open: number[], close: number): Code { return { - open: `\x1b[${open.join(";")}m`, + open: `\x1b[${open.join(';')}m`, close: `\x1b[${close}m`, - regexp: new RegExp(`\\x1b\\[${close}m`, "g"), + regexp: new RegExp(`\\x1b\\[${close}m`, 'g') }; } @@ -559,7 +535,7 @@ export function bgRgb8(str: string, color: number): string { * @param color code */ export function rgb24(str: string, color: number | Rgb): string { - if (typeof color === "number") { + if (typeof color === 'number') { return run(str, code([38, 2, (color >> 16) & 0xff, (color >> 8) & 0xff, color & 0xff], 39)); } return run(str, code([38, 2, clampAndTruncate(color.r), clampAndTruncate(color.g), clampAndTruncate(color.b)], 39)); @@ -581,7 +557,7 @@ export function rgb24(str: string, color: number | Rgb): string { * @param color code */ export function bgRgb24(str: string, color: number | Rgb): string { - if (typeof color === "number") { + if (typeof color === 'number') { return run(str, code([48, 2, (color >> 16) & 0xff, (color >> 8) & 0xff, color & 0xff], 49)); } return run(str, code([48, 2, clampAndTruncate(color.r), clampAndTruncate(color.g), clampAndTruncate(color.b)], 49)); @@ -590,10 +566,10 @@ export function bgRgb24(str: string, color: number | Rgb): string { // https://github.com/chalk/ansi-regex/blob/02fa893d619d3da85411acc8fd4e2eea0e95a9d9/index.js const ANSI_PATTERN = new RegExp( [ - "[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)", - "(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))", - ].join("|"), - "g", + '[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)', + '(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))' + ].join('|'), + 'g' ); /** @@ -601,9 +577,9 @@ const ANSI_PATTERN = new RegExp( * @param string to remove ANSI escape codes from */ export function stripColor(string: string): string { - return string.replace(ANSI_PATTERN, ""); + return string.replace(ANSI_PATTERN, ''); } -export function delay(time: number, result?: T) { +export function delay(time: number, result?: T): Promise { return setTimeout(time, result); } diff --git a/packages/core/src/events/handler.ts b/packages/core/src/events/handler.ts index 76b74a3..ff1c4da 100644 --- a/packages/core/src/events/handler.ts +++ b/packages/core/src/events/handler.ts @@ -1,11 +1,11 @@ -import type { GatewayEvents } from "@biscuitland/ws"; -import type { Session } from "../index"; +import type { GatewayEvents } from '@biscuitland/ws'; +import type { Session } from '../index'; export function actionHandler([session, payload, shardId]: Parameters) { // @ts-expect-error At this point, typescript sucks session.emit(payload.t, payload.d, shardId); // @ts-expect-error At this point, typescript sucks - session.emit("RAW", payload.d, shardId); + session.emit('RAW', payload.d, shardId); } export type ActionHandler = ( diff --git a/packages/core/src/managers/ChannelManager.ts b/packages/core/src/managers/ChannelManager.ts index 85229ce..5dfa652 100644 --- a/packages/core/src/managers/ChannelManager.ts +++ b/packages/core/src/managers/ChannelManager.ts @@ -1,23 +1,23 @@ import type { APIChannel, - RESTPostAPIChannelMessageJSONBody, - RESTPatchAPIChannelJSONBody, - RESTGetAPIChannelThreadsArchivedQuery, RESTGetAPIChannelMessageReactionUsersQuery, + RESTGetAPIChannelThreadMemberQuery, + RESTGetAPIChannelThreadMembersQuery, + RESTGetAPIChannelThreadsArchivedQuery, + RESTPatchAPIChannelJSONBody, RESTPatchAPIChannelMessageJSONBody, - RESTPostAPIChannelMessagesBulkDeleteJSONBody, - RESTPutAPIChannelPermissionJSONBody, - RESTPostAPIChannelInviteJSONBody, + RESTPatchAPIStageInstanceJSONBody, RESTPostAPIChannelFollowersJSONBody, - RESTPutAPIChannelRecipientJSONBody, + RESTPostAPIChannelInviteJSONBody, + RESTPostAPIChannelMessageJSONBody, + RESTPostAPIChannelMessagesBulkDeleteJSONBody, RESTPostAPIChannelMessagesThreadsJSONBody, RESTPostAPIChannelThreadsJSONBody, RESTPostAPIChannelThreadsResult, - RESTPostAPIGuildForumThreadsJSONBody, - RESTGetAPIChannelThreadMembersQuery, - RESTGetAPIChannelThreadMemberQuery, RESTPostAPIChannelWebhookJSONBody, - RESTPatchAPIStageInstanceJSONBody + RESTPostAPIGuildForumThreadsJSONBody, + RESTPutAPIChannelPermissionJSONBody, + RESTPutAPIChannelRecipientJSONBody } from '@biscuitland/common'; import type { RawFile } from '@biscuitland/rest'; diff --git a/packages/core/src/managers/GuildManager.ts b/packages/core/src/managers/GuildManager.ts index 8a42c18..55db2e0 100644 --- a/packages/core/src/managers/GuildManager.ts +++ b/packages/core/src/managers/GuildManager.ts @@ -1,45 +1,45 @@ -import type { Session } from '../session'; import type { - GuildMFALevel, APIGuildChannel, GuildChannelType, - RESTPostAPIGuildPruneJSONBody, - RESTPostAPIGuildsJSONBody, - RESTPatchAPIGuildJSONBody, - RESTPostAPIGuildChannelJSONBody, - RESTPatchAPIGuildChannelPositionsJSONBody, - RESTGetAPIGuildBansQuery, - RESTPutAPIGuildBanJSONBody, - RESTPostAPIGuildRoleJSONBody, - RESTPatchAPIGuildRolePositionsJSONBody, - RESTPatchAPIGuildRoleJSONBody, - RESTPatchAPIGuildWidgetSettingsJSONBody, - RESTPatchAPIGuildWelcomeScreenJSONBody, - RESTGetAPIGuildPruneCountQuery, + GuildMFALevel, RESTGetAPIAuditLogQuery, - RESTPostAPIAutoModerationRuleJSONBody, - RESTPatchAPIAutoModerationRuleJSONBody, - RESTPostAPITemplateCreateGuildJSONBody, + RESTGetAPIGuildBansQuery, RESTGetAPIGuildMembersQuery, RESTGetAPIGuildMembersSearchQuery, - RESTPatchAPICurrentGuildMemberJSONBody, - RESTPutAPIGuildMemberJSONBody, - RESTPatchAPIGuildMemberJSONBody, - RESTGetAPIGuildWidgetImageQuery, - RESTPatchAPIGuildEmojiJSONBody, - RESTPostAPIGuildEmojiJSONBody, - RESTPatchAPIGuildVoiceStateUserJSONBody, - RESTPatchAPIGuildVoiceStateCurrentMemberJSONBody, - RESTPatchAPIGuildStickerJSONBody, - RESTPostAPIGuildStickerFormDataBody, - RESTGetAPIGuildScheduledEventsQuery, - RESTPatchAPIGuildScheduledEventJSONBody, - RESTPostAPIGuildScheduledEventJSONBody, + RESTGetAPIGuildPruneCountQuery, RESTGetAPIGuildScheduledEventQuery, RESTGetAPIGuildScheduledEventUsersQuery, + RESTGetAPIGuildScheduledEventsQuery, + RESTGetAPIGuildWidgetImageQuery, + RESTPatchAPIAutoModerationRuleJSONBody, + RESTPatchAPICurrentGuildMemberJSONBody, + RESTPatchAPIGuildChannelPositionsJSONBody, + RESTPatchAPIGuildEmojiJSONBody, + RESTPatchAPIGuildJSONBody, + RESTPatchAPIGuildMemberJSONBody, + RESTPatchAPIGuildRoleJSONBody, + RESTPatchAPIGuildRolePositionsJSONBody, + RESTPatchAPIGuildScheduledEventJSONBody, + RESTPatchAPIGuildStickerJSONBody, RESTPatchAPIGuildTemplateJSONBody, - RESTPostAPIGuildTemplatesJSONBody + RESTPatchAPIGuildVoiceStateCurrentMemberJSONBody, + RESTPatchAPIGuildVoiceStateUserJSONBody, + RESTPatchAPIGuildWelcomeScreenJSONBody, + RESTPatchAPIGuildWidgetSettingsJSONBody, + RESTPostAPIAutoModerationRuleJSONBody, + RESTPostAPIGuildChannelJSONBody, + RESTPostAPIGuildEmojiJSONBody, + RESTPostAPIGuildPruneJSONBody, + RESTPostAPIGuildRoleJSONBody, + RESTPostAPIGuildScheduledEventJSONBody, + RESTPostAPIGuildStickerFormDataBody, + RESTPostAPIGuildTemplatesJSONBody, + RESTPostAPIGuildsJSONBody, + RESTPostAPITemplateCreateGuildJSONBody, + RESTPutAPIGuildBanJSONBody, + RESTPutAPIGuildMemberJSONBody } from '@biscuitland/common'; +import type { Session } from '../session'; export class GuildManager { readonly session!: Session; diff --git a/packages/core/src/managers/InteractionManager.ts b/packages/core/src/managers/InteractionManager.ts index 03759ec..46d0529 100644 --- a/packages/core/src/managers/InteractionManager.ts +++ b/packages/core/src/managers/InteractionManager.ts @@ -3,8 +3,8 @@ import type { RESTPostAPIInteractionCallbackJSONBody, RESTPostAPIInteractionFollowupJSONBody } from '@biscuitland/common'; -import type { Session } from '..'; import type { RawFile } from '@biscuitland/rest'; +import type { Session } from '..'; export class InteractionManager { readonly session!: Session; diff --git a/packages/core/src/managers/MemberManager.ts b/packages/core/src/managers/MemberManager.ts index 53fcd5c..a588184 100644 --- a/packages/core/src/managers/MemberManager.ts +++ b/packages/core/src/managers/MemberManager.ts @@ -1,6 +1,6 @@ -import type { APIGuildMember, MakeRequired } from "@biscuitland/common"; -import type { ImageOptions, Session } from "../index"; -import { formatImageURL } from "../index"; +import type { APIGuildMember, MakeRequired } from '@biscuitland/common'; +import type { ImageOptions, Session } from '../index'; +import { formatImageURL } from '../index'; export class MemberManager { constructor(private readonly session: Session) {} @@ -14,6 +14,6 @@ export class MemberManager { } } -export type DynamicMember = MakeRequired & { +export type DynamicMember = MakeRequired & { guild_id: string; }; diff --git a/packages/core/src/managers/UserManager.ts b/packages/core/src/managers/UserManager.ts index bb8f8f2..d0a9c7c 100644 --- a/packages/core/src/managers/UserManager.ts +++ b/packages/core/src/managers/UserManager.ts @@ -2,21 +2,21 @@ import type { APIUser, RESTGetAPICurrentUserGuildsQuery, RESTPatchAPICurrentUserJSONBody, - RESTPutAPICurrentUserApplicationRoleConnectionJSONBody, -} from "@biscuitland/common"; -import type { ImageOptions, Session } from "../index"; -import { formatImageURL } from "../index"; + RESTPutAPICurrentUserApplicationRoleConnectionJSONBody +} from '@biscuitland/common'; +import type { ImageOptions, Session } from '../index'; +import { formatImageURL } from '../index'; export class UserManager { readonly session!: Session; constructor(session: Session) { - Object.defineProperty(this, "session", { + Object.defineProperty(this, 'session', { value: session, - writable: false, + writable: false }); } - get(userId = "@me") { + get(userId = '@me') { return this.session.api.users(userId).get(); } @@ -29,36 +29,36 @@ export class UserManager { } createDM(userId: string) { - return this.session.api.users("@me").channels.post({ body: { recipient_id: userId } }); + return this.session.api.users('@me').channels.post({ body: { recipient_id: userId } }); } editCurrent(body: RESTPatchAPICurrentUserJSONBody) { - return this.session.api.users("@me").patch({ - body, + return this.session.api.users('@me').patch({ + body }); } getGuilds(query?: RESTGetAPICurrentUserGuildsQuery) { - return this.session.api.users("@me").guilds.get({ query }); + return this.session.api.users('@me').guilds.get({ query }); } getGuildMember(guildId: string) { - return this.session.api.users("@me").guilds(guildId).member.get(); + return this.session.api.users('@me').guilds(guildId).member.get(); } leaveGuild(guildId: string) { - return this.session.api.users("@me").guilds(guildId).delete(); + return this.session.api.users('@me').guilds(guildId).delete(); } getConnections() { - return this.session.api.users("@me").connections.get(); + return this.session.api.users('@me').connections.get(); } getRoleConnections(applicationId: string) { - return this.session.api.users("@me").applications(applicationId)["role-connection"].get(); + return this.session.api.users('@me').applications(applicationId)['role-connection'].get(); } updateRoleConnection(applicationId: string, body: RESTPutAPICurrentUserApplicationRoleConnectionJSONBody) { - return this.session.api.users("@me").applications(applicationId)["role-connection"].put({ body }); + return this.session.api.users('@me').applications(applicationId)['role-connection'].put({ body }); } } diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index d8147fa..7ca9f11 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -1,10 +1,10 @@ -import { GatewayIntentBits, Identify, When } from "@biscuitland/common"; -import type { BiscuitRESTOptions, CDNRoutes, Routes } from "@biscuitland/rest"; -import { BiscuitREST, CDN, Router } from "@biscuitland/rest"; -import { GatewayEvents, GatewayManager, GatewayManagerOptions } from "@biscuitland/ws"; -import EventEmitter2 from "eventemitter2"; -import { MainManager, getBotIdFromToken } from "."; -import { Handler, actionHandler } from "./events/handler"; +import { GatewayIntentBits, Identify, When } from '@biscuitland/common'; +import type { BiscuitRESTOptions, CDNRoutes, Routes } from '@biscuitland/rest'; +import { BiscuitREST, CDN, Router } from '@biscuitland/rest'; +import { GatewayEvents, ShardManager, ShardManagerOptions } from '@biscuitland/ws'; +import EventEmitter2 from 'eventemitter2'; +import { MainManager, getBotIdFromToken } from '.'; +import { Handler, actionHandler } from './events/handler'; export class Session extends EventEmitter2 { constructor(public options: BiscuitOptions) { @@ -18,7 +18,7 @@ export class Session extends EventEmitter2 { api: Routes; cdn: CDNRoutes; managers: MainManager; - gateway!: When; + gateway!: When; private _applicationId?: string; private _botId?: string; @@ -67,7 +67,7 @@ export class Session extends EventEmitter2 { if (!rest) { return new BiscuitREST({ token: this.options.token, - ...this.options.defaultRestOptions, + ...this.options.defaultRestOptions }); } @@ -75,14 +75,14 @@ export class Session extends EventEmitter2 { return rest; } - throw new Error("[CORE] REST not found"); + throw new Error('[CORE] REST not found'); } async start() { // alias fixed `this` on handlePayload const ctx = this as Session; - ctx.gateway = new GatewayManager({ + ctx.gateway = new ShardManager({ token: this.options.token, intents: this.options.intents ?? 0, info: this.options.defaultGatewayOptions?.info ?? (await this.api.gateway.bot.get()), @@ -92,10 +92,10 @@ export class Session extends EventEmitter2 { // @ts-expect-error actionHandler([ctx, { t, d }, shard]); }, - ...this.options.defaultGatewayOptions, + ...this.options.defaultGatewayOptions }); - ctx.once("READY", (payload) => { + ctx.once('READY', (payload) => { const { user, application } = payload; this.botId = user.id; this.applicationId = application.id; @@ -110,12 +110,12 @@ export class Session extends EventEmitter2 { } } -export type HandlePayload = Pick["handlePayload"]; +export type HandlePayload = Pick['handlePayload']; export interface BiscuitOptions { token: string; intents: number | GatewayIntentBits; rest?: BiscuitREST; defaultRestOptions?: Partial; - defaultGatewayOptions?: Identify>>; + defaultGatewayOptions?: Identify>>; } diff --git a/packages/helpers/src/Collector.ts b/packages/helpers/src/Collector.ts index f612ffc..507922e 100644 --- a/packages/helpers/src/Collector.ts +++ b/packages/helpers/src/Collector.ts @@ -1,7 +1,7 @@ -import { MakeRequired, Options } from "@biscuitland/common"; -import { Handler, type Session } from "@biscuitland/core"; -import { GatewayEvents } from "@biscuitland/ws"; -import { EventEmitter } from "node:events"; +import { EventEmitter } from 'node:events'; +import { MakeRequired, Options } from '@biscuitland/common'; +import { Handler, type Session } from '@biscuitland/core'; +import { GatewayEvents } from '@biscuitland/ws'; interface CollectorOptions { event: `${E}`; @@ -13,19 +13,19 @@ interface CollectorOptions { export const DEFAULT_OPTIONS = { filter: () => true, - max: -1, + max: -1 }; export enum CollectorStatus { Idle = 0, Started = 1, - Ended = 2, + Ended = 2 } export class EventCollector extends EventEmitter { collected = new Set[0]>(); status: CollectorStatus = CollectorStatus.Idle; - options: MakeRequired, "filter" | "max">; + options: MakeRequired, 'filter' | 'max'>; private timeout: NodeJS.Timeout | null = null; constructor(readonly session: Session, rawOptions: CollectorOptions) { @@ -36,24 +36,24 @@ export class EventCollector extends EventEmitter start() { this.session.setMaxListeners(this.session.getMaxListeners() + 1); this.session.on(this.options.event, (...args: unknown[]) => this.collect(...(args as Parameters))); - this.timeout = setTimeout(() => this.stop("time"), this.options.idle ?? this.options.time); + this.timeout = setTimeout(() => this.stop('time'), this.options.idle ?? this.options.time); } private collect(...args: Parameters) { if (this.options.filter?.(...args)) { this.collected.add(args[0]); - this.emit("collect", ...args); + this.emit('collect', ...args); } if (this.options.idle) { if (this.timeout) clearTimeout(this.timeout); - this.timeout = setTimeout(() => this.stop("time"), this.options.idle); + this.timeout = setTimeout(() => this.stop('time'), this.options.idle); } - if (this.collected.size >= this.options.max!) this.stop("max"); + if (this.collected.size >= this.options.max!) this.stop('max'); } - stop(reason = "User stopped") { + stop(reason = 'User stopped') { if (this.status === CollectorStatus.Ended) return; if (this.timeout) clearTimeout(this.timeout); @@ -62,17 +62,17 @@ export class EventCollector extends EventEmitter this.session.setMaxListeners(this.session.getMaxListeners() - 1); this.status = CollectorStatus.Ended; - this.emit("end", reason, this.collected); + this.emit('end', reason, this.collected); } - on(event: "collect", listener: (...args: Parameters) => unknown): this; - on(event: "end", listener: (reason: string | null | undefined, collected: Set[0]>) => void): this; + on(event: 'collect', listener: (...args: Parameters) => unknown): this; + on(event: 'end', listener: (reason: string | null | undefined, collected: Set[0]>) => void): this; on(event: string, listener: unknown): this { return super.on(event, listener as () => unknown); } - once(event: "collect", listener: (...args: Parameters) => unknown): this; - once(event: "end", listener: (reason: string | null | undefined, collected: Set[0]>) => void): this; + once(event: 'collect', listener: (...args: Parameters) => unknown): this; + once(event: 'end', listener: (reason: string | null | undefined, collected: Set[0]>) => void): this; once(event: string, listener: unknown): this { return super.once(event, listener as () => unknown); } diff --git a/packages/helpers/src/MessageEmbed.ts b/packages/helpers/src/MessageEmbed.ts index 414c64f..f7352b1 100644 --- a/packages/helpers/src/MessageEmbed.ts +++ b/packages/helpers/src/MessageEmbed.ts @@ -1,4 +1,4 @@ -import { APIEmbed, APIEmbedAuthor, APIEmbedField, APIEmbedFooter, ObjectToLower, TypeArray, toSnakeCase } from "@biscuitland/common"; +import { APIEmbed, APIEmbedAuthor, APIEmbedField, APIEmbedFooter, ObjectToLower, TypeArray, toSnakeCase } from '@biscuitland/common'; export class MessageEmbed { constructor(public data: Partial = {}) { diff --git a/packages/helpers/src/Permissions.ts b/packages/helpers/src/Permissions.ts index d9a0856..a5339a7 100644 --- a/packages/helpers/src/Permissions.ts +++ b/packages/helpers/src/Permissions.ts @@ -1,4 +1,4 @@ -import { PermissionFlagsBits } from "@biscuitland/common"; +import { PermissionFlagsBits } from '@biscuitland/common'; export type PermissionsStrings = keyof typeof PermissionFlagsBits; export type PermissionResolvable = bigint | PermissionsStrings | PermissionsStrings[] | PermissionsStrings | PermissionsStrings[]; @@ -89,17 +89,17 @@ export class Permissions { static resolve(bit: PermissionResolvable): bigint { switch (typeof bit) { - case "bigint": + case 'bigint': return bit; - case "number": + case 'number': return BigInt(bit); - case "string": + case 'string': return BigInt(Permissions.Flags[bit]); - case "object": + case 'object': return Permissions.resolve( bit - .map((p) => (typeof p === "string" ? BigInt(Permissions.Flags[p]) : BigInt(p))) - .reduce((acc, cur) => acc | cur, Permissions.None), + .map((p) => (typeof p === 'string' ? BigInt(Permissions.Flags[p]) : BigInt(p))) + .reduce((acc, cur) => acc | cur, Permissions.None) ); default: throw new TypeError(`Cannot resolve permission: ${bit}`); @@ -125,7 +125,7 @@ export class Permissions { } toJSON(): { fields: string[] } { - const fields = Object.keys(Permissions.Flags).filter((bit) => typeof bit === "number" && this.has(bit)); + const fields = Object.keys(Permissions.Flags).filter((bit) => typeof bit === 'number' && this.has(bit)); return { fields }; } diff --git a/packages/helpers/src/Utils.ts b/packages/helpers/src/Utils.ts index df0589b..4bc3e95 100644 --- a/packages/helpers/src/Utils.ts +++ b/packages/helpers/src/Utils.ts @@ -1,4 +1,4 @@ -import { APIMessageActionRowComponent, APIModalActionRowComponent, ComponentType } from "@biscuitland/common"; +import { APIMessageActionRowComponent, APIModalActionRowComponent, ComponentType } from '@biscuitland/common'; import { ChannelSelectMenu, MentionableSelectMenu, @@ -6,9 +6,9 @@ import { ModalTextInput, RoleSelectMenu, StringSelectMenu, - UserSelectMenu, -} from "./components"; -import { BaseComponent } from "./components/BaseComponent"; + UserSelectMenu +} from './components'; +import { BaseComponent } from './components/BaseComponent'; export function createComponent(data: APIMessageActionRowComponent): HelperComponents; export function createComponent(data: APIModalActionRowComponent): HelperComponents; diff --git a/packages/helpers/src/commands/contextMenu/ContextCommand.ts b/packages/helpers/src/commands/contextMenu/ContextCommand.ts index a60239d..7200efb 100644 --- a/packages/helpers/src/commands/contextMenu/ContextCommand.ts +++ b/packages/helpers/src/commands/contextMenu/ContextCommand.ts @@ -1,5 +1,5 @@ -import { ApplicationCommandType, LocalizationMap, RESTPostAPIContextMenuApplicationCommandsJSONBody } from "@biscuitland/common"; -import { PermissionResolvable, Permissions } from "../../Permissions"; +import { ApplicationCommandType, LocalizationMap, RESTPostAPIContextMenuApplicationCommandsJSONBody } from '@biscuitland/common'; +import { PermissionResolvable, Permissions } from '../../Permissions'; export type ContextCommandType = ApplicationCommandType.Message | ApplicationCommandType.User; diff --git a/packages/helpers/src/commands/index.ts b/packages/helpers/src/commands/index.ts index 973a0bb..47bc1e4 100644 --- a/packages/helpers/src/commands/index.ts +++ b/packages/helpers/src/commands/index.ts @@ -1,3 +1,3 @@ -export * from "./contextMenu/ContextCommand"; -export * from "./slash/SlashCommand"; -export * from "./slash/SlashCommandOption"; +export * from './contextMenu/ContextCommand'; +export * from './slash/SlashCommand'; +export * from './slash/SlashCommandOption'; diff --git a/packages/helpers/src/commands/slash/SlashCommand.ts b/packages/helpers/src/commands/slash/SlashCommand.ts index ced21cc..c79fe32 100644 --- a/packages/helpers/src/commands/slash/SlashCommand.ts +++ b/packages/helpers/src/commands/slash/SlashCommand.ts @@ -1,7 +1,7 @@ -import { ApplicationCommandType, RESTPostAPIChatInputApplicationCommandsJSONBody } from "@biscuitland/common"; -import { Mixin } from "ts-mixer"; -import { PermissionResolvable, Permissions } from "../../Permissions"; -import { AllSlashOptions, SlashSubcommandGroupOption, SlashSubcommandOption } from "./SlashCommandOption"; +import { ApplicationCommandType, RESTPostAPIChatInputApplicationCommandsJSONBody } from '@biscuitland/common'; +import { Mixin } from 'ts-mixer'; +import { PermissionResolvable, Permissions } from '../../Permissions'; +import { AllSlashOptions, SlashSubcommandGroupOption, SlashSubcommandOption } from './SlashCommandOption'; class SlashCommandB { constructor(public data: Partial = {}) {} @@ -33,7 +33,7 @@ class SlashCommandB { return this; } - addRawOption(option: ReturnType) { + addRawOption(option: ReturnType) { this.data.options ??= []; // @ts-expect-error discord-api-types bad typing, again this.data.options.push(option); @@ -42,7 +42,7 @@ class SlashCommandB { toJSON(): RESTPostAPIChatInputApplicationCommandsJSONBody { return { ...this.data, - type: ApplicationCommandType.ChatInput, + type: ApplicationCommandType.ChatInput } as RESTPostAPIChatInputApplicationCommandsJSONBody & { type: ApplicationCommandType.ChatInput; }; diff --git a/packages/helpers/src/commands/slash/SlashCommandOption.ts b/packages/helpers/src/commands/slash/SlashCommandOption.ts index 39f6ffd..f78b693 100644 --- a/packages/helpers/src/commands/slash/SlashCommandOption.ts +++ b/packages/helpers/src/commands/slash/SlashCommandOption.ts @@ -1,26 +1,26 @@ import { - APIApplicationCommandIntegerOption as AACIO, - APIApplicationCommandNumberOption as AACNO, - APIApplicationCommandSubcommandOption as AACSCO, - APIApplicationCommandSubcommandGroupOption as AACSGO, - APIApplicationCommandStringOption as AACSO, APIApplicationCommandAttachmentOption, APIApplicationCommandBooleanOption, APIApplicationCommandChannelOption, + APIApplicationCommandIntegerOption as AACIO, APIApplicationCommandMentionableOption, + APIApplicationCommandNumberOption as AACNO, APIApplicationCommandOption, APIApplicationCommandOptionBase, APIApplicationCommandOptionChoice, APIApplicationCommandRoleOption, + APIApplicationCommandStringOption as AACSO, + APIApplicationCommandSubcommandGroupOption as AACSGO, + APIApplicationCommandSubcommandOption as AACSCO, APIApplicationCommandUserOption, ApplicationCommandOptionType, ChannelType, LocalizationMap, RestToKeys, TypeArray, - When, -} from "@biscuitland/common"; -import { OptionValuesLength } from "../../"; + When +} from '@biscuitland/common'; +import { OptionValuesLength } from '../../'; export type SlashBaseOptionTypes = | Exclude @@ -53,7 +53,7 @@ export abstract class SlashBaseOption { return this; } - addLocalizations(locals: RestToKeys<[LocalizationMap, "name", "description"]>): this { + addLocalizations(locals: RestToKeys<[LocalizationMap, 'name', 'description']>): this { this.data.name_localizations = locals.name; this.data.description_localizations = locals.description; return this; @@ -102,7 +102,7 @@ export class SlashStringOption extends SlashRequir addChoices(choices: TypeArray>): SlashStringOption { const ctx = this as SlashStringOption; ctx.data.choices ??= []; - ctx.data.choices.concat(choices); + ctx.data.choices = ctx.data.choices!.concat(choices); return ctx; } @@ -148,7 +148,7 @@ export class SlashNumberOption extends SlashRequir addChoices(choices: TypeArray>): SlashNumberOption { const ctx = this as SlashNumberOption; ctx.data.choices ??= []; - ctx.data.choices.concat(choices); + ctx.data.choices = ctx.data.choices.concat(choices); return ctx; } @@ -333,7 +333,7 @@ export class SlashSubcommandGroupOption extends SlashBaseOption) { + addRawOption(option: ReturnType) { this.data.options ??= []; this.data.options.push(option); } diff --git a/packages/helpers/src/components/ActionRow.ts b/packages/helpers/src/components/ActionRow.ts index a46355b..dfe6f55 100644 --- a/packages/helpers/src/components/ActionRow.ts +++ b/packages/helpers/src/components/ActionRow.ts @@ -1,6 +1,6 @@ -import { APIActionRowComponent, APIMessageActionRowComponent, ComponentType, TypeArray } from "@biscuitland/common"; -import { MessageComponents, createComponent } from "../Utils"; -import { BaseComponent } from "./BaseComponent"; +import { APIActionRowComponent, APIMessageActionRowComponent, ComponentType, TypeArray } from '@biscuitland/common'; +import { MessageComponents, createComponent } from '../Utils'; +import { BaseComponent } from './BaseComponent'; export class MessageActionRow extends BaseComponent> { constructor({ components, ...data }: Partial> = {}) { @@ -10,7 +10,7 @@ export class MessageActionRow extends BaseComponent components: T[]; addComponents(component: TypeArray): this { - this.components.concat(component); + this.components = this.components.concat(component); return this; } @@ -22,7 +22,7 @@ export class MessageActionRow extends BaseComponent toJSON(): APIActionRowComponent { return { ...this.data, - components: this.components.map((c) => c.toJSON()), - } as APIActionRowComponent>; + components: this.components.map((c) => c.toJSON()) + } as APIActionRowComponent>; } } diff --git a/packages/helpers/src/components/BaseComponent.ts b/packages/helpers/src/components/BaseComponent.ts index 2bb8f09..c88dcf9 100644 --- a/packages/helpers/src/components/BaseComponent.ts +++ b/packages/helpers/src/components/BaseComponent.ts @@ -1,4 +1,4 @@ -import { APIBaseComponent, ComponentType } from "@biscuitland/common"; +import { APIBaseComponent, ComponentType } from '@biscuitland/common'; export abstract class BaseComponent> = APIBaseComponent,> { constructor(public data: Partial) {} diff --git a/packages/helpers/src/components/MessageButton.ts b/packages/helpers/src/components/MessageButton.ts index 98310b8..9c03f36 100644 --- a/packages/helpers/src/components/MessageButton.ts +++ b/packages/helpers/src/components/MessageButton.ts @@ -1,5 +1,5 @@ -import { APIButtonComponentBase, APIMessageComponentEmoji, ButtonStyle, ComponentType, When } from "@biscuitland/common"; -import { BaseComponent } from "./BaseComponent"; +import { APIButtonComponentBase, APIMessageComponentEmoji, ButtonStyle, ComponentType, When } from '@biscuitland/common'; +import { BaseComponent } from './BaseComponent'; export type ButtonStylesForID = Exclude; diff --git a/packages/helpers/src/components/SelectMenu.ts b/packages/helpers/src/components/SelectMenu.ts index 0a49894..2267dbc 100644 --- a/packages/helpers/src/components/SelectMenu.ts +++ b/packages/helpers/src/components/SelectMenu.ts @@ -73,7 +73,7 @@ export class StringSelectMenu extends SelectMenu { addOption(option: TypeArray): this { this.data.options ??= []; - this.data.options.concat(option); + this.data.options = this.data.options.concat(option); return this; } @@ -84,6 +84,7 @@ export class StringSelectMenu extends SelectMenu { } export class StringSelectOption { + // biome-ignore lint/nursery/noEmptyBlockStatements: constructor(public data: Partial = {}) {} setLabel(label: string): this { diff --git a/packages/helpers/src/components/TextInput.ts b/packages/helpers/src/components/TextInput.ts index abdb4e3..ade6b72 100644 --- a/packages/helpers/src/components/TextInput.ts +++ b/packages/helpers/src/components/TextInput.ts @@ -1,6 +1,6 @@ -import { APITextInputComponent, ComponentType, TextInputStyle } from "@biscuitland/common"; -import { OptionValuesLength } from ".."; -import { BaseComponent } from "./BaseComponent"; +import { APITextInputComponent, ComponentType, TextInputStyle } from '@biscuitland/common'; +import { OptionValuesLength } from '..'; +import { BaseComponent } from './BaseComponent'; export class ModalTextInput extends BaseComponent { constructor(data: Partial = {}) { diff --git a/packages/helpers/src/components/index.ts b/packages/helpers/src/components/index.ts index 554cf5e..f76ad29 100644 --- a/packages/helpers/src/components/index.ts +++ b/packages/helpers/src/components/index.ts @@ -1,5 +1,5 @@ -export * from "./ActionRow"; -export * from "./BaseComponent"; -export * from "./MessageButton"; -export * from "./SelectMenu"; -export * from "./TextInput"; +export * from './ActionRow'; +export * from './BaseComponent'; +export * from './MessageButton'; +export * from './SelectMenu'; +export * from './TextInput'; diff --git a/packages/helpers/src/index.ts b/packages/helpers/src/index.ts index fd99747..b6dd0e5 100644 --- a/packages/helpers/src/index.ts +++ b/packages/helpers/src/index.ts @@ -1,5 +1,5 @@ -export * from "./MessageEmbed"; -export * from "./Permissions"; -export * from "./Utils"; -export * from "./commands"; -export * from "./components"; +export * from './MessageEmbed'; +export * from './Permissions'; +export * from './Utils'; +export * from './commands'; +export * from './components'; diff --git a/packages/rest/src/REST.ts b/packages/rest/src/REST.ts index 8c0a978..9cf5ec0 100644 --- a/packages/rest/src/REST.ts +++ b/packages/rest/src/REST.ts @@ -1,8 +1,8 @@ +import type { Identify } from '@biscuitland/common'; import type { RawFile, RequestData } from '@discordjs/rest'; import { REST } from '@discordjs/rest'; -import type { Identify } from '@biscuitland/common'; import type { RequestMethod } from './Router'; -import { Routes } from './Routes'; + export class BiscuitREST { api: REST; constructor(public options: BiscuitRESTOptions) { @@ -72,12 +72,12 @@ export type RequestObject, Q = (M extends `${RequestMethod.Get}` ? unknown : { - body?: B; - files?: RawFile[]; - }); + body?: B; + files?: RawFile[]; + }); export type RestArguments = any> = M extends RequestMethod.Get ? Q extends never - ? RequestObject - : never + ? RequestObject + : never : RequestObject; diff --git a/packages/rest/src/Routes/gateway.ts b/packages/rest/src/Routes/gateway.ts index 68bf446..de46831 100644 --- a/packages/rest/src/Routes/gateway.ts +++ b/packages/rest/src/Routes/gateway.ts @@ -1,4 +1,4 @@ -import { RESTGetAPIGatewayResult, RESTGetAPIGatewayBotResult } from '@biscuitland/common'; +import { RESTGetAPIGatewayBotResult, RESTGetAPIGatewayResult } from '@biscuitland/common'; import { RestArguments } from '../REST'; import { RequestMethod } from '../Router'; diff --git a/packages/rest/src/index.ts b/packages/rest/src/index.ts index 747b44a..d39c3b6 100644 --- a/packages/rest/src/index.ts +++ b/packages/rest/src/index.ts @@ -1,5 +1,5 @@ -export * from './Routes/'; -export * from './Router'; -export * from './REST'; +export { REST, type RawFile } from '@discordjs/rest'; export * from './CDN'; -export { type RawFile, REST } from '@discordjs/rest'; +export * from './REST'; +export * from './Router'; +export * from './Routes'; diff --git a/packages/ws/README.md b/packages/ws/README.md index d61623d..959df29 100644 --- a/packages/ws/README.md +++ b/packages/ws/README.md @@ -23,7 +23,7 @@ yarn add @biscuitland/ws ## Example ```ts -import { GatewayManager } from "@biscuitland/ws"; +import { ShardManager } from "@biscuitland/ws"; import { BiscuitREST, Router } from "@biscuitland/rest"; import { GatewayIntentBits } from "@biscuitland/common"; @@ -36,7 +36,7 @@ const api = new Router(rest).createProxy(); const connection = await api.gateway.bot.get(); // gateway bot code ↓ - const ws = new GatewayManager({ + const ws = new ShardManager({ token, intents, connection, diff --git a/packages/ws/src/SharedTypes.ts b/packages/ws/src/SharedTypes.ts index 10fcd6a..e26a653 100644 --- a/packages/ws/src/SharedTypes.ts +++ b/packages/ws/src/SharedTypes.ts @@ -11,7 +11,6 @@ import type { GatewayAutoModerationActionExecutionDispatchData, GatewayChannelPinsUpdateDispatchData, GatewayChannelUpdateDispatchData, - GatewayDispatchEvents, GatewayGuildBanAddDispatchData, GatewayGuildBanRemoveDispatchData, GatewayGuildCreateDispatchData, @@ -56,13 +55,15 @@ import type { GatewayVoiceStateUpdateData, GatewayWebhooksUpdateDispatchData, PresenceUpdateStatus, - RestToKeys, -} from "@biscuitland/common"; + RestToKeys +} from '@biscuitland/common'; + +import { GatewayDispatchEvents } from '@biscuitland/common'; /** https://discord.com/developers/docs/topics/gateway-events#update-presence */ export interface StatusUpdate { /** The user's activities */ - activities?: Omit[]; + activities?: Omit[]; /** The user's new status */ status: PresenceUpdateStatus; } @@ -79,7 +80,7 @@ export interface UpdateVoiceState { self_deaf: boolean; } -export type ShardStatusUpdate = Pick; +export type ShardStatusUpdate = Pick; export interface RequestGuildMembersOptions extends GatewayRequestGuildMembersDataWithQuery, GatewayRequestGuildMembersDataWithUserIds {} @@ -96,7 +97,7 @@ export type AtLeastOne< T, U = { [K in keyof T]: Pick; - }, + } > = Partial & U[keyof U]; export type ClientUser = { bot: true } & APIUser; @@ -155,7 +156,7 @@ export type StageSameEvents = RestToKeys< APIStageInstance, GatewayDispatchEvents.StageInstanceCreate, GatewayDispatchEvents.StageInstanceUpdate, - GatewayDispatchEvents.StageInstanceDelete, + GatewayDispatchEvents.StageInstanceDelete ] >; @@ -167,7 +168,7 @@ export type GuildScheduledUserSameEvents = RestToKeys< [ GatewayGuildScheduledEventUserRemoveDispatchData, GatewayDispatchEvents.GuildScheduledEventUserRemove, - GatewayDispatchEvents.GuildScheduledEventUserAdd, + GatewayDispatchEvents.GuildScheduledEventUserAdd ] >; @@ -176,7 +177,7 @@ export type GuildScheduledSameEvents = RestToKeys< APIGuildScheduledEvent, GatewayDispatchEvents.GuildScheduledEventCreate, GatewayDispatchEvents.GuildScheduledEventDelete, - GatewayDispatchEvents.GuildScheduledEventUpdate, + GatewayDispatchEvents.GuildScheduledEventUpdate ] >; @@ -189,7 +190,7 @@ export type AutoModetaractionRuleEvents = RestToKeys< APIAutoModerationRule, GatewayDispatchEvents.AutoModerationRuleCreate, GatewayDispatchEvents.AutoModerationRuleDelete, - GatewayDispatchEvents.AutoModerationRuleUpdate, + GatewayDispatchEvents.AutoModerationRuleUpdate ] >; diff --git a/packages/ws/src/constants/index.ts b/packages/ws/src/constants/index.ts index 032d3e6..4501f98 100644 --- a/packages/ws/src/constants/index.ts +++ b/packages/ws/src/constants/index.ts @@ -1,24 +1,24 @@ -import type { GatewayDispatchPayload } from "@biscuitland/common"; -import type { GatewayManagerOptions } from "../gateway"; +import type { GatewayDispatchPayload } from '@biscuitland/common'; +import { ShardManagerOptions } from '../discord'; const COMPRESS = false; const properties = { os: process.platform, - browser: "Biscuit", - device: "Biscuit", + browser: 'Biscuit', + device: 'Biscuit' }; -const GatewayManagerDefaults: Partial = { +const ShardManagerDefaults: Partial = { totalShards: 1, spawnShardDelay: 5300, debug: false, intents: 0, properties: properties, version: 10, - handlePayload: function (shardId: number, packet: GatewayDispatchPayload): void { + handlePayload: (shardId: number, packet: GatewayDispatchPayload): void => { console.info(`Packet ${packet.t} on shard ${shardId}`); - }, + } }; export interface IdentifyProperties { @@ -51,41 +51,7 @@ enum ShardState { /** Shard is trying to resume a session with the gateway. */ Resuming = 5, /** Shard got shut down studied or due to a not (self) fixable error and may not attempt to reconnect on its own. */ - Offline = 6, + Offline = 6 } -function isObject(o: any) { - return o && typeof o === "object" && !Array.isArray(o); -} - -function Options(defaults: any, ...options: any[]): T { - const option = options.shift(); - if (!option) return defaults; - - return Options( - { - ...option, - ...Object.fromEntries( - Object.entries(defaults).map(([key, value]) => [ - key, - isObject(value) ? Options(value, option?.[key] || {}) : option?.[key] || value, - ]), - ), - }, - ...options, - ); -} - -// nutella truco -function OptionsD>(o: O) { - return function (target: { new (...args: any[]): any }) { - return class extends target { - constructor(...args: any[]) { - super(); - this.options = Options(o, ...args.filter(isObject)); - } - }; - }; -} - -export { COMPRESS, GatewayManagerDefaults, Options, OptionsD, ShardState, isObject, properties }; +export { COMPRESS, ShardManagerDefaults, ShardState, properties }; diff --git a/packages/ws/src/discord/heartbeater.ts b/packages/ws/src/discord/heartbeater.ts new file mode 100644 index 0000000..9650cfa --- /dev/null +++ b/packages/ws/src/discord/heartbeater.ts @@ -0,0 +1,122 @@ +import { GatewayHeartbeatRequest, GatewayHello, GatewayOpcodes, GatewayReceivePayload } from '@biscuitland/common'; +import { Shard } from './shard.js'; +import { ShardSocketCloseCodes } from './shared.js'; + +export interface ShardHeart { + /** Whether or not the heartbeat was acknowledged by Discord in time. */ + ack: boolean; + /** Interval between heartbeats requested by Discord. */ + interval: number; + /** Id of the interval, which is used for sending the heartbeats. */ + intervalId?: NodeJS.Timeout; + /** Unix (in milliseconds) timestamp when the last heartbeat ACK was received from Discord. */ + lastAck?: number; + /** Unix timestamp (in milliseconds) when the last heartbeat was sent. */ + lastBeat?: number; + /** Round trip time (in milliseconds) from Shard to Discord and back. + * Calculated using the heartbeat system. + * Note: this value is undefined until the first heartbeat to Discord has happened. + */ + rtt?: number; + /** Id of the timeout which is used for sending the first heartbeat to Discord since it's "special". */ + timeoutId?: NodeJS.Timeout; + /** internal value */ + toString(): string; +} + +export class ShardHeartBeater { + heart: ShardHeart = { + ack: false, + interval: 30_000 + }; + // biome-ignore lint/nursery/noEmptyBlockStatements: + constructor(public shard: Shard) {} + + acknowledge(ack = true) { + this.heart.ack = ack; + } + + handleHeartbeat(_packet: Extract) { + this.shard.logger.debug(`[Shard #${this.shard.id}] received hearbeat event`); + this.heartbeat(false); + } + + /** + * sends a heartbeat whenever its needed + * fails if heart.interval is null + */ + heartbeat(acknowledgeAck: boolean) { + if (acknowledgeAck) { + if (!this.heart.lastAck) { + this.shard.logger.debug(`[Shard #${this.shard.id}] Heartbeat not acknowledged.`); + this.shard.close(ShardSocketCloseCodes.ZombiedConnection, 'Zombied connection, did not receive an heartbeat ACK in time.'); + this.shard.identify(true); + } + this.heart.lastAck = undefined; + } + + this.heart.lastBeat = Date.now(); + + // avoid creating a bucket here + this.shard.websocket?.send( + JSON.stringify({ + op: GatewayOpcodes.Heartbeat, + d: this.shard.data.resumeSeq + }) + ); + } + + stopHeartbeating() { + clearInterval(this.heart.intervalId); + clearTimeout(this.heart.timeoutId); + } + + startHeartBeating() { + this.shard.logger.debug(`[Shard #${this.shard.id}] scheduling heartbeat!`); + + if (!this.shard.isOpen()) return; + + // The first heartbeat needs to be send with a random delay between `0` and `interval` + // Using a `setTimeout(_, jitter)` here to accomplish that. + // `Math.random()` can be `0` so we use `0.5` if this happens + // Reference: https://discord.com/developers/docs/topics/gateway#heartbeating + const jitter = Math.ceil(this.heart.interval * (Math.random() || 0.5)); + + this.heart.timeoutId = setTimeout(() => { + // send a heartbeat + this.heartbeat(false); + this.heart.intervalId = setInterval(() => { + this.acknowledge(false); + this.heartbeat(false); + }, this.heart.interval); + }, jitter); + } + + handleHello(packet: GatewayHello) { + if (packet.d.heartbeat_interval > 0) { + if (this.heart.interval != null) { + this.stopHeartbeating(); + } + + this.heart.interval = packet.d.heartbeat_interval; + this.heart.intervalId = setInterval(() => { + this.acknowledge(false); + this.heartbeat(false); + }, this.heart.interval); + } + + this.startHeartBeating(); + } + + onpacket(packet: GatewayReceivePayload) { + switch (packet.op) { + case GatewayOpcodes.Heartbeat: + return this.handleHeartbeat(packet); + case GatewayOpcodes.Hello: + return this.handleHello(packet); + case GatewayOpcodes.HeartbeatAck: + this.acknowledge(); + return (this.heart.lastAck = Date.now()); + } + } +} diff --git a/packages/ws/src/discord/index.ts b/packages/ws/src/discord/index.ts new file mode 100644 index 0000000..d46579a --- /dev/null +++ b/packages/ws/src/discord/index.ts @@ -0,0 +1,3 @@ +export * from './shard'; +export * from './sharder'; +export * from './shared'; diff --git a/packages/ws/src/discord/shard.ts b/packages/ws/src/discord/shard.ts new file mode 100644 index 0000000..2080ec9 --- /dev/null +++ b/packages/ws/src/discord/shard.ts @@ -0,0 +1,315 @@ +import { + GATEWAY_BASE_URL, + GatewayCloseCodes, + GatewayDispatchEvents, + GatewayDispatchPayload, + GatewayOpcodes, + GatewayReadyDispatchData, + GatewayReceivePayload, + GatewaySendPayload, + type Logger, +} from "@biscuitland/common"; +import { setTimeout as delay } from "node:timers/promises"; +import { inflateSync } from "node:zlib"; +import WS, { WebSocket, type CloseEvent } from "ws"; +import { ShardState, properties } from "../constants"; +import { DynamicBucket, PriorityQueue } from "../structures"; +import { ShardHeartBeater } from "./heartbeater.js"; +import { ShardData, ShardOptions, ShardSocketCloseCodes } from "./shared.js"; + +export class Shard { + logger: Logger; + data: Partial | ShardData; + websocket: WebSocket | null = null; + heartbeater: ShardHeartBeater; + bucket: DynamicBucket; + offlineSendQueue = new PriorityQueue<(_?: unknown) => void>(); + constructor(public id: number, protected options: ShardOptions) { + this.options.ratelimitOptions ??= { + rateLimitResetInterval: 60_000, + maxRequestsPerRateLimitTick: 120, + }; + this.logger = options.logger; + this.data = { + resumeSeq: null, + resume_gateway_url: GATEWAY_BASE_URL, + }; + + this.heartbeater = new ShardHeartBeater(this); + + const safe = this.calculateSafeRequests(); + this.bucket = new DynamicBucket({ + limit: safe, + refillAmount: safe, + refillInterval: 6e4, + logger: this.logger, + }); + } + + isOpen() { + this.logger.fatal(`[Shard #${this.id}]`, "isOpen", this.websocket?.readyState === WebSocket.OPEN); + return this.websocket?.readyState === WebSocket.OPEN; + } + + /** + * the state of the current shard + */ + get state() { + return this.data.shardState ?? ShardState.Offline; + } + + set state(st: ShardState) { + this.data.shardState = st; + } + + get gatewayURL() { + return this.data.resume_gateway_url ?? this.options.info.url; + } + + connect() { + this.logger.fatal(`[Shard #${this.id}]`, "Connect", this.state); + if (![ShardState.Resuming, ShardState.Identifying].includes(this.state)) { + this.state = ShardState.Connecting; + } + + this.websocket = new WebSocket(this.gatewayURL); + + this.websocket!.onmessage = (event) => this.handleMessage(event); + + this.websocket!.onclose = (event) => this.handleClosed(event); + + this.websocket!.onerror = (event) => this.logger.error(event); + + return new Promise((resolve, reject) => { + const timer = setTimeout(reject, 30_000); + this.websocket!.onopen = () => { + if (![ShardState.Resuming, ShardState.Identifying].includes(this.state)) { + this.state = ShardState.Unidentified; + } + + clearTimeout(timer); + resolve(this); + }; + + this.heartbeater = new ShardHeartBeater(this); + }); + } + + checkOffline(priority: number) { + // biome-ignore lint/style/noArguments: + // biome-ignore lint/correctness/noUndeclaredVariables: + this.logger.fatal(`[Shard #${this.id}]`, "checkOffline", ...arguments); + if (!this.isOpen()) { + return new Promise((resolve) => this.offlineSendQueue.push(resolve, priority)); + } + return Promise.resolve(); + } + + async identify(justTry = false) { + this.logger.debug(`[Shard #${this.id}] ${justTry ? "Trying " : ""}on identify ${this.isOpen()}`); + + if (this.isOpen()) { + if (justTry) return; + this.logger.debug(`[Shard #${this.id}] CLOSING EXISTING SHARD`); + this.close(ShardSocketCloseCodes.ReIdentifying, "Re-identifying closure of old connection."); + } + + this.state = ShardState.Identifying; + + if (!this.isOpen()) { + await this.connect(); + } + + this.send(0, { + op: GatewayOpcodes.Identify, + d: { + token: `Bot ${this.options.token}`, + compress: this.options.compress, + properties, + shard: [this.id, this.options.info.shards], + intents: this.options.intents, + }, + }); + } + + resume() { + this.logger.fatal(`[Shard #${this.id}]`, "Resuming"); + this.state = ShardState.Resuming; + const data = { + seq: this.data.resumeSeq!, + session_id: this.data.session_id!, + token: `Bot ${this.options.token}`, + }; + console.log({ data }); + return this.send(0, { d: data, op: GatewayOpcodes.Resume }); + } + + /** + * Send a message to Discord Gateway. + * sets up the buckets aswell for every path + * these buckets are dynamic memory however a good practice is to use 'WebSocket.send' directly + * in simpler terms, do not use where we don't want buckets + */ + async send(priority: number, message: T) { + // biome-ignore lint/style/noArguments: + // biome-ignore lint/correctness/noUndeclaredVariables: + this.logger.fatal(`[Shard #${this.id}]`, "Send", ...arguments); + // Before acquiring a token from the bucket, check whether the shard is currently offline or not. + // Else bucket and token wait time just get wasted. + await this.checkOffline(priority); + + // pause the function execution for the bucket to be acquired + await this.bucket.acquire(priority); + + // It's possible, that the shard went offline after a token has been acquired from the bucket. + await this.checkOffline(priority); + + // send the payload at last + this.websocket?.send(JSON.stringify(message)); + } + + protected handleMessage({ data }: WS.MessageEvent) { + if (data instanceof Buffer) { + data = inflateSync(data); + } + /** + * Idk why, but Bun sends this event when websocket connects. + * MessageEvent { + * type: "message", + * data: "Already authenticated." + * } + */ + if ((data as string).startsWith("{")) data = JSON.parse(data as string); + + const packet = data as unknown as GatewayReceivePayload; + + // emit other events + this.onpacket(packet); + } + + async onpacket(packet: GatewayReceivePayload | GatewayDispatchPayload) { + if (packet.s !== null) { + this.data.resumeSeq = packet.s; + } + + this.logger.debug(`[Shard #${this.id}]`, packet.t, packet.op); + + this.heartbeater.onpacket(packet); + + switch (packet.op) { + case GatewayOpcodes.Hello: + if (this.data.session_id) { + await this.resume(); + } else { + // await this.identify(true); + } + break; + case GatewayOpcodes.Reconnect: + this.disconnect(); + await this.connect(); + // await this.resume(); + break; + case GatewayOpcodes.InvalidSession: { + const resumable = packet.d as boolean; + // We need to wait for a random amount of time between 1 and 5 + // Reference: https://discord.com/developers/docs/topics/gateway#resuming + // el delay es el tipico timoeut promise, hazmelo pls + //yo con un import { setTimeout as delay } from 'node:timers/promises'; en la mochila + await delay(Math.floor((Math.random() * 4 + 1) * 1000)); + + if (!resumable) { + this.data.resumeSeq = 0; + this.data.session_id = undefined; + await this.identify(true); + break; + } + await this.resume(); + break; + } + } + + switch (packet.t) { + case GatewayDispatchEvents.Resumed: + this.state = ShardState.Connected; + this.offlineSendQueue.toArray().map((resolve: () => any) => resolve()); + break; + case GatewayDispatchEvents.Ready: { + const payload = packet.d as GatewayReadyDispatchData; + this.data.resume_gateway_url = payload.resume_gateway_url; + this.data.session_id = payload.session_id; + this.state = ShardState.Connected; + this.offlineSendQueue.toArray().map((resolve: () => any) => resolve()); + this.options.handlePayload(this.id, packet); + break; + } + default: + this.options.handlePayload(this.id, packet as GatewayDispatchPayload); + break; + } + } + + close(code: number, reason: string) { + if (this.websocket?.readyState !== WebSocket.OPEN) return; + this.websocket?.close(code, reason); + } + + disconnect() { + // biome-ignore lint/style/noArguments: + // biome-ignore lint/correctness/noUndeclaredVariables: + this.logger.info(`[Shard #${this.id}]`, "Disconnect", ...arguments); + this.close(ShardSocketCloseCodes.Shutdown, "Shard down request"); + this.state = ShardState.Offline; + } + + protected async handleClosed(close: CloseEvent) { + this.heartbeater.stopHeartbeating(); + + switch (close.code) { + case ShardSocketCloseCodes.Shutdown: + case ShardSocketCloseCodes.ReIdentifying: + case ShardSocketCloseCodes.Resharded: + case ShardSocketCloseCodes.ResumeClosingOldConnection: + case ShardSocketCloseCodes.ZombiedConnection: + this.state = ShardState.Disconnected; + return; + + case GatewayCloseCodes.UnknownOpcode: + case GatewayCloseCodes.NotAuthenticated: + case GatewayCloseCodes.InvalidSeq: + case GatewayCloseCodes.RateLimited: + case GatewayCloseCodes.SessionTimedOut: + this.logger.debug(`[Shard #${this.id}] Gateway connection closing requiring re-identify. Code: ${close.code}`); + this.state = ShardState.Identifying; + + return this.identify(); + case GatewayCloseCodes.AuthenticationFailed: + case GatewayCloseCodes.InvalidShard: + case GatewayCloseCodes.ShardingRequired: + case GatewayCloseCodes.InvalidAPIVersion: + case GatewayCloseCodes.InvalidIntents: + case GatewayCloseCodes.DisallowedIntents: + this.state = ShardState.Offline; + + throw new Error(close.reason || "Discord gave no reason! GG! You broke Discord!"); + // Gateway connection closes on which a resume is allowed. + default: + console.log(close.code); + this.logger.info(`[Shard #${this.id}] closed shard #${this.id}. Resuming...`); + this.state = ShardState.Resuming; + + this.disconnect(); + await this.connect(); + } + } + + /** Calculate the amount of requests which can safely be made per rate limit interval, before the gateway gets disconnected due to an exceeded rate limit. */ + calculateSafeRequests(): number { + // * 2 adds extra safety layer for discords OP 1 requests that we need to respond to + const safeRequests = + this.options.ratelimitOptions!.maxRequestsPerRateLimitTick - + Math.ceil(this.options.ratelimitOptions!.rateLimitResetInterval / this.heartbeater!.heart.interval) * 2; + + if (safeRequests < 0) return 0; + return safeRequests; + } +} diff --git a/packages/ws/src/discord/sharder.ts b/packages/ws/src/discord/sharder.ts new file mode 100644 index 0000000..f960059 --- /dev/null +++ b/packages/ws/src/discord/sharder.ts @@ -0,0 +1,160 @@ +import { + APIGatewayBotInfo, + Collection, + GatewayOpcodes, + GatewayUpdatePresence, + GatewayVoiceStateUpdate, + LogLevels, + Logger, + ObjectToLower, + Options, + toSnakeCase, +} from "@biscuitland/common"; +import { ShardManagerDefaults } from "../constants"; +import { SequentialBucket } from "../structures"; +import { Shard } from "./shard.js"; +import { ShardManagerOptions } from "./shared"; + +export class ShardManager extends Collection { + connectQueue: SequentialBucket; + options: Required; + logger: Logger; + + constructor(options: ShardManagerOptions) { + super(); + this.options = Options>(ShardManagerDefaults, options, { info: { shards: options.totalShards } }); + + this.connectQueue = new SequentialBucket(this.concurrency); + + this.logger = new Logger({ + active: this.options.debug, + name: "[ShardManager]", + logLevel: LogLevels.Debug, + }); + } + + get remaining(): number { + return this.options.info.session_start_limit.remaining; + } + + get concurrency(): number { + return this.options.info.session_start_limit.max_concurrency; + } + + calculeShardId(guildId: string) { + return Number((BigInt(guildId) >> 22n) % BigInt(this.options.totalShards)); + } + + spawn(shardId: number) { + this.logger.info(`Spawn shard ${shardId}`); + let shard = this.get(shardId); + + shard ??= new Shard(shardId, { + token: this.options.token, + intents: this.options.intents, + info: Options(this.options.info, { shards: this.options.totalShards }), + handlePayload: this.options.handlePayload, + properties: this.options.properties, + logger: this.logger, + compress: false, + }); + + this.set(shardId, shard); + + return shard; + } + + async spawnShards(): Promise { + const buckets = this.spawnBuckets(); + + this.logger.info("Spawn shards"); + for (const bucket of buckets) { + for (const shard of bucket) { + if (!shard) break; + this.logger.info(`${shard.id} add to connect queue`); + await this.connectQueue.push(shard.identify.bind(shard, false)); + } + } + } + + /* + * spawns buckets in order + * https://discord.com/developers/docs/topics/gateway#sharding-max-concurrency + */ + spawnBuckets(): Shard[][] { + this.logger.info("Preparing buckets"); + const chunks = SequentialBucket.chunk(new Array(this.options.totalShards), this.concurrency); + + // biome-ignore lint/complexity/noForEach: i mean is the same thing, but we need the index; + chunks.forEach((arr: any[], index: number) => { + for (let i = 0; i < arr.length; i++) { + const id = i + (index > 0 ? index * this.concurrency : 0); + chunks[index][i] = this.spawn(id); + } + }); + this.logger.info(`${chunks.length} buckets created`); + return chunks; + } + + forceIdentify(shardId: number) { + this.logger.info(`Shard #${shardId} force identify`); + return this.spawn(shardId).identify(); + } + + disconnect(shardId: number) { + this.logger.info(`Force disconnect shard ${shardId}`); + return this.get(shardId)?.disconnect(); + } + + disconnectAll() { + this.logger.info("Disconnect all shards"); + // biome-ignore lint/complexity/noForEach: In maps, for each and for of have same performance + return this.forEach((shard) => shard.disconnect()); + } + + setShardPresence(shardId: number, payload: GatewayUpdatePresence["d"]) { + this.logger.info(`Shard #${shardId} update presence`); + return this.get(shardId)?.send(1, { + op: GatewayOpcodes.PresenceUpdate, + d: payload, + }); + } + setPresence(payload: GatewayUpdatePresence["d"]): Promise | undefined { + return new Promise((resolve) => { + // biome-ignore lint/complexity/noForEach: In maps, for each and for of have same performance + this.forEach((shard) => { + this.setShardPresence(shard.id, payload); + }, this); + resolve(); + }); + } + + joinVoice(guild_id: string, channel_id: string, options: ObjectToLower>) { + const shardId = this.calculeShardId(guild_id); + this.logger.info(`Shard #${shardId} join voice ${channel_id} in ${guild_id}`); + + return this.get(shardId)?.send(1, { + op: GatewayOpcodes.VoiceStateUpdate, + d: { + guild_id, + channel_id, + ...toSnakeCase(options), + }, + }); + } + + leaveVoice(guild_id: string) { + const shardId = this.calculeShardId(guild_id); + this.logger.info(`Shard #${shardId} leave voice in ${guild_id}`); + + return this.get(shardId)?.send(1, { + op: GatewayOpcodes.VoiceStateUpdate, + d: { + guild_id, + channel_id: null, + self_mute: false, + self_deaf: false, + }, + }); + } +} diff --git a/packages/ws/src/gateway/shared.ts b/packages/ws/src/discord/shared.ts similarity index 88% rename from packages/ws/src/gateway/shared.ts rename to packages/ws/src/discord/shared.ts index 0aa0281..220a1f4 100644 --- a/packages/ws/src/gateway/shared.ts +++ b/packages/ws/src/discord/shared.ts @@ -1,5 +1,48 @@ -import type { APIGatewayBotInfo, GatewayDispatchPayload, GatewayIntentBits } from "@biscuitland/common"; -import type { IdentifyProperties, ShardState } from "../constants/index"; +import { APIGatewayBotInfo, GatewayDispatchPayload, GatewayIntentBits, Logger } from '@biscuitland/common'; +import { IdentifyProperties, ShardState } from '../constants'; + +export interface ShardManagerOptions extends ShardDetails { + /** Important data which is used by the manager to connect shards to the gateway. */ + info: APIGatewayBotInfo; + /** + * Delay in milliseconds to wait before spawning next shard. OPTIMAL IS ABOVE 5100. YOU DON'T WANT TO HIT THE RATE LIMIT!!! + * @default 5300 + */ + spawnShardDelay?: number; + /** + * Total amount of shards your bot uses. Useful for zero-downtime updates or resharding. + * @default 1 + */ + totalShards?: number; + /** + * The payload handlers for messages on the shard. + */ + handlePayload(shardId: number, packet: GatewayDispatchPayload): unknown; + /** + * wheter to send debug information to the console + */ + debug?: boolean; +} + +export interface ShardData { + /** state */ + shardState: ShardState; + + /** resume seq to resume connections */ + resumeSeq: number | null; + + /** + * resume_gateway_url is the url to resume the connection + * @link https://discord.com/developers/docs/topics/gateway#ready-event + */ + resume_gateway_url?: string; + + /** + * session_id is the unique session id of the gateway + * do not mistake with the biscuit client which is named Session + */ + session_id?: string; +} export interface ShardDetails { /** Bot token which is used to connect to Discord */ @@ -24,6 +67,17 @@ export interface ShardDetails { properties?: IdentifyProperties; } +export interface ShardOptions extends ShardDetails { + info: APIGatewayBotInfo; + handlePayload(shardId: number, packet: GatewayDispatchPayload): unknown; + ratelimitOptions?: { + maxRequestsPerRateLimitTick: number; + rateLimitResetInterval: number; + }; + logger: Logger; + compress: boolean; +} + export enum ShardSocketCloseCodes { /** A regular Shard shutdown. */ Shutdown = 3000, @@ -38,61 +92,5 @@ export enum ShardSocketCloseCodes { /** Special close code reserved for Discordeno's zero-downtime resharding system. */ Resharded = 3065, /** Shard is re-identifying therefore the old connection needs to be closed. */ - ReIdentifying = 3066, -} - -export interface ShardOptions extends ShardDetails { - info: APIGatewayBotInfo; - handlePayload(shardId: number, packet: GatewayDispatchPayload): unknown; - debug?: boolean; - ratelimitOptions?: { - maxRequestsPerRateLimitTick: number; - rateLimitResetInterval: number; - }; -} - -export interface ShardData { - /** the id of the current shard */ - id: number; - - /** state */ - shardState: ShardState; - - /** resume seq to resume connections */ - resumeSeq: number | null; - - /** - * resume_gateway_url is the url to resume the connection - * @link https://discord.com/developers/docs/topics/gateway#ready-event - */ - resume_gateway_url?: string; - - /** - * session_id is the unique session id of the gateway - * do not mistake with the biscuit client which is named Session - */ - session_id?: string; -} - -export interface GatewayManagerOptions extends ShardDetails { - /** Important data which is used by the manager to connect shards to the gateway. */ - info: APIGatewayBotInfo; - /** - * Delay in milliseconds to wait before spawning next shard. OPTIMAL IS ABOVE 5100. YOU DON'T WANT TO HIT THE RATE LIMIT!!! - * @default 5300 - */ - spawnShardDelay?: number; - /** - * Total amount of shards your bot uses. Useful for zero-downtime updates or resharding. - * @default 1 - */ - totalShards?: number; - /** - * The payload handlers for messages on the shard. - */ - handlePayload(shardId: number, packet: GatewayDispatchPayload): unknown; - /** - * wheter to send debug information to the console - */ - debug?: boolean; + ReIdentifying = 3066 } diff --git a/packages/ws/src/gateway/index.ts b/packages/ws/src/gateway/index.ts deleted file mode 100644 index e057f33..0000000 --- a/packages/ws/src/gateway/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./shard"; -export * from "./sharder"; -export * from "./shared"; diff --git a/packages/ws/src/gateway/shard.ts b/packages/ws/src/gateway/shard.ts deleted file mode 100644 index 784a5ff..0000000 --- a/packages/ws/src/gateway/shard.ts +++ /dev/null @@ -1,477 +0,0 @@ -import type { - GatewayDispatchPayload, - GatewayHeartbeatRequest, - GatewayHello, - GatewayReceivePayload, - GatewaySendPayload, - When, -} from "@biscuitland/common"; - -import { - GATEWAY_BASE_URL, - GatewayVersion as GATEWAY_VERSION, - GatewayCloseCodes, - GatewayDispatchEvents, - GatewayOpcodes, - Logger, -} from "@biscuitland/common"; -import { inflateSync } from "node:zlib"; -import { WebSocket, type MessageEvent } from "ws"; -import { COMPRESS, ShardState, properties } from "../constants/index"; -import { DynamicBucket, PriorityQueue } from "../structures/index"; -import type { ShardData, ShardOptions } from "./shared"; - -export interface ShardHeart { - /** Whether or not the heartbeat was acknowledged by Discord in time. */ - ack: boolean; - /** Interval between heartbeats requested by Discord. */ - interval: number; - /** Id of the interval, which is used for sending the heartbeats. */ - intervalId?: NodeJS.Timer; - /** Unix (in milliseconds) timestamp when the last heartbeat ACK was received from Discord. */ - lastAck?: number; - /** Unix timestamp (in milliseconds) when the last heartbeat was sent. */ - lastBeat?: number; - /** Round trip time (in milliseconds) from Shard to Discord and back. - * Calculated using the heartbeat system. - * Note: this value is undefined until the first heartbeat to Discord has happened. - */ - rtt?: number; - /** Id of the timeout which is used for sending the first heartbeat to Discord since it's "special". */ - timeoutId?: NodeJS.Timeout; - /** internal value */ - toString(): string; -} - -export class ShardHeartbeater { - shard: Shard; - heart: ShardHeart; - - constructor(shard: Shard) { - this.shard = shard; - this.heart = { - ack: false, - interval: 30_000, - }; - } - - acknowledge(ack = true) { - this.heart.ack = ack; - } - - async handleHeartbeat(_packet: Extract) { - this.shard.logger.debug("received hearbeat event"); - this.heartbeat(false); - } - - async handleHello(packet: GatewayHello) { - if (packet.d.heartbeat_interval > 0) { - if (this.heart.interval != null) { - this.stopHeartbeating(); - } - - this.heart.interval = packet.d.heartbeat_interval; - this.heart.intervalId = setInterval(() => { - this.acknowledge(false); - this.heartbeat(false); - }, this.heart.interval); - } - - const interval: number = packet.d.heartbeat_interval; - return this.startHeartbeating(interval); - } - - async onpacket(packet: GatewayReceivePayload) { - switch (packet.op) { - case GatewayOpcodes.Hello: { - return this.handleHello(packet); - } - case GatewayOpcodes.Heartbeat: { - return this.handleHeartbeat(packet); - } - case GatewayOpcodes.HeartbeatAck: { - this.acknowledge(); - return (this.heart.lastAck = Date.now()); - } - } - } - - /** - * sends a heartbeat whenever its needed - * fails if heart.interval is null - */ - heartbeat(acknowledgeAck: boolean) { - if (acknowledgeAck) { - if (!this.heart.lastAck) this.shard.disconnect({ reconnect: () => this.shard.resume() }); - this.heart.lastAck = undefined; - } - - this.heart.lastBeat = Date.now(); - - // avoid creating a bucket here - this.shard.websocket.send( - JSON.stringify({ - op: GatewayOpcodes.Heartbeat, - d: this.shard.data.resumeSeq, - }), - ); - } - - /** - * wait the first interval upon receiving the hello event, then start heartbiting - * interval * jitter - * @param interval the packet interval - */ - async startHeartbeating(interval: number) { - this.heart.interval = interval; - this.shard.logger.debug("scheduling heartbeat!"); - - // The first heartbeat needs to be send with a random delay between `0` and `interval` - // Using a `setTimeout(_, jitter)` here to accomplish that. - // `Math.random()` can be `0` so we use `0.5` if this happens - // Reference: https://discord.com/developers/docs/topics/gateway#heartbeating - const jitter = Math.ceil(this.heart.interval * (Math.random() || 0.5)); - - return (this.heart.timeoutId = setTimeout(() => { - // send a heartbeat - this.heartbeat(false); - this.heart.intervalId = setInterval(() => { - this.acknowledge(false); - this.heartbeat(false); - }, this.heart.interval); - }, jitter)); - } - - stopHeartbeating() { - clearTimeout(this.heart.intervalId); - return clearTimeout(this.heart.timeoutId); - } -} - -export class Shard { - constructor(id: number, protected options: ShardOptions) { - this.options.ratelimitOptions ??= { - rateLimitResetInterval: 60_000, - maxRequestsPerRateLimitTick: 120, - }; - this.data = { - id, - resume_gateway_url: GATEWAY_BASE_URL, - resumeSeq: null, - } as never; - this.websocket = null as never; - this.ready = false; - this.handlePayload = (packet) => { - // do not -> - // decorate this function - if (packet.t === "READY") { - this.logger.debug("received ready event"); - this.data.resume_gateway_url = `${packet.d.resume_gateway_url}?v=${GATEWAY_VERSION}&encoding=json`; - this.logger.debug("switch resume url"); - this.data.session_id = packet.d.session_id; - } - - this.options.handlePayload(this.data.id ?? id, packet); - }; - - // we create an empty heartbeater just to get the interval variable - this.heartbeater = new ShardHeartbeater(this as Shard) as never; - - const safe = this.calculateSafeRequests(); - - this.bucket = new DynamicBucket({ - limit: safe, - refillAmount: safe, - refillInterval: 6e4, - debug: this.options.debug, - }); - this.logger = new Logger({ - name: "[Shard]", - active: this.options.debug, - logLevel: 0, - }); - } - - websocket: When; - heartbeater: When; - data: When>; - handlePayload: (packet: GatewayDispatchPayload) => unknown; - offlineSendQueue: PriorityQueue<(_?: unknown) => void> = new PriorityQueue(); - bucket: DynamicBucket; - logger: Logger; - resolves: Map<"READY" | "RESUMED" | "INVALID_SESSION", (payload: GatewayReceivePayload) => void> = new Map(); - - /** wheter the shard was already identified */ - ready: boolean; - - /** - * a guard of wheter is connected or not - */ - isConnected(): this is Shard { - { - return this.websocket?.readyState === WebSocket.OPEN; - } - } - - /** - * the state of the current shard - */ - get state() { - return this.data.shardState ?? ShardState.Offline; - } - - set state(st: ShardState) { - this.data.shardState = st; - } - - /** - * pushes dead requests for the bot to resolve later on send payload - * this is a remanecent of the old gateway but I (Yuzu) decided to let it untouched - */ - async checkOffline(priority: number) { - if (!this.isConnected()) { - return new Promise((resolve) => this.offlineSendQueue.push(resolve, priority)); - } - return; - } - - /** - * Send a message to Discord Gateway. - * sets up the buckets aswell for every path - * these buckets are dynamic memory however a good practice is to use 'WebSocket.send' directly - * in simpler terms, do not use where we don't want buckets - */ - async send(this: Shard, priority: number, message: GatewaySendPayload) { - // Before acquiring a token from the bucket, check whether the shard is currently offline or not. - // Else bucket and token wait time just get wasted. - await this.checkOffline(priority); - - // pause the function execution for the bucket to be acquired - await this.bucket.acquire(priority); - - // It's possible, that the shard went offline after a token has been acquired from the bucket. - await this.checkOffline(priority); - - // send the payload at last - this.websocket.send(JSON.stringify(message)); - } - - /** starts the ws connection */ - async connect(): Promise> { - if (this.state === ShardState.Resuming) { - this.state = ShardState.Connecting; - } - if (this.state === ShardState.Identifying) { - this.state = ShardState.Connecting; - } - - // set client - const websocket = new WebSocket(this.data.session_id ? this.gatewayURL : this.options.info.url); - this.websocket = websocket as When; - - this.websocket.onmessage = async (event) => { - return await (this as Shard).handleMessage(event); - }; - - this.websocket.onerror = (event) => { - return this.logger.error({ error: event, shardId: this.data.id }); - }; - - this.websocket.onclose = async (event) => { - return await (this as Shard).handleClose(event.code, event.reason); - }; - - return new Promise>((resolve, _reject) => { - this.websocket!.onopen = () => { - if (this.state === ShardState.Resuming) { - this.state = ShardState.Unidentified; - } - if (this.state === ShardState.Identifying) { - this.state = ShardState.Unidentified; - } - resolve(this as Shard); - }; - - // set hearbeater - const heartbeater = new ShardHeartbeater(this as Shard); - this.heartbeater = heartbeater as When; - }); - } - - /** Handle an incoming gateway message. */ - async handleMessage(this: Shard, event: MessageEvent) { - let preProcessMessage = event.data; - - if (preProcessMessage instanceof Buffer) { - preProcessMessage = inflateSync(preProcessMessage); - } - - if (typeof preProcessMessage === "string") { - const packet = JSON.parse(preProcessMessage); - - // emit heartbeat events - this.heartbeater.onpacket(packet); - - // try to identify - if (packet.op === GatewayOpcodes.Hello) { - this.identify(); - } - - // emit other events - this.onpacket(packet); - } - return; - } - - async handleClose(this: Shard, code: number, reason: string) { - this.heartbeater.stopHeartbeating(); - - switch (code) { - case 1000: { - this.logger.info("Uknown error code trying to resume"); - return this.resume(); - } - case GatewayCloseCodes.UnknownOpcode: - case GatewayCloseCodes.NotAuthenticated: - case GatewayCloseCodes.InvalidSeq: - case GatewayCloseCodes.RateLimited: - case GatewayCloseCodes.SessionTimedOut: - this.logger.info(`Gateway connection closing requiring re-identify. Code: ${code}`); - this.state = ShardState.Identifying; - return this.identify(); - case GatewayCloseCodes.AuthenticationFailed: - case GatewayCloseCodes.InvalidShard: - case GatewayCloseCodes.ShardingRequired: - case GatewayCloseCodes.InvalidAPIVersion: - case GatewayCloseCodes.InvalidIntents: - case GatewayCloseCodes.DisallowedIntents: - this.state = ShardState.Offline; - // disconnected event - throw new Error(reason || "Discord gave no reason! GG! You broke Discord!"); - default: - return this.disconnect({ reconnect: () => this.resume() }); - } - } - - async resume(this: Shard) { - this.state = ShardState.Resuming; - - if (!this.ready) return; - - return await this.send(0, { - op: GatewayOpcodes.Resume, - d: { - token: this.options.token, - session_id: this.data.session_id!, - seq: this.data.resumeSeq!, - }, - }); - } - - resetState() { - this.ready = false; - } - - disconnect(options?: { reconnect?: () => unknown }) { - this.logger.error("closing connection"); - - if (!this.websocket) { - return; - } - - if (!options?.reconnect) { - this.websocket.close(1012, "BiscuitWS: close"); - } else { - this.logger.error("trying to reconnect"); - options.reconnect(); - } - - this.websocket.terminate(); - - this.resetState(); - } - - async onpacket(this: Shard, packet: GatewayDispatchPayload | GatewayReceivePayload) { - if (packet.s) this.data.resumeSeq = packet.s; - - switch (packet.op) { - case GatewayOpcodes.InvalidSession: - this.logger.debug("got invalid session, trying to identify back"); - this.data.resumeSeq = null; - this.data.session_id = undefined; - this.data.resume_gateway_url = undefined; - this.resetState(); - await this.identify(); - break; - case GatewayOpcodes.Reconnect: - this.disconnect({ - reconnect: () => { - this.resume(); - }, - }); - break; - } - - switch (packet.t) { - case GatewayDispatchEvents.Resumed: - this.heartbeater.heartbeat(false); - break; - default: - this.handlePayload(packet as any); - } - } - - /** do the handshake with discord */ - async identify() { - if (!this.isConnected()) { - await this.connect().then((shardThis: Shard) => { - this.identify.call(shardThis); - }); - return; - } - - if (!this.ready) { - this.logger.debug(`identifying shard ${this.data.id} with a total of ${this.options.info.shards}`); - this.data.shardState = ShardState.Identifying; - await this.send(0, { - op: GatewayOpcodes.Identify, - d: { - token: this.options.token, - intents: this.options.intents, - properties: properties, - shard: [this.data.id, this.options.info.shards], - compress: COMPRESS, - }, - }).then(() => { - this.logger.debug("finished identifying"); - }); - // ^if we don't get this message start preocupating - - this.ready = true; - } - // ^we make sure we can no longer identify unless invalid session - - return new Promise((resolve) => { - return this.shardIsReady?.().then(() => { - resolve(); - }); - }); - } - - get gatewayURL() { - return this.data.resume_gateway_url ?? this.options.info.url; - } - - /** Calculate the amount of requests which can safely be made per rate limit interval, before the gateway gets disconnected due to an exceeded rate limit. */ - calculateSafeRequests(): number { - // * 2 adds extra safety layer for discords OP 1 requests that we need to respond to - const safeRequests = - this.options.ratelimitOptions!.maxRequestsPerRateLimitTick - - Math.ceil(this.options.ratelimitOptions!.rateLimitResetInterval / this.heartbeater!.heart.interval) * 2; - - if (safeRequests < 0) return 0; - else return safeRequests; - } - - shardIsReady?: () => Promise; -} diff --git a/packages/ws/src/gateway/sharder.ts b/packages/ws/src/gateway/sharder.ts deleted file mode 100644 index c26a794..0000000 --- a/packages/ws/src/gateway/sharder.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { APIGatewayBotInfo } from "@biscuitland/common"; -import { Collection, Logger } from "@biscuitland/common"; -import { GatewayManagerDefaults, Options } from "../constants/index"; -import { SequentialBucket } from "../structures/index"; -import { Shard } from "./shard"; -import type { GatewayManagerOptions } from "./shared"; - -export class GatewayManager extends Collection { - connectQueue: SequentialBucket; - options: Required; - logger: Logger; - - constructor(_options: GatewayManagerOptions) { - super(); - this.options = Options>(GatewayManagerDefaults, { info: { shards: _options.totalShards } }, _options); - this.connectQueue = new SequentialBucket(this.concurrency); - this.logger = new Logger({ - name: "[GatewayManager]", - active: this.options.debug, - logLevel: 0, - }); - } - - get concurrency(): number { - return this.options.info.session_start_limit.max_concurrency; - } - - get remaining(): number { - return this.options.info.session_start_limit.remaining; - } - - /* - * spawns buckets in order - * https://discord.com/developers/docs/topics/gateway#sharding-max-concurrency - */ - spawnBuckets(): Shard[][] { - this.logger.info("Preparing buckets"); - const chunks = SequentialBucket.chunk(new Array(this.options.totalShards), this.concurrency); - - chunks.forEach((arr: any[], index: number) => { - for (let i = 0; i < arr.length; i++) { - const id = i + (index > 0 ? index * this.concurrency : 0); - chunks[index][i] = this.spawn(id); - } - }); - - return chunks; - } - - async spawnShards(): Promise { - const buckets = this.spawnBuckets(); - - this.logger.info("Spawn shards"); - for (const bucket of buckets) { - for (const shard of bucket) { - if (!shard) break; - await this.connectQueue.push(shard.identify.bind(shard)); - } - } - } - - spawn(shardId: number) { - let shard = this.get(shardId); - - shard ??= new Shard(shardId, { - token: this.options.token, - intents: this.options.intents, - info: Options(this.options.info, { shards: this.options.totalShards }), - handlePayload: this.options.handlePayload, - properties: this.options.properties, - debug: this.options.debug, - }); - - this.set(shardId, shard); - - return shard; - } - - async forceIdentify(shardId: number) { - await this.spawn(shardId).identify(); - } - - explode() { - return this.forEach(($) => $.disconnect()); - } -} diff --git a/packages/ws/src/index.ts b/packages/ws/src/index.ts index eb18939..8e02e0b 100644 --- a/packages/ws/src/index.ts +++ b/packages/ws/src/index.ts @@ -1,3 +1,3 @@ -export * from "./SharedTypes"; -export * from "./constants"; -export * from "./gateway"; +export * from './SharedTypes'; +export * from './constants'; +export * from './discord'; diff --git a/packages/ws/src/structures/dynamic_bucket.ts b/packages/ws/src/structures/dynamic_bucket.ts deleted file mode 100644 index d8dfe83..0000000 --- a/packages/ws/src/structures/dynamic_bucket.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { Logger, delay } from "@biscuitland/common"; -import { PriorityQueue } from "./lists"; - -/** - * just any kind of request to queue and resolve later - */ -export type QueuedRequest = (value: void | Promise) => Promise | any; - -/** - * options of the dynamic bucket - */ -export interface DynamicBucketOptions { - limit: number; - refillInterval: number; - refillAmount: number; - debug?: boolean; -} - -/** - * generally useless for interaction based bots - * ideally this would only be triggered on certain paths - * example: a huge amount of messages being spammed - * - * a dynamic bucket is just a priority queue implemented using linked lists - * we create an empty bucket for every path - * dynamically allocating memory improves the final memory footprint - */ -export class DynamicBucket { - limit: number; - refillInterval: number; - refillAmount: number; - - /** The queue of requests to acquire an available request. Mapped by */ - queue: PriorityQueue = new PriorityQueue(); - - /** The amount of requests that have been used up already. */ - used = 0; - - /** Whether or not the queue is already processing. */ - processing = false; - - /** The timeout id for the timer to reduce the used amount by the refill amount. */ - timeoutId?: NodeJS.Timeout; - - /** The timestamp in milliseconds when the next refill is scheduled. */ - refillsAt?: number; - - logger: Logger; - - constructor(options: DynamicBucketOptions) { - this.limit = options.limit; - this.refillInterval = options.refillInterval; - this.refillAmount = options.refillAmount; - this.logger = new Logger({ - name: "[Gateway]", - active: options.debug, - logLevel: 0, - }); - } - - get remaining(): number { - if (this.limit < this.used) return 0; - else return this.limit - this.used; - } - - refill(): void { - // Lower the used amount by the refill amount - this.used = this.refillAmount > this.used ? 0 : this.used - this.refillAmount; - - // Reset the refillsAt timestamp since it just got refilled - this.refillsAt = undefined; - - if (this.used > 0) { - if (this.timeoutId) { - clearTimeout(this.timeoutId); - } - this.timeoutId = setTimeout(() => { - this.refill(); - }, this.refillInterval); - this.refillsAt = Date.now() + this.refillInterval; - } - } - - /** Begin processing the queue. */ - async processQueue(): Promise { - // There is already a queue that is processing - if (this.processing) { - return; - } - - // Begin going through the queue. - while (!this.queue.isEmpty()) { - if (this.remaining) { - this.logger.debug(`Processing queue. Remaining: ${this.remaining} Length: ${this.queue.size()}`); - // Resolves the promise allowing the paused execution of this request to resolve and continue. - this.queue.peek()(); - this.queue.pop(); - - // A request can be made - this.used++; - - // Create a new timeout for this request if none exists. - if (!this.timeoutId) { - this.timeoutId = setTimeout(() => { - this.refill(); - }, this.refillInterval); - - // Set the time for when this refill will occur. - this.refillsAt = Date.now() + this.refillInterval; - } - } - - // Check if a refill is scheduled, since we have used up all available requests - else if (this.refillsAt) { - const now = Date.now(); - // If there is time left until next refill, just delay execution. - if (this.refillsAt > now) { - await delay(this.refillsAt - now); - } - } - } - - // Loop has ended mark false so it can restart later when needed - this.processing = false; - } - - /** Pauses the execution until the request is available to be made. */ - async acquire(priority: number): Promise { - return await new Promise((resolve) => { - this.queue.push(resolve, priority); - void this.processQueue(); - }); - } - - toString() { - return [...this.queue].toString(); - } -} diff --git a/packages/ws/src/structures/index.ts b/packages/ws/src/structures/index.ts index 49413a0..c7bda7b 100644 --- a/packages/ws/src/structures/index.ts +++ b/packages/ws/src/structures/index.ts @@ -1,3 +1,337 @@ -export * from "./dynamic_bucket"; -export * from "./lists"; -export * from "./sequential_bucket"; +import { Logger, delay } from '@biscuitland/common'; + +/** + * just any kind of request to queue and resolve later + */ +export type QueuedRequest = (value: void | Promise) => Promise | any; + +/** + * options of the dynamic bucket + */ +export interface DynamicBucketOptions { + limit: number; + refillInterval: number; + refillAmount: number; + logger: Logger; +} + +/** + * generally useless for interaction based bots + * ideally this would only be triggered on certain paths + * example: a huge amount of messages being spammed + * + * a dynamic bucket is just a priority queue implemented using linked lists + * we create an empty bucket for every path + * dynamically allocating memory improves the final memory footprint + */ +export class DynamicBucket { + limit: number; + refillInterval: number; + refillAmount: number; + + /** The queue of requests to acquire an available request. Mapped by */ + queue = new PriorityQueue(); + + /** The amount of requests that have been used up already. */ + used = 0; + + /** Whether or not the queue is already processing. */ + processing = false; + + /** The timeout id for the timer to reduce the used amount by the refill amount. */ + timeoutId?: NodeJS.Timeout; + + /** The timestamp in milliseconds when the next refill is scheduled. */ + refillsAt?: number; + + logger: Logger; + + constructor(options: DynamicBucketOptions) { + this.limit = options.limit; + this.refillInterval = options.refillInterval; + this.refillAmount = options.refillAmount; + this.logger = options.logger; + } + + get remaining(): number { + if (this.limit < this.used) return 0; + return this.limit - this.used; + } + + refill(): void { + // Lower the used amount by the refill amount + this.used = this.refillAmount > this.used ? 0 : this.used - this.refillAmount; + + // Reset the refillsAt timestamp since it just got refilled + this.refillsAt = undefined; + + if (this.used > 0) { + if (this.timeoutId) { + clearTimeout(this.timeoutId); + } + this.timeoutId = setTimeout(() => { + this.refill(); + }, this.refillInterval); + this.refillsAt = Date.now() + this.refillInterval; + } + } + + /** Begin processing the queue. */ + async processQueue(): Promise { + // There is already a queue that is processing + if (this.processing) { + return; + } + + // Begin going through the queue. + while (!this.queue.isEmpty()) { + if (this.remaining) { + this.logger.debug(`Processing queue. Remaining: ${this.remaining} Length: ${this.queue.size()}`); + // Resolves the promise allowing the paused execution of this request to resolve and continue. + this.queue.peek()(); + this.queue.pop(); + + // A request can be made + this.used++; + + // Create a new timeout for this request if none exists. + if (!this.timeoutId) { + this.timeoutId = setTimeout(() => { + this.refill(); + }, this.refillInterval); + + // Set the time for when this refill will occur. + this.refillsAt = Date.now() + this.refillInterval; + } + // Check if a refill is scheduled, since we have used up all available requests + } else if (this.refillsAt) { + const now = Date.now(); + // If there is time left until next refill, just delay execution. + if (this.refillsAt > now) { + await delay(this.refillsAt - now); + } + } + } + + // Loop has ended mark false so it can restart later when needed + this.processing = false; + } + + /** Pauses the execution until the request is available to be made. */ + async acquire(priority: number): Promise { + return await new Promise((resolve) => { + this.queue.push(resolve, priority); + // biome-ignore lint/complexity/noVoid: + void this.processQueue(); + }); + } + + toString() { + return [...this.queue].toString(); + } +} + +/** + * abstract node lol + */ +export interface AbstractNode { + data: T; + next: this | null; +} + +export interface QueuePusher { + push(data: T): NonNullable>; +} + +export interface QueuePusherWithPriority { + push(data: T, priority: number): NonNullable>; +} + +export class TNode implements AbstractNode { + data: T; + next: this | null; + + constructor(data: T) { + this.data = data; + this.next = null; + } + + static null(list: AbstractNode | null): list is null { + return !list; + } +} + +export class PNode extends TNode { + priority: number; + + constructor(data: T, priority: number) { + super(data); + this.priority = priority; + } +} + +export abstract class Queue { + protected abstract head: AbstractNode | null; + + /** + * O(1) + */ + public pop() { + if (TNode.null(this.head)) { + throw new Error('cannot pop a list without elements'); + } + + return (this.head = this.head.next); + } + + /** + * O(1) + */ + public peek(): T { + if (TNode.null(this.head)) { + throw new Error('cannot peek an empty list'); + } + + return this.head.data; + } + + /** + * O(n) + */ + public size(): number { + let aux = this.head; + + if (TNode.null(aux)) { + return 0; + } + + let count = 1; + + while (aux.next !== null) { + count++; + aux = aux.next; + } + + return count; + } + + /** + * O(1) + */ + public isEmpty() { + return TNode.null(this.head); + } + + *[Symbol.iterator](): IterableIterator { + let temp = this.head; + while (temp !== null) { + yield temp.data; + temp = temp.next; + } + } + + public toArray(): T[] { + return Array.from(this); + } + public toString() { + return this.head?.toString() || ''; + } +} + +export class LinkedList extends Queue implements QueuePusher { + protected head: TNode | null = null; + + /** + * O(1) + */ + public push(data: T): NonNullable> { + const temp = new TNode(data); + temp.next = this.head; + this.head = temp; + + return this.head; + } +} + +export class PriorityQueue extends Queue implements QueuePusherWithPriority { + protected head: PNode | null = null; + + /** + * O(#priorities) + */ + public push(data: T, priority: number): NonNullable> { + let start = this.head; + const temp = new PNode(data, priority); + + if (TNode.null(this.head) || TNode.null(start)) { + this.head = temp; + return this.head; + } + + if (this.head.priority > priority) { + temp.next = this.head; + this.head = temp; + return this.head; + } + + while (start.next !== null && start.next.priority < priority) { + start = start.next; + } + + temp.next = start.next as PNode; + start.next = temp; + + return this.head; + } +} + +export class SequentialBucket { + private connections: LinkedList; + private capacity: number; // max_concurrency + private spawnTimeout: number; + + constructor(maxCapacity: number) { + this.connections = new LinkedList(); + this.capacity = maxCapacity; + this.spawnTimeout = 5000; + } + + public async destroy() { + this.connections = new LinkedList(); + } + + public async push(promise: QueuedRequest) { + this.connections.push(promise); + + if (this.capacity <= this.connections.size()) { + await this.acquire(); + await delay(this.spawnTimeout); + } + return; + } + + public async acquire(promises = this.connections) { + while (!promises.isEmpty()) { + const item = promises.peek(); + item().catch((...args: any[]) => { + Promise.reject(...args); + }); + promises.pop(); + } + + return Promise.resolve(true); + } + + public static chunk(array: T[], chunks: number): T[][] { + let index = 0; + let resIndex = 0; + const result = Array(Math.ceil(array.length / chunks)); + + while (index < array.length) { + result[resIndex] = array.slice(index, (index += chunks)); + resIndex++; + } + + return result; + } +} diff --git a/packages/ws/src/structures/lists.ts b/packages/ws/src/structures/lists.ts deleted file mode 100644 index 8d746b5..0000000 --- a/packages/ws/src/structures/lists.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * abstract node lol - */ -export interface AbstractNode { - data: T; - next: this | null; -} - -export interface QueuePusher { - push(data: T): NonNullable>; -} - -export interface QueuePusherWithPriority { - push(data: T, priority: number): NonNullable>; -} - -export class TNode implements AbstractNode { - data: T; - next: this | null; - - constructor(data: T) { - this.data = data; - this.next = null; - } - - static null(list: AbstractNode | null): list is null { - return !list; - } -} - -export class PNode extends TNode { - priority: number; - - constructor(data: T, priority: number) { - super(data); - this.priority = priority; - } -} - -export abstract class Queue { - protected abstract head: AbstractNode | null; - - /** - * O(1) - */ - public pop() { - if (TNode.null(this.head)) { - throw new Error("cannot pop a list without elements"); - } - - return (this.head = this.head.next); - } - - /** - * O(1) - */ - public peek(): T { - if (TNode.null(this.head)) { - throw new Error("cannot peek an empty list"); - } - - return this.head.data; - } - - /** - * O(n) - */ - public size(): number { - let aux = this.head; - - if (TNode.null(aux)) { - return 0; - } - - let count = 1; - - while (aux.next !== null) { - count++; - aux = aux.next; - } - - return count; - } - - /** - * O(1) - */ - public isEmpty() { - return TNode.null(this.head); - } - - *[Symbol.iterator](): IterableIterator { - let temp = this.head; - while (temp !== null) { - yield temp.data; - temp = temp.next; - } - } - - public toArray(): T[] { - return Array.from(this); - } - public toString() { - return this.head?.toString() || ""; - } -} - -export class LinkedList extends Queue implements QueuePusher { - protected head: TNode | null = null; - - /** - * O(1) - */ - public push(data: T): NonNullable> { - const temp = new TNode(data); - temp.next = this.head; - this.head = temp; - - return this.head; - } -} - -export class PriorityQueue extends Queue implements QueuePusherWithPriority { - protected head: PNode | null = null; - - /** - * O(#priorities) - */ - public push(data: T, priority: number): NonNullable> { - let start = this.head; - const temp = new PNode(data, priority); - - if (TNode.null(this.head) || TNode.null(start)) { - this.head = temp; - return this.head; - } - - if (this.head.priority > priority) { - temp.next = this.head; - this.head = temp; - return this.head; - } - - while (start.next !== null && start.next.priority < priority) { - start = start.next; - } - - temp.next = start.next as PNode; - start.next = temp; - - return this.head; - } -} diff --git a/packages/ws/src/structures/sequential_bucket.ts b/packages/ws/src/structures/sequential_bucket.ts deleted file mode 100644 index 7f9a452..0000000 --- a/packages/ws/src/structures/sequential_bucket.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { delay } from "@biscuitland/common"; -import type { QueuedRequest } from "./dynamic_bucket"; -import { LinkedList } from "./lists"; - -export class SequentialBucket { - private connections: LinkedList; - private capacity: number; // max_concurrency - private spawnTimeout: number; - - constructor(maxCapacity: number) { - this.connections = new LinkedList(); - this.capacity = maxCapacity; - this.spawnTimeout = 5000; - } - - public async destroy() { - this.connections = new LinkedList(); - } - - public async push(promise: QueuedRequest) { - this.connections.push(promise); - - if (this.capacity <= this.connections.size()) { - await this.acquire(); - await delay(this.spawnTimeout); - } - return; - } - - public async acquire(promises = this.connections) { - while (!promises.isEmpty()) { - const item = promises.peek(); - item().catch((...args: any[]) => { - Promise.reject(...args); - }); - promises.pop(); - } - - return Promise.resolve(true); - } - - public static chunk(array: T[], chunks: number): T[][] { - let index = 0; - let resIndex = 0; - const result = Array(Math.ceil(array.length / chunks)); - - while (index < array.length) { - result[resIndex] = array.slice(index, (index += chunks)); - resIndex++; - } - - return result; - } -}