refactor: native zlib support (#10243)

* refactor: remove zlib-sync

* fix: bad length check

* refactor: support both options

BREAKING CHANGE: renamed compression related options

* chore: fix doc comment

* chore: update debug messages

* chore: better wording

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>

* chore: suggested changes

* chore: better naming

* refactor: lazy node:zlib import and lib detection

* chore: zlib capitalization

* fix: use proper var

* refactor: better inflate check

Co-authored-by: Aura <kyradiscord@gmail.com>

* chore: debug label

Co-authored-by: Superchupu <53496941+SuperchupuDev@users.noreply.github.com>

---------

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
Co-authored-by: Aura <kyradiscord@gmail.com>
Co-authored-by: Superchupu <53496941+SuperchupuDev@users.noreply.github.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
DD 2024-05-11 18:32:05 +03:00 committed by GitHub
parent 7816ec2e6b
commit 20258f94bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 160 additions and 63 deletions

View file

@ -50,7 +50,10 @@ const manager = new WebSocketManager({
intents: 0, // for no intents intents: 0, // for no intents
rest, rest,
// uncomment if you have zlib-sync installed and want to use compression // uncomment if you have zlib-sync installed and want to use compression
// compression: CompressionMethod.ZlibStream, // compression: CompressionMethod.ZlibSync,
// alternatively, we support compression using node's native `node:zlib` module:
// compression: CompressionMethod.ZlibNative,
}); });
manager.on(WebSocketShardEvents.Dispatch, (event) => { manager.on(WebSocketShardEvents.Dispatch, (event) => {

View file

@ -18,13 +18,19 @@ export enum Encoding {
* Valid compression methods * Valid compression methods
*/ */
export enum CompressionMethod { export enum CompressionMethod {
ZlibStream = 'zlib-stream', ZlibNative,
ZlibSync,
} }
export const DefaultDeviceProperty = `@discordjs/ws [VI]{{inject}}[/VI]` as `@discordjs/ws ${string}`; export const DefaultDeviceProperty = `@discordjs/ws [VI]{{inject}}[/VI]` as `@discordjs/ws ${string}`;
const getDefaultSessionStore = lazy(() => new Collection<number, SessionInfo | null>()); const getDefaultSessionStore = lazy(() => new Collection<number, SessionInfo | null>());
export const CompressionParameterMap = {
[CompressionMethod.ZlibNative]: 'zlib-stream',
[CompressionMethod.ZlibSync]: 'zlib-stream',
} as const satisfies Record<CompressionMethod, string>;
/** /**
* Default options used by the manager * Default options used by the manager
*/ */
@ -46,6 +52,7 @@ export const DefaultWebSocketManagerOptions = {
version: APIVersion, version: APIVersion,
encoding: Encoding.JSON, encoding: Encoding.JSON,
compression: null, compression: null,
useIdentifyCompression: false,
retrieveSessionInfo(shardId) { retrieveSessionInfo(shardId) {
const store = getDefaultSessionStore(); const store = getDefaultSessionStore();
return store.get(shardId) ?? null; return store.get(shardId) ?? null;

View file

@ -96,9 +96,9 @@ export interface OptionalWebSocketManagerOptions {
*/ */
buildStrategy(manager: WebSocketManager): IShardingStrategy; buildStrategy(manager: WebSocketManager): IShardingStrategy;
/** /**
* The compression method to use * The transport compression method to use - mutually exclusive with `useIdentifyCompression`
* *
* @defaultValue `null` (no compression) * @defaultValue `null` (no transport compression)
*/ */
compression: CompressionMethod | null; compression: CompressionMethod | null;
/** /**
@ -176,6 +176,12 @@ export interface OptionalWebSocketManagerOptions {
* Function used to store session information for a given shard * Function used to store session information for a given shard
*/ */
updateSessionInfo(shardId: number, sessionInfo: SessionInfo | null): Awaitable<void>; updateSessionInfo(shardId: number, sessionInfo: SessionInfo | null): Awaitable<void>;
/**
* Whether to use the `compress` option when identifying
*
* @defaultValue `false`
*/
useIdentifyCompression: boolean;
/** /**
* The gateway version to use * The gateway version to use
* *

View file

@ -1,11 +1,10 @@
/* eslint-disable id-length */
import { Buffer } from 'node:buffer'; import { Buffer } from 'node:buffer';
import { once } from 'node:events'; import { once } from 'node:events';
import { clearInterval, clearTimeout, setInterval, setTimeout } from 'node:timers'; import { clearInterval, clearTimeout, setInterval, setTimeout } from 'node:timers';
import { setTimeout as sleep } from 'node:timers/promises'; import { setTimeout as sleep } from 'node:timers/promises';
import { URLSearchParams } from 'node:url'; import { URLSearchParams } from 'node:url';
import { TextDecoder } from 'node:util'; import { TextDecoder } from 'node:util';
import { inflate } from 'node:zlib'; import type * as nativeZlib from 'node:zlib';
import { Collection } from '@discordjs/collection'; import { Collection } from '@discordjs/collection';
import { lazy, shouldUseGlobalFetchAndWebSocket } from '@discordjs/util'; import { lazy, shouldUseGlobalFetchAndWebSocket } from '@discordjs/util';
import { AsyncQueue } from '@sapphire/async-queue'; import { AsyncQueue } from '@sapphire/async-queue';
@ -21,13 +20,20 @@ import {
type GatewaySendPayload, type GatewaySendPayload,
} from 'discord-api-types/v10'; } from 'discord-api-types/v10';
import { WebSocket, type Data } from 'ws'; import { WebSocket, type Data } from 'ws';
import type { Inflate } from 'zlib-sync'; import type * as ZlibSync from 'zlib-sync';
import type { IContextFetchingStrategy } from '../strategies/context/IContextFetchingStrategy.js'; import type { IContextFetchingStrategy } from '../strategies/context/IContextFetchingStrategy';
import { ImportantGatewayOpcodes, getInitialSendRateLimitState } from '../utils/constants.js'; import {
CompressionMethod,
CompressionParameterMap,
ImportantGatewayOpcodes,
getInitialSendRateLimitState,
} from '../utils/constants.js';
import type { SessionInfo } from './WebSocketManager.js'; import type { SessionInfo } from './WebSocketManager.js';
// eslint-disable-next-line promise/prefer-await-to-then /* eslint-disable promise/prefer-await-to-then */
const getZlibSync = lazy(async () => import('zlib-sync').then((mod) => mod.default).catch(() => null)); const getZlibSync = lazy(async () => import('zlib-sync').then((mod) => mod.default).catch(() => null));
const getNativeZlib = lazy(async () => import('node:zlib').then((mod) => mod).catch(() => null));
/* eslint-enable promise/prefer-await-to-then */
export enum WebSocketShardEvents { export enum WebSocketShardEvents {
Closed = 'closed', Closed = 'closed',
@ -86,9 +92,9 @@ const WebSocketConstructor: typeof WebSocket = shouldUseGlobalFetchAndWebSocket(
export class WebSocketShard extends AsyncEventEmitter<WebSocketShardEventsMap> { export class WebSocketShard extends AsyncEventEmitter<WebSocketShardEventsMap> {
private connection: WebSocket | null = null; private connection: WebSocket | null = null;
private useIdentifyCompress = false; private nativeInflate: nativeZlib.Inflate | null = null;
private inflate: Inflate | null = null; private zLibSyncInflate: ZlibSync.Inflate | null = null;
private readonly textDecoder = new TextDecoder(); private readonly textDecoder = new TextDecoder();
@ -120,6 +126,18 @@ export class WebSocketShard extends AsyncEventEmitter<WebSocketShardEventsMap> {
#status: WebSocketShardStatus = WebSocketShardStatus.Idle; #status: WebSocketShardStatus = WebSocketShardStatus.Idle;
private identifyCompressionEnabled = false;
/**
* @privateRemarks
*
* This is needed because `this.strategy.options.compression` is not an actual reflection of the compression method
* used, but rather the compression method that the user wants to use. This is because the libraries could just be missing.
*/
private get transportCompressionEnabled() {
return this.strategy.options.compression !== null && (this.nativeInflate ?? this.zLibSyncInflate) !== null;
}
public get status(): WebSocketShardStatus { public get status(): WebSocketShardStatus {
return this.#status; return this.#status;
} }
@ -161,21 +179,63 @@ export class WebSocketShard extends AsyncEventEmitter<WebSocketShardEventsMap> {
throw new Error("Tried to connect a shard that wasn't idle"); throw new Error("Tried to connect a shard that wasn't idle");
} }
const { version, encoding, compression } = this.strategy.options; const { version, encoding, compression, useIdentifyCompression } = this.strategy.options;
this.identifyCompressionEnabled = useIdentifyCompression;
// eslint-disable-next-line id-length
const params = new URLSearchParams({ v: version, encoding }); const params = new URLSearchParams({ v: version, encoding });
if (compression) { if (compression !== null) {
const zlib = await getZlibSync(); if (useIdentifyCompression) {
if (zlib) { console.warn('WebSocketShard: transport compression is enabled, disabling identify compression');
params.append('compress', compression); this.identifyCompressionEnabled = false;
this.inflate = new zlib.Inflate({ }
chunkSize: 65_535,
to: 'string', params.append('compress', CompressionParameterMap[compression]);
});
} else if (!this.useIdentifyCompress) { switch (compression) {
this.useIdentifyCompress = true; case CompressionMethod.ZlibNative: {
console.warn( const zlib = await getNativeZlib();
'WebSocketShard: Compression is enabled but zlib-sync is not installed, falling back to identify compress', if (zlib) {
); const inflate = zlib.createInflate({
chunkSize: 65_535,
flush: zlib.constants.Z_SYNC_FLUSH,
});
inflate.on('error', (error) => {
this.emit(WebSocketShardEvents.Error, { error });
});
this.nativeInflate = inflate;
} else {
console.warn('WebSocketShard: Compression is set to native but node:zlib is not available.');
params.delete('compress');
}
break;
}
case CompressionMethod.ZlibSync: {
const zlib = await getZlibSync();
if (zlib) {
this.zLibSyncInflate = new zlib.Inflate({
chunkSize: 65_535,
to: 'string',
});
} else {
console.warn('WebSocketShard: Compression is set to zlib-sync, but it is not installed.');
params.delete('compress');
}
break;
}
}
}
if (this.identifyCompressionEnabled) {
const zlib = await getNativeZlib();
if (!zlib) {
console.warn('WebSocketShard: Identify compression is enabled, but node:zlib is not available.');
this.identifyCompressionEnabled = false;
} }
} }
@ -451,28 +511,29 @@ export class WebSocketShard extends AsyncEventEmitter<WebSocketShardEventsMap> {
`shard id: ${this.id.toString()}`, `shard id: ${this.id.toString()}`,
`shard count: ${this.strategy.options.shardCount}`, `shard count: ${this.strategy.options.shardCount}`,
`intents: ${this.strategy.options.intents}`, `intents: ${this.strategy.options.intents}`,
`compression: ${this.inflate ? 'zlib-stream' : this.useIdentifyCompress ? 'identify' : 'none'}`, `compression: ${this.transportCompressionEnabled ? CompressionParameterMap[this.strategy.options.compression!] : this.identifyCompressionEnabled ? 'identify' : 'none'}`,
]); ]);
const d: GatewayIdentifyData = { const data: GatewayIdentifyData = {
token: this.strategy.options.token, token: this.strategy.options.token,
properties: this.strategy.options.identifyProperties, properties: this.strategy.options.identifyProperties,
intents: this.strategy.options.intents, intents: this.strategy.options.intents,
compress: this.useIdentifyCompress, compress: this.identifyCompressionEnabled,
shard: [this.id, this.strategy.options.shardCount], shard: [this.id, this.strategy.options.shardCount],
}; };
if (this.strategy.options.largeThreshold) { if (this.strategy.options.largeThreshold) {
d.large_threshold = this.strategy.options.largeThreshold; data.large_threshold = this.strategy.options.largeThreshold;
} }
if (this.strategy.options.initialPresence) { if (this.strategy.options.initialPresence) {
d.presence = this.strategy.options.initialPresence; data.presence = this.strategy.options.initialPresence;
} }
await this.send({ await this.send({
op: GatewayOpcodes.Identify, op: GatewayOpcodes.Identify,
d, // eslint-disable-next-line id-length
d: data,
}); });
await this.waitForEvent(WebSocketShardEvents.Ready, this.strategy.options.readyTimeout); await this.waitForEvent(WebSocketShardEvents.Ready, this.strategy.options.readyTimeout);
@ -490,6 +551,7 @@ export class WebSocketShard extends AsyncEventEmitter<WebSocketShardEventsMap> {
this.replayedEvents = 0; this.replayedEvents = 0;
return this.send({ return this.send({
op: GatewayOpcodes.Resume, op: GatewayOpcodes.Resume,
// eslint-disable-next-line id-length
d: { d: {
token: this.strategy.options.token, token: this.strategy.options.token,
seq: session.sequence, seq: session.sequence,
@ -507,6 +569,7 @@ export class WebSocketShard extends AsyncEventEmitter<WebSocketShardEventsMap> {
await this.send({ await this.send({
op: GatewayOpcodes.Heartbeat, op: GatewayOpcodes.Heartbeat,
// eslint-disable-next-line id-length
d: session?.sequence ?? null, d: session?.sequence ?? null,
}); });
@ -514,6 +577,14 @@ export class WebSocketShard extends AsyncEventEmitter<WebSocketShardEventsMap> {
this.isAck = false; this.isAck = false;
} }
private parseInflateResult(result: any): GatewayReceivePayload | null {
if (!result) {
return null;
}
return JSON.parse(typeof result === 'string' ? result : this.textDecoder.decode(result)) as GatewayReceivePayload;
}
private async unpackMessage(data: Data, isBinary: boolean): Promise<GatewayReceivePayload | null> { private async unpackMessage(data: Data, isBinary: boolean): Promise<GatewayReceivePayload | null> {
// Deal with no compression // Deal with no compression
if (!isBinary) { if (!isBinary) {
@ -528,10 +599,12 @@ export class WebSocketShard extends AsyncEventEmitter<WebSocketShardEventsMap> {
const decompressable = new Uint8Array(data as ArrayBuffer); const decompressable = new Uint8Array(data as ArrayBuffer);
// Deal with identify compress // Deal with identify compress
if (this.useIdentifyCompress) { if (this.identifyCompressionEnabled) {
return new Promise((resolve, reject) => { // eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve, reject) => {
const zlib = (await getNativeZlib())!;
// eslint-disable-next-line promise/prefer-await-to-callbacks // eslint-disable-next-line promise/prefer-await-to-callbacks
inflate(decompressable, { chunkSize: 65_535 }, (err, result) => { zlib.inflate(decompressable, { chunkSize: 65_535 }, (err, result) => {
if (err) { if (err) {
reject(err); reject(err);
return; return;
@ -542,42 +615,50 @@ export class WebSocketShard extends AsyncEventEmitter<WebSocketShardEventsMap> {
}); });
} }
// Deal with gw wide zlib-stream compression // Deal with transport compression
if (this.inflate) { if (this.transportCompressionEnabled) {
const l = decompressable.length;
const flush = const flush =
l >= 4 && decompressable.length >= 4 &&
decompressable[l - 4] === 0x00 && decompressable.at(-4) === 0x00 &&
decompressable[l - 3] === 0x00 && decompressable.at(-3) === 0x00 &&
decompressable[l - 2] === 0xff && decompressable.at(-2) === 0xff &&
decompressable[l - 1] === 0xff; decompressable.at(-1) === 0xff;
const zlib = (await getZlibSync())!; if (this.nativeInflate) {
this.inflate.push(Buffer.from(decompressable), flush ? zlib.Z_SYNC_FLUSH : zlib.Z_NO_FLUSH); this.nativeInflate.write(decompressable, 'binary');
if (this.inflate.err) { if (!flush) {
this.emit(WebSocketShardEvents.Error, { return null;
error: new Error(`${this.inflate.err}${this.inflate.msg ? `: ${this.inflate.msg}` : ''}`), }
});
const [result] = await once(this.nativeInflate, 'data');
return this.parseInflateResult(result);
} else if (this.zLibSyncInflate) {
const zLibSync = (await getZlibSync())!;
this.zLibSyncInflate.push(Buffer.from(decompressable), flush ? zLibSync.Z_SYNC_FLUSH : zLibSync.Z_NO_FLUSH);
if (this.zLibSyncInflate.err) {
this.emit(WebSocketShardEvents.Error, {
error: new Error(
`${this.zLibSyncInflate.err}${this.zLibSyncInflate.msg ? `: ${this.zLibSyncInflate.msg}` : ''}`,
),
});
}
if (!flush) {
return null;
}
const { result } = this.zLibSyncInflate;
return this.parseInflateResult(result);
} }
if (!flush) {
return null;
}
const { result } = this.inflate;
if (!result) {
return null;
}
return JSON.parse(typeof result === 'string' ? result : this.textDecoder.decode(result)) as GatewayReceivePayload;
} }
this.debug([ this.debug([
'Received a message we were unable to decompress', 'Received a message we were unable to decompress',
`isBinary: ${isBinary.toString()}`, `isBinary: ${isBinary.toString()}`,
`useIdentifyCompress: ${this.useIdentifyCompress.toString()}`, `identifyCompressionEnabled: ${this.identifyCompressionEnabled.toString()}`,
`inflate: ${Boolean(this.inflate).toString()}`, `inflate: ${this.transportCompressionEnabled ? CompressionMethod[this.strategy.options.compression!] : 'none'}`,
]); ]);
return null; return null;
@ -838,7 +919,7 @@ export class WebSocketShard extends AsyncEventEmitter<WebSocketShardEventsMap> {
messages.length > 1 messages.length > 1
? `\n${messages ? `\n${messages
.slice(1) .slice(1)
.map((m) => ` ${m}`) .map((message) => ` ${message}`)
.join('\n')}` .join('\n')}`
: '' : ''
}`; }`;