fix(ws): gateway resume now it works correctly

feat(ws): more usesfull methods in sharder
fix(helpers): data arrays fixed
ci: change rome to biome

Co-authored-by: MARCROCK22 <MARCROCK22@users.noreply.github.com>
This commit is contained in:
socram03 2023-11-08 20:16:28 -04:00
parent e2202772cf
commit 02b8bd0d28
46 changed files with 1313 additions and 1349 deletions

View File

@ -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

BIN
bun.lockb Executable file

Binary file not shown.

View File

@ -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 <yuzuru@programmer.net>",
"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 <yuzuru@programmer.net>",
"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"
}
}

View File

@ -6,7 +6,7 @@ export class Collection<K, V> extends Map<K, V> {
*/
maxSize: number | undefined;
/** Handler to remove items from the collection every so often. */
sweeper: (CollectionSweeper<K, V> & { intervalId?: NodeJS.Timer }) | undefined;
sweeper: (CollectionSweeper<K, V> & { intervalId?: NodeJS.Timeout }) | undefined;
constructor(entries?: (ReadonlyArray<readonly [K, V]> | null) | Map<K, V>, options?: CollectionOptions<K, V>) {
super(entries ?? []);
@ -23,6 +23,7 @@ export class Collection<K, V> extends Map<K, V> {
this.sweeper = options;
this.sweeper.intervalId = setInterval(() => {
// biome-ignore lint/complexity/noForEach: <explanation>
this.forEach((value, key) => {
if (!this.sweeper?.filter(value, key)) return;
@ -98,6 +99,7 @@ export class Collection<K, V> extends Map<K, V> {
/** Find all elements in this collection that match the given pattern. */
filter(callback: (value: V, key: K) => boolean): Collection<K, V> {
const relevant = new Collection<K, V>();
// biome-ignore lint/complexity/noForEach: <explanation>
this.forEach((value, key) => {
if (callback(value, key)) relevant.set(key, value);
});

View File

@ -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<T>(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 = <T = any>(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<Obj extends Record<string, any>>(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<Obj extends Record<string, any>>(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<T>(time: number, result?: T) {
export function delay<T>(time: number, result?: T): Promise<T> {
return setTimeout(time, result);
}

View File

@ -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<ActionHandler>) {
// @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<G extends keyof GatewayEvents = keyof GatewayEvents,> = (

View File

@ -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';

View File

@ -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;

View File

@ -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;

View File

@ -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<APIGuildMember, "user"> & {
export type DynamicMember = MakeRequired<APIGuildMember, 'user'> & {
guild_id: string;
};

View File

@ -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 });
}
}

View File

@ -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<On extends boolean = boolean> extends EventEmitter2 {
constructor(public options: BiscuitOptions) {
@ -18,7 +18,7 @@ export class Session<On extends boolean = boolean> extends EventEmitter2 {
api: Routes;
cdn: CDNRoutes;
managers: MainManager;
gateway!: When<On, GatewayManager>;
gateway!: When<On, ShardManager>;
private _applicationId?: string;
private _botId?: string;
@ -67,7 +67,7 @@ export class Session<On extends boolean = boolean> extends EventEmitter2 {
if (!rest) {
return new BiscuitREST({
token: this.options.token,
...this.options.defaultRestOptions,
...this.options.defaultRestOptions
});
}
@ -75,14 +75,14 @@ export class Session<On extends boolean = boolean> 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<true>;
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<On extends boolean = boolean> 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<On extends boolean = boolean> extends EventEmitter2 {
}
}
export type HandlePayload = Pick<GatewayManagerOptions, "handlePayload">["handlePayload"];
export type HandlePayload = Pick<ShardManagerOptions, 'handlePayload'>['handlePayload'];
export interface BiscuitOptions {
token: string;
intents: number | GatewayIntentBits;
rest?: BiscuitREST;
defaultRestOptions?: Partial<BiscuitRESTOptions>;
defaultGatewayOptions?: Identify<Partial<Omit<GatewayManagerOptions, "token" | "intents">>>;
defaultGatewayOptions?: Identify<Partial<Omit<ShardManagerOptions, 'token' | 'intents'>>>;
}

View File

@ -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<E extends keyof GatewayEvents> {
event: `${E}`;
@ -13,19 +13,19 @@ interface CollectorOptions<E extends keyof GatewayEvents> {
export const DEFAULT_OPTIONS = {
filter: () => true,
max: -1,
max: -1
};
export enum CollectorStatus {
Idle = 0,
Started = 1,
Ended = 2,
Ended = 2
}
export class EventCollector<E extends keyof GatewayEvents> extends EventEmitter {
collected = new Set<Parameters<Handler[E]>[0]>();
status: CollectorStatus = CollectorStatus.Idle;
options: MakeRequired<CollectorOptions<E>, "filter" | "max">;
options: MakeRequired<CollectorOptions<E>, 'filter' | 'max'>;
private timeout: NodeJS.Timeout | null = null;
constructor(readonly session: Session, rawOptions: CollectorOptions<E>) {
@ -36,24 +36,24 @@ export class EventCollector<E extends keyof GatewayEvents> extends EventEmitter
start() {
this.session.setMaxListeners(this.session.getMaxListeners() + 1);
this.session.on(this.options.event, (...args: unknown[]) => this.collect(...(args as Parameters<Handler[E]>)));
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<Handler[E]>) {
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<E extends keyof GatewayEvents> 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<Handler[E]>) => unknown): this;
on(event: "end", listener: (reason: string | null | undefined, collected: Set<Parameters<Handler[E]>[0]>) => void): this;
on(event: 'collect', listener: (...args: Parameters<Handler[E]>) => unknown): this;
on(event: 'end', listener: (reason: string | null | undefined, collected: Set<Parameters<Handler[E]>[0]>) => void): this;
on(event: string, listener: unknown): this {
return super.on(event, listener as () => unknown);
}
once(event: "collect", listener: (...args: Parameters<Handler[E]>) => unknown): this;
once(event: "end", listener: (reason: string | null | undefined, collected: Set<Parameters<Handler[E]>[0]>) => void): this;
once(event: 'collect', listener: (...args: Parameters<Handler[E]>) => unknown): this;
once(event: 'end', listener: (reason: string | null | undefined, collected: Set<Parameters<Handler[E]>[0]>) => void): this;
once(event: string, listener: unknown): this {
return super.once(event, listener as () => unknown);
}

View File

@ -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<APIEmbed> = {}) {

View File

@ -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 };
}

View File

@ -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;

View File

@ -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;

View File

@ -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';

View File

@ -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<RESTPostAPIChatInputApplicationCommandsJSONBody> = {}) {}
@ -33,7 +33,7 @@ class SlashCommandB {
return this;
}
addRawOption(option: ReturnType<AllSlashOptions["toJSON"]>) {
addRawOption(option: ReturnType<AllSlashOptions['toJSON']>) {
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;
};

View File

@ -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<APIApplicationCommandOption, AACSO | AACNO | AACIO | AACSCO>
@ -53,7 +53,7 @@ export abstract class SlashBaseOption<DataType extends SlashBaseOptionTypes> {
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<AC extends boolean = boolean> extends SlashRequir
addChoices(choices: TypeArray<APIApplicationCommandOptionChoice<string>>): SlashStringOption<true> {
const ctx = this as SlashStringOption<true>;
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<AC extends boolean = boolean> extends SlashRequir
addChoices(choices: TypeArray<APIApplicationCommandOptionChoice<number>>): SlashNumberOption<true> {
const ctx = this as SlashNumberOption<true>;
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<APIApplicationCo
return this;
}
addRawOption(option: ReturnType<SlashSubcommandOption["toJSON"]>) {
addRawOption(option: ReturnType<SlashSubcommandOption['toJSON']>) {
this.data.options ??= [];
this.data.options.push(option);
}

View File

@ -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<T extends MessageComponents> extends BaseComponent<APIActionRowComponent<APIMessageActionRowComponent>> {
constructor({ components, ...data }: Partial<APIActionRowComponent<APIMessageActionRowComponent>> = {}) {
@ -10,7 +10,7 @@ export class MessageActionRow<T extends MessageComponents> extends BaseComponent
components: T[];
addComponents(component: TypeArray<T>): this {
this.components.concat(component);
this.components = this.components.concat(component);
return this;
}
@ -22,7 +22,7 @@ export class MessageActionRow<T extends MessageComponents> extends BaseComponent
toJSON(): APIActionRowComponent<APIMessageActionRowComponent> {
return {
...this.data,
components: this.components.map((c) => c.toJSON()),
} as APIActionRowComponent<ReturnType<T["toJSON"]>>;
components: this.components.map((c) => c.toJSON())
} as APIActionRowComponent<ReturnType<T['toJSON']>>;
}
}

View File

@ -1,4 +1,4 @@
import { APIBaseComponent, ComponentType } from "@biscuitland/common";
import { APIBaseComponent, ComponentType } from '@biscuitland/common';
export abstract class BaseComponent<TYPE extends Partial<APIBaseComponent<ComponentType>> = APIBaseComponent<ComponentType>,> {
constructor(public data: Partial<TYPE>) {}

View File

@ -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<ButtonStyle, ButtonStyle.Link>;

View File

@ -73,7 +73,7 @@ export class StringSelectMenu extends SelectMenu<APIStringSelectComponent> {
addOption(option: TypeArray<APISelectMenuOption>): 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<APIStringSelectComponent> {
}
export class StringSelectOption {
// biome-ignore lint/nursery/noEmptyBlockStatements: <explanation>
constructor(public data: Partial<APISelectMenuOption> = {}) {}
setLabel(label: string): this {

View File

@ -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<APITextInputComponent> {
constructor(data: Partial<APITextInputComponent> = {}) {

View File

@ -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';

View File

@ -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';

View File

@ -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<M extends RequestMethod, B = Record<string, any>, Q =
(M extends `${RequestMethod.Get}`
? unknown
: {
body?: B;
files?: RawFile[];
});
body?: B;
files?: RawFile[];
});
export type RestArguments<M extends RequestMethod, B = any, Q extends never | Record<string, any> = any> = M extends RequestMethod.Get
? Q extends never
? RequestObject<M, never, B>
: never
? RequestObject<M, never, B>
: never
: RequestObject<M, B, Q>;

View File

@ -1,4 +1,4 @@
import { RESTGetAPIGatewayResult, RESTGetAPIGatewayBotResult } from '@biscuitland/common';
import { RESTGetAPIGatewayBotResult, RESTGetAPIGatewayResult } from '@biscuitland/common';
import { RestArguments } from '../REST';
import { RequestMethod } from '../Router';

View File

@ -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';

View File

@ -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,

View File

@ -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<GatewayActivity, "created_at" | "id">[];
activities?: Omit<GatewayActivity, 'created_at' | 'id'>[];
/** The user's new status */
status: PresenceUpdateStatus;
}
@ -79,7 +80,7 @@ export interface UpdateVoiceState {
self_deaf: boolean;
}
export type ShardStatusUpdate = Pick<GatewayPresenceUpdateData, "activities" | "status">;
export type ShardStatusUpdate = Pick<GatewayPresenceUpdateData, 'activities' | 'status'>;
export interface RequestGuildMembersOptions extends GatewayRequestGuildMembersDataWithQuery, GatewayRequestGuildMembersDataWithUserIds {}
@ -96,7 +97,7 @@ export type AtLeastOne<
T,
U = {
[K in keyof T]: Pick<T, K>;
},
}
> = Partial<T> & 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
]
>;

View File

@ -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<GatewayManagerOptions> = {
const ShardManagerDefaults: Partial<ShardManagerOptions> = {
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<T>(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 extends Record<any, any>>(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 };

View File

@ -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: <explanation>
constructor(public shard: Shard) {}
acknowledge(ack = true) {
this.heart.ack = ack;
}
handleHeartbeat(_packet: Extract<GatewayReceivePayload, GatewayHeartbeatRequest>) {
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());
}
}
}

View File

@ -0,0 +1,3 @@
export * from './shard';
export * from './sharder';
export * from './shared';

View File

@ -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> | 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<Shard>((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: <explanation>
// biome-ignore lint/correctness/noUndeclaredVariables: <explanation>
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<T extends GatewaySendPayload = GatewaySendPayload>(priority: number, message: T) {
// biome-ignore lint/style/noArguments: <explanation>
// biome-ignore lint/correctness/noUndeclaredVariables: <explanation>
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: <explanation>
// biome-ignore lint/correctness/noUndeclaredVariables: <explanation>
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;
}
}

View File

@ -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<number, Shard> {
connectQueue: SequentialBucket;
options: Required<ShardManagerOptions>;
logger: Logger;
constructor(options: ShardManagerOptions) {
super();
this.options = Options<Required<ShardManagerOptions>>(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<APIGatewayBotInfo>(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<void> {
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<GatewayUpdatePresence>(1, {
op: GatewayOpcodes.PresenceUpdate,
d: payload,
});
}
setPresence(payload: GatewayUpdatePresence["d"]): Promise<void> | 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<Pick<GatewayVoiceStateUpdate["d"], "self_deaf" | "self_mute">>) {
const shardId = this.calculeShardId(guild_id);
this.logger.info(`Shard #${shardId} join voice ${channel_id} in ${guild_id}`);
return this.get(shardId)?.send<GatewayVoiceStateUpdate>(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<GatewayVoiceStateUpdate>(1, {
op: GatewayOpcodes.VoiceStateUpdate,
d: {
guild_id,
channel_id: null,
self_mute: false,
self_deaf: false,
},
});
}
}

View File

@ -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
}

View File

@ -1,3 +0,0 @@
export * from "./shard";
export * from "./sharder";
export * from "./shared";

View File

@ -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<true>;
heart: ShardHeart;
constructor(shard: Shard<true>) {
this.shard = shard;
this.heart = {
ack: false,
interval: 30_000,
};
}
acknowledge(ack = true) {
this.heart.ack = ack;
}
async handleHeartbeat(_packet: Extract<GatewayReceivePayload, GatewayHeartbeatRequest>) {
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<Connected extends boolean = true> {
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<true>) 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<Connected, WebSocket, null>;
heartbeater: When<Connected, ShardHeartbeater, null>;
data: When<Connected, ShardData, Partial<ShardData>>;
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<true> {
{
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<true>, 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<Shard<true>> {
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<Connected, WebSocket>;
this.websocket.onmessage = async (event) => {
return await (this as Shard<true>).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<true>).handleClose(event.code, event.reason);
};
return new Promise<Shard<true>>((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<true>);
};
// set hearbeater
const heartbeater = new ShardHeartbeater(this as Shard<true>);
this.heartbeater = heartbeater as When<Connected, ShardHeartbeater, null>;
});
}
/** Handle an incoming gateway message. */
async handleMessage(this: Shard<true>, 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<true>, 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<true>) {
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<true>, 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<true>) => {
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<void>((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<void>;
}

View File

@ -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<number, Shard> {
connectQueue: SequentialBucket;
options: Required<GatewayManagerOptions>;
logger: Logger;
constructor(_options: GatewayManagerOptions) {
super();
this.options = Options<Required<GatewayManagerOptions>>(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<void> {
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<true>(shardId, {
token: this.options.token,
intents: this.options.intents,
info: Options<APIGatewayBotInfo>(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());
}
}

View File

@ -1,3 +1,3 @@
export * from "./SharedTypes";
export * from "./constants";
export * from "./gateway";
export * from './SharedTypes';
export * from './constants';
export * from './discord';

View File

@ -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<void>) => Promise<unknown> | 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 <shardId, resolve()> */
queue: PriorityQueue<QueuedRequest> = 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<void> {
// 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<void> {
return await new Promise((resolve) => {
this.queue.push(resolve, priority);
void this.processQueue();
});
}
toString() {
return [...this.queue].toString();
}
}

View File

@ -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<void>) => Promise<unknown> | 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 <shardId, resolve()> */
queue = new PriorityQueue<QueuedRequest>();
/** 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<void> {
// 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<void> {
return await new Promise((resolve) => {
this.queue.push(resolve, priority);
// biome-ignore lint/complexity/noVoid: <explanation>
void this.processQueue();
});
}
toString() {
return [...this.queue].toString();
}
}
/**
* abstract node lol
*/
export interface AbstractNode<T> {
data: T;
next: this | null;
}
export interface QueuePusher<T> {
push(data: T): NonNullable<TNode<T>>;
}
export interface QueuePusherWithPriority<T> {
push(data: T, priority: number): NonNullable<PNode<T>>;
}
export class TNode<T> implements AbstractNode<T> {
data: T;
next: this | null;
constructor(data: T) {
this.data = data;
this.next = null;
}
static null<T>(list: AbstractNode<T> | null): list is null {
return !list;
}
}
export class PNode<T> extends TNode<T> {
priority: number;
constructor(data: T, priority: number) {
super(data);
this.priority = priority;
}
}
export abstract class Queue<T> {
protected abstract head: AbstractNode<T> | 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<T> {
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<T> extends Queue<T> implements QueuePusher<T> {
protected head: TNode<T> | null = null;
/**
* O(1)
*/
public push(data: T): NonNullable<TNode<T>> {
const temp = new TNode<T>(data);
temp.next = this.head;
this.head = temp;
return this.head;
}
}
export class PriorityQueue<T> extends Queue<T> implements QueuePusherWithPriority<T> {
protected head: PNode<T> | null = null;
/**
* O(#priorities)
*/
public push(data: T, priority: number): NonNullable<PNode<T>> {
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<T>;
start.next = temp;
return this.head;
}
}
export class SequentialBucket {
private connections: LinkedList<QueuedRequest>;
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<T>(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;
}
}

View File

@ -1,153 +0,0 @@
/**
* abstract node lol
*/
export interface AbstractNode<T> {
data: T;
next: this | null;
}
export interface QueuePusher<T> {
push(data: T): NonNullable<TNode<T>>;
}
export interface QueuePusherWithPriority<T> {
push(data: T, priority: number): NonNullable<PNode<T>>;
}
export class TNode<T> implements AbstractNode<T> {
data: T;
next: this | null;
constructor(data: T) {
this.data = data;
this.next = null;
}
static null<T>(list: AbstractNode<T> | null): list is null {
return !list;
}
}
export class PNode<T> extends TNode<T> {
priority: number;
constructor(data: T, priority: number) {
super(data);
this.priority = priority;
}
}
export abstract class Queue<T> {
protected abstract head: AbstractNode<T> | 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<T> {
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<T> extends Queue<T> implements QueuePusher<T> {
protected head: TNode<T> | null = null;
/**
* O(1)
*/
public push(data: T): NonNullable<TNode<T>> {
const temp = new TNode<T>(data);
temp.next = this.head;
this.head = temp;
return this.head;
}
}
export class PriorityQueue<T> extends Queue<T> implements QueuePusherWithPriority<T> {
protected head: PNode<T> | null = null;
/**
* O(#priorities)
*/
public push(data: T, priority: number): NonNullable<PNode<T>> {
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<T>;
start.next = temp;
return this.head;
}
}

View File

@ -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<QueuedRequest>;
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<T>(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;
}
}