feat: no-de-no-de, now with extra buns (#9683)

BREAKING CHANGE: The REST and RequestManager classes now extend AsyncEventEmitter
from `@vladfrangu/async_event_emitter`, which aids in cross-compatibility
between Node, Deno, Bun, CF Workers, Vercel Functions, etc.

BREAKING CHANGE: DefaultUserAgentAppendix has been adapted to support multiple
different platforms (previously mentioned Deno, Bun, CF Workers, etc)

BREAKING CHANGE: the entry point for `@discordjs/rest` will now differ
in non-node-like environments (CF Workers, etc.)

Co-authored-by: Suneet Tipirneni <77477100+suneettipirneni@users.noreply.github.com>
Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
Co-authored-by: suneettipirneni <suneettipirneni@icloud.com>
This commit is contained in:
Vlad Frangu 2023-07-17 09:27:57 +03:00 committed by GitHub
parent 351a18bc35
commit 386f206caf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 272 additions and 179 deletions

View file

@ -1,3 +1,11 @@
{
"extends": "../../.eslintrc.json"
"extends": "../../.eslintrc.json",
"rules": {
"n/prefer-global/url": 0,
"n/prefer-global/url-search-params": 0,
"n/prefer-global/buffer": 0,
"n/prefer-global/process": 0,
"no-restricted-globals": 0,
"unicorn/prefer-node-protocol": 0
}
}

View file

@ -1 +1,4 @@
module.exports = require('../../.lintstagedrc.json');
module.exports = {
...require('../../.lintstagedrc.json'),
'src/**.ts': 'vitest related --run --config ./vitest.config.ts',
};

View file

@ -0,0 +1,4 @@
import { setDefaultStrategy } from '../src/environment.js';
import { makeRequest } from '../src/strategies/undiciRequest.js';
setDefaultStrategy(makeRequest);

View file

@ -14,14 +14,19 @@
"changelog": "git cliff --prepend ./CHANGELOG.md -u -c ./cliff.toml -r ../../ --include-path 'packages/rest/*'",
"release": "cliff-jumper"
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"typings": "./dist/index.d.ts",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
"node": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"default": {
"types": "./dist/web.d.ts",
"import": "./dist/web.mjs",
"require": "./dist/web.js"
}
},
"./*": {
"types": "./dist/strategies/*.d.ts",
@ -65,8 +70,9 @@
"@discordjs/util": "workspace:^",
"@sapphire/async-queue": "^1.5.0",
"@sapphire/snowflake": "^3.5.1",
"@vladfrangu/async_event_emitter": "^2.2.2",
"discord-api-types": "^0.37.45",
"file-type": "^18.4.0",
"magic-bytes.js": "^1.0.14",
"tslib": "^2.5.2",
"undici": "^5.22.1"
},

View file

@ -0,0 +1,11 @@
import type { RESTOptions } from './shared.js';
let defaultStrategy: RESTOptions['makeRequest'];
export function setDefaultStrategy(newStrategy: RESTOptions['makeRequest']) {
defaultStrategy = newStrategy;
}
export function getDefaultStrategy() {
return defaultStrategy;
}

View file

@ -1,15 +1,7 @@
export * from './lib/CDN.js';
export * from './lib/errors/DiscordAPIError.js';
export * from './lib/errors/HTTPError.js';
export * from './lib/errors/RateLimitError.js';
export * from './lib/RequestManager.js';
export * from './lib/REST.js';
export * from './lib/utils/constants.js';
export { calculateUserDefaultAvatarIndex, makeURLSearchParams, parseResponse } from './lib/utils/utils.js';
import { shouldUseGlobalFetchAndWebSocket } from '@discordjs/util';
import { setDefaultStrategy } from './environment.js';
import { makeRequest } from './strategies/undiciRequest.js';
/**
* The {@link https://github.com/discordjs/discord.js/blob/main/packages/rest/#readme | @discordjs/rest} version
* that you are currently using.
*/
// This needs to explicitly be `string` so it is not typed as a "const string" that gets injected by esbuild
export const version = '[VI]{{inject}}[/VI]' as string;
setDefaultStrategy(shouldUseGlobalFetchAndWebSocket() ? fetch : makeRequest);
export * from './shared.js';

View file

