diff --git a/packages/rest/.eslintrc.json b/packages/rest/.eslintrc.json index 99ef7cec8051..bf98d1b08630 100644 --- a/packages/rest/.eslintrc.json +++ b/packages/rest/.eslintrc.json @@ -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 + } } diff --git a/packages/rest/.lintstagedrc.js b/packages/rest/.lintstagedrc.js index dc17706a55ac..688ec53c18a3 100644 --- a/packages/rest/.lintstagedrc.js +++ b/packages/rest/.lintstagedrc.js @@ -1 +1,4 @@ -module.exports = require('../../.lintstagedrc.json'); +module.exports = { + ...require('../../.lintstagedrc.json'), + 'src/**.ts': 'vitest related --run --config ./vitest.config.ts', +}; diff --git a/packages/rest/__tests__/setup.ts b/packages/rest/__tests__/setup.ts new file mode 100644 index 000000000000..6d348870e67f --- /dev/null +++ b/packages/rest/__tests__/setup.ts @@ -0,0 +1,4 @@ +import { setDefaultStrategy } from '../src/environment.js'; +import { makeRequest } from '../src/strategies/undiciRequest.js'; + +setDefaultStrategy(makeRequest); diff --git a/packages/rest/package.json b/packages/rest/package.json index 041b68664318..cacbe3075484 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -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" }, diff --git a/packages/rest/src/environment.ts b/packages/rest/src/environment.ts new file mode 100644 index 000000000000..f59a1ce45ad9 --- /dev/null +++ b/packages/rest/src/environment.ts @@ -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; +} diff --git a/packages/rest/src/index.ts b/packages/rest/src/index.ts index b6af7b7ef7df..32934acf5466 100644 --- a/packages/rest/src/index.ts +++ b/packages/rest/src/index.ts @@ -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'; diff --git a/packages/rest/src/lib/CDN.ts b/packages/rest/src/lib/CDN.ts index a42966bd8829..38313f839a32 100644 --- a/packages/rest/src/lib/CDN.ts +++ b/packages/rest/src/lib/CDN.ts @@ -1,6 +1,4 @@ /* eslint-disable jsdoc/check-param-names */ - -import { URL } from 'node:url'; import { ALLOWED_EXTENSIONS, ALLOWED_SIZES, diff --git a/packages/rest/src/lib/REST.ts b/packages/rest/src/lib/REST.ts index 4b24ab208639..03723d3017fe 100644 --- a/packages/rest/src/lib/REST.ts +++ b/packages/rest/src/lib/REST.ts @@ -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 { + extends Pick { body: Readable | ReadableStream | null; } @@ -223,31 +223,16 @@ export interface RestEvents { handlerSweep: [sweptHandlers: Collection]; hashSweep: [sweptHashes: Collection]; 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: ((event: K, ...args: RestEvents[K]) => boolean) & - ((event: Exclude, ...args: any[]) => boolean); +export type RestEventsMap = { + [K in keyof RestEvents]: RestEvents[K]; +}; - off: ((event: K, listener: (...args: RestEvents[K]) => void) => this) & - ((event: Exclude, listener: (...args: any[]) => void) => this); - - on: ((event: K, listener: (...args: RestEvents[K]) => void) => this) & - ((event: Exclude, listener: (...args: any[]) => void) => this); - - once: ((event: K, listener: (...args: RestEvents[K]) => void) => this) & - ((event: Exclude, listener: (...args: any[]) => void) => this); - - removeAllListeners: ((event?: K) => this) & - ((event?: Exclude) => this); -} - -export class REST extends EventEmitter { +export class REST extends AsyncEventEmitter { 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) => { diff --git a/packages/rest/src/lib/RequestManager.ts b/packages/rest/src/lib/RequestManager.ts index 7b3abb8f6569..66920b03b668 100644 --- a/packages/rest/src/lib/RequestManager.ts +++ b/packages/rest/src/lib/RequestManager.ts @@ -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: ((event: K, ...args: RestEvents[K]) => boolean) & - ((event: Exclude, ...args: any[]) => boolean); - - off: ((event: K, listener: (...args: RestEvents[K]) => void) => this) & - ((event: Exclude, listener: (...args: any[]) => void) => this); - - on: ((event: K, listener: (...args: RestEvents[K]) => void) => this) & - ((event: Exclude, listener: (...args: any[]) => void) => this); - - once: ((event: K, listener: (...args: RestEvents[K]) => void) => this) & - ((event: Exclude, listener: (...args: any[]) => void) => this); - - removeAllListeners: ((event?: K) => this) & - ((event?: Exclude) => this); -} - /** * Represents the class that manages handlers for endpoints */ -export class RequestManager extends EventEmitter { +export class RequestManager extends AsyncEventEmitter { /** * 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'; } } diff --git a/packages/rest/src/lib/errors/HTTPError.ts b/packages/rest/src/lib/errors/HTTPError.ts index d9859e9400e8..f04982422c89 100644 --- a/packages/rest/src/lib/errors/HTTPError.ts +++ b/packages/rest/src/lib/errors/HTTPError.ts @@ -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, ) { - super(STATUS_CODES[status]); - + super(statusText); this.requestBody = { files: bodyData.files, json: bodyData.body }; } } diff --git a/packages/rest/src/lib/handlers/BurstHandler.ts b/packages/rest/src/lib/handlers/BurstHandler.ts index 971ac32d60a6..812abdde1ce0 100644 --- a/packages/rest/src/lib/handlers/BurstHandler.ts +++ b/packages/rest/src/lib/handlers/BurstHandler.ts @@ -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'; /** diff --git a/packages/rest/src/lib/handlers/SequentialHandler.ts b/packages/rest/src/lib/handlers/SequentialHandler.ts index f18919b18fe9..acfcc7072bfe 100644 --- a/packages/rest/src/lib/handlers/SequentialHandler.ts +++ b/packages/rest/src/lib/handlers/SequentialHandler.ts @@ -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 { diff --git a/packages/rest/src/lib/handlers/Shared.ts b/packages/rest/src/lib/handlers/Shared.ts index 42c3278b4bb0..b51c11202b17 100644 --- a/packages/rest/src/lib/handlers/Shared.ts +++ b/packages/rest/src/lib/handlers/Shared.ts @@ -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) { diff --git a/packages/rest/src/lib/utils/constants.ts b/packages/rest/src/lib/utils/constants.ts index 98f84ac9d4fd..27d15cdc51ef 100644 --- a/packages/rest/src/lib/utils/constants.ts +++ b/packages/rest/src/lib/utils/constants.ts @@ -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 { + return getDefaultStrategy()(...args); }, } as const satisfies Required; diff --git a/packages/rest/src/lib/utils/utils.ts b/packages/rest/src/lib/utils/utils.ts index 07d63e542d86..d7710a8ad96b 100644 --- a/packages/rest/src/lib/utils/utils.ts +++ b/packages/rest/src/lib/utils/utils.ts @@ -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 { + return new Promise((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; +} diff --git a/packages/rest/src/shared.ts b/packages/rest/src/shared.ts new file mode 100644 index 000000000000..b6af7b7ef7df --- /dev/null +++ b/packages/rest/src/shared.ts @@ -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; diff --git a/packages/rest/src/strategies/undiciRequest.ts b/packages/rest/src/strategies/undiciRequest.ts index 691ab365f268..aba78514d68d 100644 --- a/packages/rest/src/strategies/undiciRequest.ts +++ b/packages/rest/src/strategies/undiciRequest.ts @@ -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[1], undefined>; @@ -30,6 +30,7 @@ export async function makeRequest(url: string, init: RequestInit): Promise), status: res.statusCode, + statusText: STATUS_CODES[res.statusCode]!, ok: res.statusCode >= 200 && res.statusCode < 300, }; } diff --git a/packages/rest/src/web.ts b/packages/rest/src/web.ts new file mode 100644 index 000000000000..71b466dd74d7 --- /dev/null +++ b/packages/rest/src/web.ts @@ -0,0 +1,5 @@ +import { setDefaultStrategy } from './environment.js'; + +setDefaultStrategy(fetch); + +export * from './shared.js'; diff --git a/packages/rest/tsup.config.ts b/packages/rest/tsup.config.ts index 10ddbde0c605..ebed6ffa4b05 100644 --- a/packages/rest/tsup.config.ts +++ b/packages/rest/tsup.config.ts @@ -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()], }); diff --git a/packages/rest/vitest.config.ts b/packages/rest/vitest.config.ts new file mode 100644 index 000000000000..87a74b225522 --- /dev/null +++ b/packages/rest/vitest.config.ts @@ -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'], + }, + }), +); diff --git a/packages/util/src/functions/index.ts b/packages/util/src/functions/index.ts index f5fdc6654663..6010ae5544e8 100644 --- a/packages/util/src/functions/index.ts +++ b/packages/util/src/functions/index.ts @@ -1,3 +1,5 @@ export * from './lazy.js'; export * from './range.js'; export * from './calculateShardId.js'; +export * from './runtime.js'; +export * from './userAgentAppendix.js'; diff --git a/packages/util/src/functions/runtime.ts b/packages/util/src/functions/runtime.ts new file mode 100644 index 000000000000..60e82185c34f --- /dev/null +++ b/packages/util/src/functions/runtime.ts @@ -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; +} diff --git a/packages/util/src/functions/userAgentAppendix.ts b/packages/util/src/functions/userAgentAppendix.ts new file mode 100644 index 000000000000..95742aefa082 --- /dev/null +++ b/packages/util/src/functions/userAgentAppendix.ts @@ -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'; +} diff --git a/packages/ws/src/ws/WebSocketShard.ts b/packages/ws/src/ws/WebSocketShard.ts index 5671a69b28ff..04a2ffffd656 100644 --- a/packages/ws/src/ws/WebSocketShard.ts +++ b/packages/ws/src/ws/WebSocketShard.ts @@ -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 { private connection: WebSocket | null = null; @@ -177,13 +183,27 @@ export class WebSocketShard extends AsyncEventEmitter { 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; @@ -247,9 +267,9 @@ export class WebSocketShard extends AsyncEventEmitter { 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; @@ -260,14 +280,22 @@ export class WebSocketShard extends AsyncEventEmitter { ]); if (shouldClose) { + let outerResolve: () => void; + const promise = new Promise((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']); } @@ -461,17 +489,23 @@ export class WebSocketShard extends AsyncEventEmitter { this.isAck = false; } - private async unpackMessage(data: ArrayBuffer | Buffer, isBinary: boolean): Promise { - const decompressable = new Uint8Array(data); - + private async unpackMessage(data: Data, isBinary: boolean): Promise { // 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); @@ -524,8 +558,8 @@ export class WebSocketShard extends AsyncEventEmitter { 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; } diff --git a/yarn.lock b/yarn.lock index e6fc8b6b4b62..b0ceffbaf40f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" @@ -13528,17 +13522,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" @@ -15125,7 +15108,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 @@ -17748,6 +17731,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" @@ -20514,13 +20504,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" @@ -21707,15 +21690,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" @@ -23708,16 +23682,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" @@ -24259,16 +24223,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"