@ -1,6 +1,4 @@
/* eslint-disable jsdoc/check-param-names */
import { URL } from 'node:url';
import {
ALLOWED_EXTENSIONS,
ALLOWED_SIZES,

View file

@ -1,7 +1,7 @@
import { EventEmitter } from 'node:events';
import type { Readable } from 'node:stream';
import type { ReadableStream } from 'node:stream/web';
import type { Collection } from '@discordjs/collection';
import { AsyncEventEmitter } from '@vladfrangu/async_event_emitter';
import type { Dispatcher, RequestInit, Response } from 'undici';
import { CDN } from './CDN.js';
import {
@ -204,7 +204,7 @@ export interface APIRequest {
}
export interface ResponseLike
extends Pick<Response, 'arrayBuffer' | 'bodyUsed' | 'headers' | 'json' | 'ok' | 'status' | 'text'> {
extends Pick<Response, 'arrayBuffer' | 'bodyUsed' | 'headers' | 'json' | 'ok' | 'status' | 'statusText' | 'text'> {
body: Readable | ReadableStream | null;
}
@ -223,31 +223,16 @@ export interface RestEvents {
handlerSweep: [sweptHandlers: Collection<string, IHandler>];
hashSweep: [sweptHashes: Collection<string, HashData>];
invalidRequestWarning: [invalidRequestInfo: InvalidRequestWarningData];
newListener: [name: string, listener: (...args: any) => void];
rateLimited: [rateLimitInfo: RateLimitData];
removeListener: [name: string, listener: (...args: any) => void];
response: [request: APIRequest, response: ResponseLike];
restDebug: [info: string];
}
export interface REST {
emit: (<K extends keyof RestEvents>(event: K, ...args: RestEvents[K]) => boolean) &
(<S extends string | symbol>(event: Exclude<S, keyof RestEvents>, ...args: any[]) => boolean);
export type RestEventsMap = {
[K in keyof RestEvents]: RestEvents[K];
};
off: (<K extends keyof RestEvents>(event: K, listener: (...args: RestEvents[K]) => void) => this) &
(<S extends string | symbol>(event: Exclude<S, keyof RestEvents>, listener: (...args: any[]) => void) => this);
on: (<K extends keyof RestEvents>(event: K, listener: (...args: RestEvents[K]) => void) => this) &
(<S extends string | symbol>(event: Exclude<S, keyof RestEvents>, listener: (...args: any[]) => void) => this);
once: (<K extends keyof RestEvents>(event: K, listener: (...args: RestEvents[K]) => void) => this) &
(<S extends string | symbol>(event: Exclude<S, keyof RestEvents>, listener: (...args: any[]) => void) => this);
removeAllListeners: (<K extends keyof RestEvents>(event?: K) => this) &
(<S extends string | symbol>(event?: Exclude<S, keyof RestEvents>) => this);
}
export class REST extends EventEmitter {
export class REST extends AsyncEventEmitter<RestEventsMap> {
public readonly cdn: CDN;
public readonly requestManager: RequestManager;
@ -256,9 +241,13 @@ export class REST extends EventEmitter {
super();
this.cdn = new CDN(options.cdn ?? DefaultRestOptions.cdn);
this.requestManager = new RequestManager(options)
// @ts-expect-error For some reason ts can't infer these types
.on(RESTEvents.Debug, this.emit.bind(this, RESTEvents.Debug))
// @ts-expect-error For some reason ts can't infer these types
.on(RESTEvents.RateLimited, this.emit.bind(this, RESTEvents.RateLimited))
// @ts-expect-error For some reason ts can't infer these types
.on(RESTEvents.InvalidRequestWarning, this.emit.bind(this, RESTEvents.InvalidRequestWarning))
// @ts-expect-error For some reason ts can't infer these types
.on(RESTEvents.HashSweep, this.emit.bind(this, RESTEvents.HashSweep));
this.on('newListener', (name, listener) => {

View file

@ -1,12 +1,9 @@
import { Blob, Buffer } from 'node:buffer';
import { EventEmitter } from 'node:events';
import { setInterval, clearInterval } from 'node:timers';
import type { URLSearchParams } from 'node:url';
import { Collection } from '@discordjs/collection';
import { lazy } from '@discordjs/util';
import { DiscordSnowflake } from '@sapphire/snowflake';
import { AsyncEventEmitter } from '@vladfrangu/async_event_emitter';
import { filetypeinfo } from 'magic-bytes.js';
import type { RequestInit, BodyInit, Dispatcher, Agent } from 'undici';
import type { RESTOptions, ResponseLike, RestEvents } from './REST.js';
import type { RESTOptions, ResponseLike, RestEventsMap } from './REST.js';
import { BurstHandler } from './handlers/BurstHandler.js';
import { SequentialHandler } from './handlers/SequentialHandler.js';
import type { IHandler } from './interfaces/Handler.js';
@ -17,9 +14,7 @@ import {
OverwrittenMimeTypes,
RESTEvents,
} from './utils/constants.js';
// Make this a lazy dynamic import as file-type is a pure ESM package
const getFileType = lazy(async () => import('file-type'));
import { isBufferLike } from './utils/utils.js';
/**
* Represents a file to be added to the request
@ -32,7 +27,7 @@ export interface RawFile {
/**
* The actual data for the file
*/
data: Buffer | boolean | number | string;
data: Buffer | Uint8Array | boolean | number | string;
/**
* An explicit key to use for key of the formdata field for this file.
* When not provided, the index of the file in the files array is used in the form `files[${index}]`.
@ -162,27 +157,10 @@ export interface HashData {
value: string;
}
export interface RequestManager {
emit: (<K extends keyof RestEvents>(event: K, ...args: RestEvents[K]) => boolean) &
(<S extends string | symbol>(event: Exclude<S, keyof RestEvents>, ...args: any[]) => boolean);
off: (<K extends keyof RestEvents>(event: K, listener: (...args: RestEvents[K]) => void) => this) &
(<S extends string | symbol>(event: Exclude<S, keyof RestEvents>, listener: (...args: any[]) => void) => this);
on: (<K extends keyof RestEvents>(event: K, listener: (...args: RestEvents[K]) => void) => this) &
(<S extends string | symbol>(event: Exclude<S, keyof RestEvents>, listener: (...args: any[]) => void) => this);
once: (<K extends keyof RestEvents>(event: K, listener: (...args: RestEvents[K]) => void) => this) &
(<S extends string | symbol>(event: Exclude<S, keyof RestEvents>, listener: (...args: any[]) => void) => this);
removeAllListeners: (<K extends keyof RestEvents>(event?: K) => this) &
(<S extends string | symbol>(event?: Exclude<S, keyof RestEvents>) => this);
}
/**
* Represents the class that manages handlers for endpoints
*/
export class RequestManager extends EventEmitter {
export class RequestManager extends AsyncEventEmitter<RestEventsMap> {
/**
* The {@link https://undici.nodejs.org/#/docs/api/Agent | Agent} for all requests
* performed by this manager.
@ -216,9 +194,9 @@ export class RequestManager extends EventEmitter {
#token: string | null = null;
private hashTimer!: NodeJS.Timer;
private hashTimer!: NodeJS.Timer | number;
private handlerTimer!: NodeJS.Timer;
private handlerTimer!: NodeJS.Timer | number;
public readonly options: RESTOptions;
@ -269,7 +247,9 @@ export class RequestManager extends EventEmitter {
// Fire event
this.emit(RESTEvents.HashSweep, sweptHashes);
}, this.options.hashSweepInterval).unref();
}, this.options.hashSweepInterval);
this.hashTimer.unref?.();
}
if (this.options.handlerSweepInterval !== 0 && this.options.handlerSweepInterval !== Number.POSITIVE_INFINITY) {
@ -292,7 +272,9 @@ export class RequestManager extends EventEmitter {
// Fire event
this.emit(RESTEvents.HandlerSweep, sweptHandlers);
}, this.options.handlerSweepInterval).unref();
}, this.options.handlerSweepInterval);
this.handlerTimer.unref?.();
}
}
@ -425,14 +407,18 @@ export class RequestManager extends EventEmitter {
// FormData.append only accepts a string or Blob.
// https://developer.mozilla.org/en-US/docs/Web/API/Blob/Blob#parameters
// The Blob constructor accepts TypedArray/ArrayBuffer, strings, and Blobs.
if (Buffer.isBuffer(file.data)) {
if (isBufferLike(file.data)) {
// Try to infer the content type from the buffer if one isn't passed
const { fileTypeFromBuffer } = await getFileType();
let contentType = file.contentType;
if (!contentType) {
const parsedType = (await fileTypeFromBuffer(file.data))?.mime;
const [parsedType] = filetypeinfo(file.data);
if (parsedType) {
contentType = OverwrittenMimeTypes[parsedType as keyof typeof OverwrittenMimeTypes] ?? parsedType;
contentType =
OverwrittenMimeTypes[parsedType.mime as keyof typeof OverwrittenMimeTypes] ??
parsedType.mime ??
'application/octet-stream';
}
}

View file

@ -1,4 +1,3 @@
import { STATUS_CODES } from 'node:http';
import type { InternalRequest } from '../RequestManager.js';
import type { RequestBody } from './DiscordAPIError.js';
@ -12,18 +11,19 @@ export class HTTPError extends Error {
/**
* @param status - The status code of the response
* @param statusText - The status text of the response
* @param method - The method of the request that erred
* @param url - The url of the request that erred
* @param bodyData - The unparsed data for the request that errored
*/
public constructor(
public status: number,
statusText: string,
public method: string,
public url: string,
bodyData: Pick<InternalRequest, 'body' | 'files'>,
) {
super(STATUS_CODES[status]);
super(statusText);
this.requestBody = { files: bodyData.files, json: bodyData.body };
}
}

View file

@ -1,10 +1,9 @@
import { setTimeout as sleep } from 'node:timers/promises';
import type { RequestInit } from 'undici';
import type { ResponseLike } from '../REST.js';
import type { HandlerRequestData, RequestManager, RouteData } from '../RequestManager.js';
import type { IHandler } from '../interfaces/Handler.js';
import { RESTEvents } from '../utils/constants.js';
import { onRateLimit } from '../utils/utils.js';
import { onRateLimit, sleep } from '../utils/utils.js';
import { handleErrors, incrementInvalidCount, makeNetworkRequest } from './Shared.js';
/**

View file

@ -1,11 +1,10 @@
import { setTimeout as sleep } from 'node:timers/promises';
import { AsyncQueue } from '@sapphire/async-queue';
import type { RequestInit } from 'undici';
import type { RateLimitData, ResponseLike } from '../REST.js';
import type { HandlerRequestData, RequestManager, RouteData } from '../RequestManager.js';
import type { IHandler } from '../interfaces/Handler.js';
import { RESTEvents } from '../utils/constants.js';
import { hasSublimit, onRateLimit } from '../utils/utils.js';
import { hasSublimit, onRateLimit, sleep } from '../utils/utils.js';
import { handleErrors, incrementInvalidCount, makeNetworkRequest } from './Shared.js';
const enum QueueType {

View file

@ -1,5 +1,3 @@
import { setTimeout, clearTimeout } from 'node:timers';
import { Response } from 'undici';
import type { RequestInit } from 'undici';
import type { ResponseLike } from '../REST.js';
import type { HandlerRequestData, RequestManager, RouteData } from '../RequestManager.js';
@ -65,7 +63,7 @@ export async function makeNetworkRequest(
retries: number,
) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), manager.options.timeout).unref();
const timeout = setTimeout(() => controller.abort(), manager.options.timeout);
if (requestData.signal) {
// If the user signal was aborted, abort the controller, else abort the local signal.
// The reason why we don't re-use the user's signal, is because users may use the same signal for multiple
@ -135,7 +133,7 @@ export async function handleErrors(
}
// We are out of retries, throw an error
throw new HTTPError(status, method, url, requestData);
throw new HTTPError(status, res.statusText, method, url, requestData);
} else {
// Handle possible malformed requests
if (status >= 400 && status < 500) {

View file

@ -1,11 +1,7 @@
import process from 'node:process';
import { lazy } from '@discordjs/util';
import { getUserAgentAppendix } from '@discordjs/util';
import { APIVersion } from 'discord-api-types/v10';
import type { RESTOptions } from '../REST.js';
const getUndiciRequest = lazy(async () => {
return import('../../strategies/undiciRequest.js');
});
import { getDefaultStrategy } from '../../environment.js';
import type { RESTOptions, ResponseLike } from '../REST.js';
export const DefaultUserAgent =
`DiscordBot (https://discord.js.org, [VI]{{inject}}[/VI])` as `DiscordBot (https://discord.js.org, ${string})`;
@ -13,7 +9,7 @@ export const DefaultUserAgent =
/**
* The default string to append onto the user agent.
*/
export const DefaultUserAgentAppendix = process.release?.name === 'node' ? `Node.js/${process.version}` : '';
export const DefaultUserAgentAppendix = getUserAgentAppendix();
export const DefaultRestOptions = {
agent: null,
@ -32,9 +28,8 @@ export const DefaultRestOptions = {
hashSweepInterval: 14_400_000, // 4 Hours
hashLifetime: 86_400_000, // 24 Hours
handlerSweepInterval: 3_600_000, // 1 Hour
async makeRequest(...args) {
const strategy = await getUndiciRequest();
return strategy.makeRequest(...args);
async makeRequest(...args): Promise<ResponseLike> {
return getDefaultStrategy()(...args);
},
} as const satisfies Required<RESTOptions>;

View file

@ -1,4 +1,3 @@
import { URLSearchParams } from 'node:url';
import type { RESTPatchAPIChannelJSONBody, Snowflake } from 'discord-api-types/v10';
import type { RateLimitData, ResponseLike } from '../REST.js';
import { type RequestManager, RequestMethod } from '../RequestManager.js';
@ -121,3 +120,23 @@ export async function onRateLimit(manager: RequestManager, rateLimitData: RateLi
export function calculateUserDefaultAvatarIndex(userId: Snowflake) {
return Number(BigInt(userId) >> 22n) % 6;
}
/**
* Sleeps for a given amount of time.
*
* @param ms - The amount of time (in milliseconds) to sleep for
*/
export async function sleep(ms: number): Promise<void> {
return new Promise<void>((resolve) => {
setTimeout(() => resolve(), ms);
});
}
/**
* Verifies that a value is a buffer-like object.
*
* @param value - The value to check
*/
export function isBufferLike(value: unknown): value is ArrayBuffer | Buffer | Uint8Array | Uint8ClampedArray {
return value instanceof ArrayBuffer || value instanceof Uint8Array || value instanceof Uint8ClampedArray;
}

View file

@ -0,0 +1,15 @@
export * from './lib/CDN.js';
export * from './lib/errors/DiscordAPIError.js';
export * from './lib/errors/HTTPError.js';
export * from './lib/errors/RateLimitError.js';
export * from './lib/RequestManager.js';
export * from './lib/REST.js';
export * from './lib/utils/constants.js';
export { calculateUserDefaultAvatarIndex, makeURLSearchParams, parseResponse } from './lib/utils/utils.js';
/**
* The {@link https://github.com/discordjs/discord.js/blob/main/packages/rest/#readme | @discordjs/rest} version
* that you are currently using.
*/
// This needs to explicitly be `string` so it is not typed as a "const string" that gets injected by esbuild
export const version = '[VI]{{inject}}[/VI]' as string;

View file

@ -1,8 +1,8 @@
import { Buffer } from 'node:buffer';
import { STATUS_CODES } from 'node:http';
import { URLSearchParams } from 'node:url';
import { types } from 'node:util';
import { type RequestInit, request } from 'undici';
import type { ResponseLike } from '../index.js';
import type { ResponseLike } from '../shared.js';
export type RequestOptions = Exclude<Parameters<typeof request>[1], undefined>;
@ -30,6 +30,7 @@ export async function makeRequest(url: string, init: RequestInit): Promise<Respo
},
headers: new Headers(res.headers as Record<string, string[] | string>),
status: res.statusCode,
statusText: STATUS_CODES[res.statusCode]!,
ok: res.statusCode >= 200 && res.statusCode < 300,
};
}

5
packages/rest/src/web.ts Normal file
View file

@ -0,0 +1,5 @@
import { setDefaultStrategy } from './environment.js';
setDefaultStrategy(fetch);
export * from './shared.js';

View file

@ -2,6 +2,6 @@ import { esbuildPluginVersionInjector } from 'esbuild-plugin-version-injector';
import { createTsupConfig } from '../../tsup.config.js';
export default createTsupConfig({
entry: ['src/index.ts', 'src/strategies/*.ts'],
entry: ['src/index.ts', 'src/web.ts', 'src/strategies/*.ts'],
esbuildPlugins: [esbuildPluginVersionInjector()],
});

View file

@ -0,0 +1,11 @@
import { defineProject, mergeConfig } from 'vitest/config';
import configShared from '../../vitest.config.js';
export default mergeConfig(
configShared,
defineProject({
test: {
setupFiles: ['./__tests__/setup.ts'],
},
}),
);

View file

@ -1,3 +1,5 @@
export * from './lazy.js';
export * from './range.js';
export * from './calculateShardId.js';
export * from './runtime.js';
export * from './userAgentAppendix.js';

View file

@ -0,0 +1,12 @@
export function shouldUseGlobalFetchAndWebSocket() {
// Browser env and deno when ran directly
if (typeof globalThis.process === 'undefined') {
return 'fetch' in globalThis && 'WebSocket' in globalThis;
}
if ('versions' in globalThis.process) {
return 'deno' in globalThis.process.versions || 'bun' in globalThis.process.versions;
}
return false;
}

View file

@ -0,0 +1,52 @@
/* eslint-disable n/prefer-global/process */
/* eslint-disable no-restricted-globals */
/**
* Resolves the user agent appendix string for the current environment.
*/
export function getUserAgentAppendix(): string {
// https://vercel.com/docs/concepts/functions/edge-functions/edge-runtime#check-if-you're-running-on-the-edge-runtime
// @ts-expect-error Vercel Edge functions
if (typeof globalThis.EdgeRuntime !== 'undefined') {
return 'Vercel-Edge-Functions';
}
// @ts-expect-error Cloudflare Workers
if (typeof globalThis.R2 !== 'undefined' && typeof globalThis.WebSocketPair !== 'undefined') {
// https://developers.cloudflare.com/workers/runtime-apis/web-standards/#navigatoruseragent
return 'Cloudflare-Workers';
}
// https://docs.netlify.com/edge-functions/api/#netlify-global-object
// @ts-expect-error Netlify Edge functions
if (typeof globalThis.Netlify !== 'undefined') {
return 'Netlify-Edge-Functions';
}
// Most (if not all) edge environments will have `process` defined. Within a web browser we'll extract it using `navigator.userAgent`.
if (typeof globalThis.process !== 'object') {
// @ts-expect-error web env
if (typeof globalThis.navigator === 'object') {
// @ts-expect-error web env
return globalThis.navigator.userAgent;
}
return 'UnknownEnvironment';
}
if ('versions' in globalThis.process) {
if ('deno' in globalThis.process.versions) {
return `Deno/${globalThis.process.versions.deno}`;
}
if ('bun' in globalThis.process.versions) {
return `Bun/${globalThis.process.versions.bun}`;
}
if ('node' in globalThis.process.versions) {
return `Node.js/${globalThis.process.versions.node}`;
}
}
return 'UnknownEnvironment';
}

View file

@ -20,7 +20,7 @@ import {
type GatewayReceivePayload,
type GatewaySendPayload,
} from 'discord-api-types/v10';
import { WebSocket, type RawData } from 'ws';
import { WebSocket, type Data } from 'ws';
import type { Inflate } from 'zlib-sync';
import type { IContextFetchingStrategy } from '../strategies/context/IContextFetchingStrategy.js';
import { ImportantGatewayOpcodes, getInitialSendRateLimitState } from '../utils/constants.js';
@ -80,6 +80,12 @@ export interface SendRateLimitState {
resetAt: number;
}
// TODO(vladfrangu): enable this once https://github.com/oven-sh/bun/issues/3392 is solved
// const WebSocketConstructor: typeof WebSocket = shouldUseGlobalFetchAndWebSocket()
// ? (globalThis as any).WebSocket
// : WebSocket;
const WebSocketConstructor: typeof WebSocket = WebSocket;
export class WebSocketShard extends AsyncEventEmitter<WebSocketShardEventsMap> {
private connection: WebSocket | null = null;
@ -179,13 +185,27 @@ export class WebSocketShard extends AsyncEventEmitter<WebSocketShardEventsMap> {
const session = await this.strategy.retrieveSessionInfo(this.id);
const url = `${session?.resumeURL ?? this.strategy.options.gatewayInformation.url}?${params.toString()}`;
this.debug([`Connecting to ${url}`]);
const connection = new WebSocket(url, { handshakeTimeout: this.strategy.options.handshakeTimeout ?? undefined })
.on('message', this.onMessage.bind(this))
.on('error', this.onError.bind(this))
.on('close', this.onClose.bind(this));
const connection = new WebSocketConstructor(url, {
handshakeTimeout: this.strategy.options.handshakeTimeout ?? undefined,
});
connection.binaryType = 'arraybuffer';
connection.onmessage = (event) => {
void this.onMessage(event.data, event.data instanceof ArrayBuffer);
};
connection.onerror = (event) => {
this.onError(event.error);
};
connection.onclose = (event) => {
void this.onClose(event.code);
};
this.connection = connection;
this.#status = WebSocketShardStatus.Connecting;
@ -249,9 +269,9 @@ export class WebSocketShard extends AsyncEventEmitter<WebSocketShardEventsMap> {
if (this.connection) {
// No longer need to listen to messages
this.connection.removeAllListeners('message');
this.connection.onmessage = null;
// Prevent a reconnection loop by unbinding the main close event
this.connection.removeAllListeners('close');
this.connection.onclose = null;
const shouldClose = this.connection.readyState === WebSocket.OPEN;
@ -262,14 +282,22 @@ export class WebSocketShard extends AsyncEventEmitter<WebSocketShardEventsMap> {
]);
if (shouldClose) {
let outerResolve: () => void;
const promise = new Promise<void>((resolve) => {
outerResolve = resolve;
});
this.connection.onclose = outerResolve!;
this.connection.close(options.code, options.reason);
await once(this.connection, 'close');
await promise;
this.emit(WebSocketShardEvents.Closed, { code: options.code });
}
// Lastly, remove the error event.
// Doing this earlier would cause a hard crash in case an error event fired on our `close` call
this.connection.removeAllListeners('error');
this.connection.onerror = null;
} else {
this.debug(['Destroying a shard that has no connection; please open an issue on GitHub']);
}
@ -476,17 +504,23 @@ export class WebSocketShard extends AsyncEventEmitter<WebSocketShardEventsMap> {
this.isAck = false;
}
private async unpackMessage(data: ArrayBuffer | Buffer, isBinary: boolean): Promise<GatewayReceivePayload | null> {
const decompressable = new Uint8Array(data);
private async unpackMessage(data: Data, isBinary: boolean): Promise<GatewayReceivePayload | null> {
// Deal with no compression
if (!isBinary) {
return JSON.parse(this.textDecoder.decode(decompressable)) as GatewayReceivePayload;
try {
return JSON.parse(data as string) as GatewayReceivePayload;
} catch {
// This is a non-JSON payload / (at the time of writing this comment) emitted by bun wrongly interpreting custom close codes https://github.com/oven-sh/bun/issues/3392
return null;
}
}
const decompressable = new Uint8Array(data as ArrayBuffer);
// Deal with identify compress
if (this.useIdentifyCompress) {
return new Promise((resolve, reject) => {
// eslint-disable-next-line promise/prefer-await-to-callbacks
inflate(decompressable, { chunkSize: 65_535 }, (err, result) => {
if (err) {
reject(err);
@ -539,8 +573,8 @@ export class WebSocketShard extends AsyncEventEmitter<WebSocketShardEventsMap> {
return null;
}
private async onMessage(data: RawData, isBinary: boolean) {
const payload = await this.unpackMessage(data as ArrayBuffer | Buffer, isBinary);
private async onMessage(data: Data, isBinary: boolean) {
const payload = await this.unpackMessage(data, isBinary);
if (!payload) {
return;
}

View file

@ -2318,13 +2318,14 @@ __metadata:
"@sapphire/snowflake": ^3.5.1
"@types/node": 18.16.14
"@vitest/coverage-c8": ^0.31.1
"@vladfrangu/async_event_emitter": ^2.2.2
cross-env: ^7.0.3
discord-api-types: ^0.37.45
esbuild-plugin-version-injector: ^1.1.0
eslint: ^8.41.0
eslint-config-neon: ^0.1.47
eslint-formatter-pretty: ^5.0.0
file-type: ^18.4.0
magic-bytes.js: ^1.0.14
prettier: ^2.8.8
tslib: ^2.5.2
tsup: ^6.7.0
@ -6039,13 +6040,6 @@ __metadata:
languageName: node
linkType: hard
"@tokenizer/token@npm:^0.3.0":
version: 0.3.0
resolution: "@tokenizer/token@npm:0.3.0"
checksum: 1d575d02d2a9f0c5a4ca5180635ebd2ad59e0f18b42a65f3d04844148b49b3db35cf00b6012a1af2d59c2ab3caca59451c5689f747ba8667ee586ad717ee58e1
languageName: node
linkType: hard
"@tootallnate/once@npm:1":
version: 1.1.2
resolution: "@tootallnate/once@npm:1.1.2"
@ -13540,17 +13534,6 @@ __metadata:
languageName: node
linkType: hard
"file-type@npm:^18.4.0":
version: 18.4.0
resolution: "file-type@npm:18.4.0"
dependencies:
readable-web-to-node-stream: ^3.0.2
strtok3: ^7.0.0
token-types: ^5.0.1
checksum: 191aa44b662417d496efc51bfb061da4c51cddfe2e3f7467b580964c3d83dbd88f76662368ea231a84d489a7d8cfc0bc2df9fefc439b519c2e6ddc498122dae0
languageName: node
linkType: hard
"file-uri-to-path@npm:1.0.0":
version: 1.0.0
resolution: "file-uri-to-path@npm:1.0.0"
@ -15137,7 +15120,7 @@ __metadata:
languageName: node
linkType: hard
"ieee754@npm:^1.1.13, ieee754@npm:^1.2.1":
"ieee754@npm:^1.1.13":
version: 1.2.1
resolution: "ieee754@npm:1.2.1"
checksum: 5144c0c9815e54ada181d80a0b810221a253562422e7c6c3a60b1901154184f49326ec239d618c416c1c5945a2e197107aee8d986a3dd836b53dffefd99b5e7e
@ -17760,6 +17743,13 @@ __metadata:
languageName: node
linkType: hard
"magic-bytes.js@npm:^1.0.14":
version: 1.0.14
resolution: "magic-bytes.js@npm:1.0.14"
checksum: 5431948f5134ea27134a2e9c197ce5fdc89677682d365f275b0193a816a037cb9fc1c5eeeb541920d653e3c44f9022d007ef4b159ec4f1a814945be9f6be8abc
languageName: node
linkType: hard
"magic-string@npm:^0.27.0":
version: 0.27.0
resolution: "magic-string@npm:0.27.0"
@ -20526,13 +20516,6 @@ __metadata:
languageName: node
linkType: hard
"peek-readable@npm:^5.0.0":
version: 5.0.0
resolution: "peek-readable@npm:5.0.0"
checksum: bef5ceb50586eb42e14efba274ac57ffe97f0ed272df9239ce029f688f495d9bf74b2886fa27847c706a9db33acda4b7d23bbd09a2d21eb4c2a54da915117414
languageName: node
linkType: hard
"peek-stream@npm:^1.1.0":
version: 1.1.3
resolution: "peek-stream@npm:1.1.3"
@ -21719,15 +21702,6 @@ __metadata:
languageName: node
linkType: hard
"readable-web-to-node-stream@npm:^3.0.2":
version: 3.0.2
resolution: "readable-web-to-node-stream@npm:3.0.2"
dependencies:
readable-stream: ^3.6.0
checksum: 8c56cc62c68513425ddfa721954875b382768f83fa20e6b31e365ee00cbe7a3d6296f66f7f1107b16cd3416d33aa9f1680475376400d62a081a88f81f0ea7f9c
languageName: node
linkType: hard
"readdirp@npm:~3.6.0":
version: 3.6.0
resolution: "readdirp@npm:3.6.0"
@ -23720,16 +23694,6 @@ __metadata:
languageName: node
linkType: hard
"strtok3@npm:^7.0.0":
version: 7.0.0
resolution: "strtok3@npm:7.0.0"
dependencies:
"@tokenizer/token": ^0.3.0
peek-readable: ^5.0.0
checksum: 2ebe7ad8f2aea611dec6742cf6a42e82764892a362907f7ce493faf334501bf981ce21c828dcc300457e6d460dc9c34d644ededb3b01dcb9e37559203cf1748c
languageName: node
linkType: hard
"style-loader@npm:^3.3.2":
version: 3.3.3
resolution: "style-loader@npm:3.3.3"
@ -24271,16 +24235,6 @@ __metadata:
languageName: node
linkType: hard
"token-types@npm:^5.0.1":
version: 5.0.1
resolution: "token-types@npm:5.0.1"
dependencies:
"@tokenizer/token": ^0.3.0
ieee754: ^1.2.1
checksum: 32780123bc6ce8b6a2231d860445c994a02a720abf38df5583ea957aa6626873cd1c4dd8af62314da4cf16ede00c379a765707a3b06f04b8808c38efdae1c785
languageName: node
linkType: hard
"toml@npm:^3.0.0":
version: 3.0.0
resolution: "toml@npm:3.0.0"