mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-19 05:55:34 +09:00
1272 lines
41 KiB
TypeScript
1272 lines
41 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import GeoIP from '@app/features/app/state/GeoIP';
|
|
import Authentication from '@app/features/auth/state/Authentication';
|
|
import {takeFastConnect} from '@app/features/gateway/transport/FastConnect';
|
|
import {
|
|
type CompressionType,
|
|
GatewayCompression,
|
|
isGatewayCompressionError,
|
|
} from '@app/features/gateway/transport/GatewayCompression';
|
|
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
|
import {
|
|
DISPATCH_FLUSH_DELAY_MS,
|
|
DISPATCH_IDLE_RETRY_TIMEOUT_MS,
|
|
DISPATCH_IDLE_TIMEOUT_MS,
|
|
isCriticalGatewayDispatch,
|
|
selectGatewayDispatchFlushMode,
|
|
shouldRetryIdleWait,
|
|
shouldSkipIdleWait,
|
|
} from '@app/features/gateway/transport/GatewayDispatchScheduling';
|
|
import {
|
|
formatGatewayReadyTimings,
|
|
type GatewayTimings,
|
|
type RpcTimings,
|
|
} from '@app/features/gateway/transport/GatewayTimingsFormatter';
|
|
import AppStorage, {PRESERVED_RESET_STORAGE_KEYS} from '@app/features/platform/state/PersistentStorage';
|
|
import {Logger, LogLevel} from '@app/features/platform/utils/AppLogger';
|
|
import {ExponentialBackoff} from '@app/features/platform/utils/RetryScheduler';
|
|
import LayerManager from '@app/features/ui/state/LayerManager';
|
|
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
|
import type {GatewayCustomStatusPayload} from '@app/features/user/state/CustomStatus';
|
|
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
|
|
import type {GatewayErrorCode} from '@fluxer/constants/src/GatewayConstants';
|
|
import {GatewayCloseCodes, GatewayOpcodes} from '@fluxer/constants/src/GatewayConstants';
|
|
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
|
|
import EventEmitter from 'eventemitter3';
|
|
|
|
const GATEWAY_TIMEOUTS = {
|
|
HeartbeatAck: 15000,
|
|
ResumeResponse: 15000,
|
|
ResumeWindow: 180000,
|
|
MinReconnect: 1000,
|
|
MaxReconnect: 60000,
|
|
ReconnectSpread: 2000,
|
|
Hello: 20000,
|
|
} as const;
|
|
export const GatewayState = {
|
|
Disconnected: 'DISCONNECTED',
|
|
Connecting: 'CONNECTING',
|
|
Connected: 'CONNECTED',
|
|
Reconnecting: 'RECONNECTING',
|
|
} as const;
|
|
|
|
export type GatewayState = ValueOf<typeof GatewayState>;
|
|
|
|
export interface GatewayPayload {
|
|
op: number;
|
|
d?: unknown;
|
|
s?: number;
|
|
t?: string;
|
|
}
|
|
|
|
export interface GatewaySocketProperties {
|
|
os: string;
|
|
browser: string;
|
|
device: string;
|
|
locale: string;
|
|
user_agent: string;
|
|
browser_version: string;
|
|
os_version: string;
|
|
build_version: string;
|
|
desktop_app_version?: string | null;
|
|
desktop_app_channel?: string | null;
|
|
desktop_arch?: string | null;
|
|
desktop_os?: string | null;
|
|
e2ee_capable?: boolean;
|
|
latitude?: string;
|
|
longitude?: string;
|
|
}
|
|
|
|
export interface GatewayPresence {
|
|
status: string;
|
|
afk: boolean;
|
|
mobile: boolean;
|
|
custom_status?: GatewayCustomStatusPayload | null;
|
|
}
|
|
|
|
export interface GatewayVoiceStateUpdateParams {
|
|
guild_id: string | null;
|
|
channel_id: string | null;
|
|
self_mute: boolean;
|
|
self_deaf: boolean;
|
|
self_video: boolean;
|
|
self_stream: boolean;
|
|
viewer_stream_keys?: ReadonlyArray<string>;
|
|
connection_id: string | null;
|
|
}
|
|
|
|
export interface GatewaySocketOptions {
|
|
token: string;
|
|
apiVersion: number;
|
|
properties: GatewaySocketProperties;
|
|
presence?: GatewayPresence;
|
|
compression?: CompressionType;
|
|
identifyFlags?: number;
|
|
initialGuildId?: string | null;
|
|
}
|
|
|
|
interface GatewayResumeProbeOptions {
|
|
accelerateReconnect?: boolean;
|
|
}
|
|
|
|
export interface GatewayErrorData {
|
|
code: GatewayErrorCode;
|
|
message: string;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function parseGatewayPayload(json: string): GatewayPayload {
|
|
const parsed: unknown = JSON.parse(json);
|
|
if (!isRecord(parsed) || typeof parsed.op !== 'number' || !Number.isFinite(parsed.op)) {
|
|
throw new Error('Gateway payload missing numeric opcode');
|
|
}
|
|
if (parsed.s !== undefined && typeof parsed.s !== 'number') {
|
|
throw new Error('Gateway payload sequence must be numeric when present');
|
|
}
|
|
if (parsed.t !== undefined && typeof parsed.t !== 'string') {
|
|
throw new Error('Gateway payload event type must be a string when present');
|
|
}
|
|
return {
|
|
op: parsed.op,
|
|
d: parsed.d,
|
|
s: parsed.s,
|
|
t: parsed.t,
|
|
};
|
|
}
|
|
|
|
function isGatewayErrorData(value: unknown): value is GatewayErrorData {
|
|
return isRecord(value) && typeof value.code === 'number' && typeof value.message === 'string';
|
|
}
|
|
|
|
export interface GatewaySocketEvents {
|
|
connecting: () => void;
|
|
connected: () => void;
|
|
ready: (data: unknown) => void;
|
|
resumed: (data: unknown) => void;
|
|
disconnect: (event: {code: number; reason: string; wasClean: boolean}) => void;
|
|
error: (error: Error | Event | CloseEvent) => void;
|
|
fatalError: (error: Error) => void;
|
|
gatewayError: (error: GatewayErrorData) => void;
|
|
message: (payload: GatewayPayload) => void;
|
|
dispatch: (type: string, data: unknown) => void;
|
|
stateChange: (newState: GatewayState, oldState: GatewayState) => void;
|
|
heartbeat: (sequence: number) => void;
|
|
heartbeatAck: () => void;
|
|
networkStatusChange: (online: boolean) => void;
|
|
}
|
|
|
|
type GatewaySocketEventArgs<K extends keyof GatewaySocketEvents> = GatewaySocketEvents[K] extends (
|
|
...args: infer Args
|
|
) => void
|
|
? Args
|
|
: never;
|
|
|
|
export class GatewaySocket extends EventEmitter<GatewaySocketEvents> {
|
|
private readonly log: Logger;
|
|
private reconnectBackoff: ExponentialBackoff;
|
|
private socket: WebSocket | null = null;
|
|
private connectionState: GatewayState = GatewayState.Disconnected;
|
|
private activeSessionId: string | null = null;
|
|
private lastSequenceNumber = 0;
|
|
private lastReconnectAt = 0;
|
|
private connectStartedAt = 0;
|
|
private heartbeatIntervalMs: number | null = null;
|
|
private heartbeatTimeoutId: number | null = null;
|
|
private heartbeatAckTimeoutId: number | null = null;
|
|
private awaitingHeartbeatAck = false;
|
|
private lastHeartbeatAckAt: number | null = null;
|
|
private lastHeartbeatSentAt: number | null = null;
|
|
private lastGatewayMessageAt: number | null = null;
|
|
private helloTimeoutId: number | null = null;
|
|
private resumeTimeoutId: number | null = null;
|
|
private reconnectTimeoutId: number | null = null;
|
|
private invalidSessionTimeoutId: number | null = null;
|
|
private isUserInitiatedDisconnect = false;
|
|
private shouldReconnectImmediately = false;
|
|
private deferredEmitQueue: Array<() => void> = [];
|
|
private deferredEmitTimeoutId: number | null = null;
|
|
private deferredEmitIdleId: number | null = null;
|
|
private criticalWorkScheduled = false;
|
|
private payloadDecompressor: GatewayCompression | null = null;
|
|
private compressionFallbackInProgress = false;
|
|
|
|
constructor(
|
|
private readonly gatewayUrlBase: string,
|
|
private readonly options: GatewaySocketOptions,
|
|
private readonly gatewayUrlWrapper?: (url: string) => string,
|
|
) {
|
|
super();
|
|
this.log = new Logger('Gateway');
|
|
this.reconnectBackoff = new ExponentialBackoff({
|
|
minDelay: GATEWAY_TIMEOUTS.MinReconnect,
|
|
maxDelay: GATEWAY_TIMEOUTS.MaxReconnect,
|
|
});
|
|
}
|
|
|
|
private emitDeferred<K extends keyof GatewaySocketEvents>(event: K, ...args: GatewaySocketEventArgs<K>): void {
|
|
this.deferredEmitQueue.push(() => {
|
|
(this.emit as (event: K, ...args: GatewaySocketEventArgs<K>) => boolean)(event, ...args);
|
|
});
|
|
const dispatchType = event === 'dispatch' ? (args[0] as string) : null;
|
|
this.scheduleDeferredFlush(dispatchType);
|
|
}
|
|
|
|
private scheduleDeferredFlush(dispatchType: string | null): void {
|
|
if (isCriticalGatewayDispatch(dispatchType)) {
|
|
this.criticalWorkScheduled = true;
|
|
}
|
|
if (selectGatewayDispatchFlushMode(dispatchType) === 'immediate') {
|
|
this.clearDeferredFlushWork();
|
|
this.flushDeferredEmits();
|
|
return;
|
|
}
|
|
if (this.hasDeferredFlushWork()) return;
|
|
this.deferredEmitTimeoutId = window.setTimeout(() => {
|
|
this.deferredEmitTimeoutId = null;
|
|
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
|
|
this.flushDeferredEmits();
|
|
return;
|
|
}
|
|
this.queueDeferredIdleFlush();
|
|
}, DISPATCH_FLUSH_DELAY_MS);
|
|
}
|
|
|
|
private queueDeferredIdleFlush(): void {
|
|
if (shouldSkipIdleWait(this.criticalWorkScheduled, typeof window.requestIdleCallback === 'function')) {
|
|
this.flushDeferredEmits();
|
|
return;
|
|
}
|
|
this.deferredEmitIdleId = window.requestIdleCallback(
|
|
(deadline) => {
|
|
this.deferredEmitIdleId = null;
|
|
if (!shouldRetryIdleWait(deadline.didTimeout, deadline.timeRemaining())) {
|
|
this.flushDeferredEmits();
|
|
return;
|
|
}
|
|
this.deferredEmitIdleId = window.requestIdleCallback(
|
|
() => {
|
|
this.deferredEmitIdleId = null;
|
|
this.flushDeferredEmits();
|
|
},
|
|
{timeout: DISPATCH_IDLE_RETRY_TIMEOUT_MS},
|
|
);
|
|
},
|
|
{timeout: DISPATCH_IDLE_TIMEOUT_MS},
|
|
);
|
|
}
|
|
|
|
private hasDeferredFlushWork(): boolean {
|
|
return this.deferredEmitTimeoutId != null || this.deferredEmitIdleId != null;
|
|
}
|
|
|
|
private clearDeferredFlushWork(): void {
|
|
if (this.deferredEmitTimeoutId != null) {
|
|
clearTimeout(this.deferredEmitTimeoutId);
|
|
this.deferredEmitTimeoutId = null;
|
|
}
|
|
if (this.deferredEmitIdleId != null && typeof window.cancelIdleCallback === 'function') {
|
|
window.cancelIdleCallback(this.deferredEmitIdleId);
|
|
}
|
|
this.deferredEmitIdleId = null;
|
|
}
|
|
|
|
private flushDeferredEmits(): void {
|
|
this.clearDeferredFlushWork();
|
|
this.criticalWorkScheduled = false;
|
|
const queue = this.deferredEmitQueue;
|
|
this.deferredEmitQueue = [];
|
|
for (const emitFn of queue) {
|
|
emitFn();
|
|
}
|
|
}
|
|
|
|
connect(): void {
|
|
if (this.connectionState === GatewayState.Connecting || this.connectionState === GatewayState.Connected) {
|
|
this.log.debug('Ignoring connect: already connecting or connected');
|
|
return;
|
|
}
|
|
this.isUserInitiatedDisconnect = false;
|
|
this.connectStartedAt = Date.now();
|
|
this.updateState(GatewayState.Connecting);
|
|
this.openSocket();
|
|
}
|
|
|
|
disconnect(code = 1000, reason = 'Client disconnecting', resumable = false): void {
|
|
this.log.info(`Disconnect requested: [${code}] ${reason}, resumable=${resumable}`);
|
|
this.isUserInitiatedDisconnect = !resumable;
|
|
this.clearHelloTimeout();
|
|
this.clearResumeTimeout();
|
|
if (this.reconnectTimeoutId != null) {
|
|
clearTimeout(this.reconnectTimeoutId);
|
|
this.reconnectTimeoutId = null;
|
|
}
|
|
if (this.invalidSessionTimeoutId != null) {
|
|
clearTimeout(this.invalidSessionTimeoutId);
|
|
this.invalidSessionTimeoutId = null;
|
|
}
|
|
this.stopHeartbeat();
|
|
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
|
try {
|
|
this.socket.close(code, reason);
|
|
} catch (error) {
|
|
this.log.error('Error while closing WebSocket', error);
|
|
}
|
|
}
|
|
if (resumable) {
|
|
this.updateState(GatewayState.Reconnecting);
|
|
this.scheduleReconnect();
|
|
} else {
|
|
this.updateState(GatewayState.Disconnected);
|
|
}
|
|
}
|
|
|
|
simulateNetworkDisconnect(): void {
|
|
if (!this.isConnected()) {
|
|
this.log.warn('Cannot simulate network disconnect: not connected');
|
|
return;
|
|
}
|
|
this.log.info('Simulating network disconnect with resumable close');
|
|
this.disconnect(4000, 'Simulated network disconnect', true);
|
|
}
|
|
|
|
reset(shouldReconnect = true): void {
|
|
this.log.info(`Resetting gateway connection (reconnect=${shouldReconnect})`);
|
|
this.clearHelloTimeout();
|
|
this.clearResumeTimeout();
|
|
if (this.reconnectTimeoutId != null) {
|
|
clearTimeout(this.reconnectTimeoutId);
|
|
this.reconnectTimeoutId = null;
|
|
}
|
|
this.stopHeartbeat();
|
|
this.clearSession();
|
|
this.resetBackoffInternal();
|
|
this.teardownSocket();
|
|
this.updateState(GatewayState.Disconnected);
|
|
if (shouldReconnect) {
|
|
this.shouldReconnectImmediately = true;
|
|
this.connect();
|
|
}
|
|
}
|
|
|
|
handleNetworkStatusChange(online: boolean): void {
|
|
this.log.info(`Network status: ${online ? 'online' : 'offline'}`);
|
|
this.emitDeferred('networkStatusChange', online);
|
|
if (!online) return;
|
|
this.probeAfterResume('online', {accelerateReconnect: true});
|
|
}
|
|
|
|
probeAfterResume(reason: string, options: GatewayResumeProbeOptions = {}): void {
|
|
this.log.info(`Probing gateway after resume: reason=${reason}, state=${this.connectionState}`);
|
|
switch (this.connectionState) {
|
|
case GatewayState.Connected:
|
|
this.verifyConnectionFreshness(reason);
|
|
return;
|
|
case GatewayState.Reconnecting:
|
|
case GatewayState.Disconnected: {
|
|
if (this.connectionState === GatewayState.Disconnected && this.isUserInitiatedDisconnect) {
|
|
this.log.debug('Disconnect was user-initiated; not reconnecting on resume');
|
|
return;
|
|
}
|
|
this.reconnectAfterResume(reason, options.accelerateReconnect === true);
|
|
return;
|
|
}
|
|
case GatewayState.Connecting: {
|
|
const connectingFor = this.connectStartedAt > 0 ? Date.now() - this.connectStartedAt : 0;
|
|
if (connectingFor > GATEWAY_TIMEOUTS.Hello) {
|
|
this.log.warn(
|
|
`Stuck in CONNECTING for ${connectingFor}ms on resume (reason=${reason}), forcing fresh attempt`,
|
|
);
|
|
this.forceReconnect(`Stuck connecting on resume: ${reason}`);
|
|
} else {
|
|
this.log.debug(`In CONNECTING for ${connectingFor}ms on resume; allowing in-progress connect to continue`);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
private reconnectAfterResume(reason: string, accelerateReconnect: boolean): void {
|
|
if (accelerateReconnect) {
|
|
if (this.reconnectTimeoutId != null) {
|
|
clearTimeout(this.reconnectTimeoutId);
|
|
this.reconnectTimeoutId = null;
|
|
}
|
|
this.shouldReconnectImmediately = true;
|
|
this.resetBackoffInternal();
|
|
this.connect();
|
|
return;
|
|
}
|
|
if (this.reconnectTimeoutId != null) {
|
|
this.log.debug(`Reconnect already scheduled; preserving backoff on resume: reason=${reason}`);
|
|
return;
|
|
}
|
|
this.log.debug(`Scheduling reconnect after resume using backoff: reason=${reason}`);
|
|
this.updateState(GatewayState.Reconnecting);
|
|
this.scheduleReconnect({allowImmediate: false});
|
|
}
|
|
|
|
forceReconnectFromResume(reason: string): void {
|
|
this.log.warn(`Force reconnect requested from resume handler: ${reason}`);
|
|
this.forceReconnect(reason);
|
|
}
|
|
|
|
private verifyConnectionFreshness(reason: string): void {
|
|
if (!this.heartbeatIntervalMs) {
|
|
this.log.debug('No heartbeat interval set; skipping freshness verification');
|
|
return;
|
|
}
|
|
const now = Date.now();
|
|
const lastSeenAt = this.lastGatewayActivityAt();
|
|
if (lastSeenAt == null) {
|
|
this.log.debug('No gateway activity history; skipping freshness verification');
|
|
return;
|
|
}
|
|
const staleness = now - lastSeenAt;
|
|
const staleThreshold = Math.max(Math.floor(this.heartbeatIntervalMs * 1.5), 30_000);
|
|
if (staleness > staleThreshold) {
|
|
this.log.warn(
|
|
`Gateway appears stale on resume: ${staleness}ms since last gateway activity (threshold ${staleThreshold}ms, reason=${reason})`,
|
|
);
|
|
this.forceReconnect(`Stale connection on resume: ${reason}`);
|
|
return;
|
|
}
|
|
if (this.socket?.readyState !== WebSocket.OPEN) {
|
|
this.log.warn(
|
|
`Connection state is Connected but socket readyState is ${this.socket?.readyState ?? 'null'}; forcing reconnect`,
|
|
);
|
|
this.forceReconnect(`Inconsistent socket state on resume: ${reason}`);
|
|
return;
|
|
}
|
|
this.log.debug(`Probing gateway via immediate heartbeat (staleness=${staleness}ms, reason=${reason})`);
|
|
this.kickHeartbeat();
|
|
}
|
|
|
|
private kickHeartbeat(): void {
|
|
if (!this.heartbeatIntervalMs) return;
|
|
if (this.heartbeatTimeoutId != null) {
|
|
clearTimeout(this.heartbeatTimeoutId);
|
|
this.heartbeatTimeoutId = null;
|
|
}
|
|
this.handleHeartbeatTick();
|
|
}
|
|
|
|
private forceReconnect(reason: string): void {
|
|
this.log.warn(`Forcing zombie socket teardown: ${reason}`);
|
|
this.clearHelloTimeout();
|
|
this.clearResumeTimeout();
|
|
if (this.reconnectTimeoutId != null) {
|
|
clearTimeout(this.reconnectTimeoutId);
|
|
this.reconnectTimeoutId = null;
|
|
}
|
|
if (this.invalidSessionTimeoutId != null) {
|
|
clearTimeout(this.invalidSessionTimeoutId);
|
|
this.invalidSessionTimeoutId = null;
|
|
}
|
|
this.stopHeartbeat();
|
|
this.teardownSocket();
|
|
this.emitDeferred('disconnect', {
|
|
code: 4000,
|
|
reason,
|
|
wasClean: false,
|
|
});
|
|
this.isUserInitiatedDisconnect = false;
|
|
this.shouldReconnectImmediately = true;
|
|
this.resetBackoffInternal();
|
|
this.updateState(GatewayState.Reconnecting);
|
|
this.scheduleReconnect();
|
|
}
|
|
|
|
updatePresence(
|
|
status: string,
|
|
afk?: boolean,
|
|
mobile?: boolean,
|
|
customStatus?: GatewayCustomStatusPayload | null,
|
|
): void {
|
|
if (!this.isConnected()) return;
|
|
this.sendPayload({
|
|
op: GatewayOpcodes.PRESENCE_UPDATE,
|
|
d: {
|
|
status,
|
|
...(afk !== undefined && {afk}),
|
|
...(mobile !== undefined && {mobile}),
|
|
...(customStatus !== undefined && {custom_status: customStatus}),
|
|
},
|
|
});
|
|
}
|
|
|
|
private buildVoiceStatePayload(
|
|
params: GatewayVoiceStateUpdateParams,
|
|
options: {useCurrentConnectionFallback: boolean},
|
|
): GatewayPayload {
|
|
const isMobileLayout = MobileLayout.isMobileLayout();
|
|
const {latitude, longitude} = GeoIP;
|
|
return {
|
|
op: GatewayOpcodes.VOICE_STATE_UPDATE,
|
|
d: {
|
|
...params,
|
|
connection_id: params.connection_id ?? (options.useCurrentConnectionFallback ? MediaEngine.connectionId : null),
|
|
is_mobile: isMobileLayout,
|
|
latitude: latitude ?? undefined,
|
|
longitude: longitude ?? undefined,
|
|
},
|
|
};
|
|
}
|
|
|
|
updateVoiceState(params: GatewayVoiceStateUpdateParams): boolean {
|
|
return this.sendPayload(this.buildVoiceStatePayload(params, {useCurrentConnectionFallback: true}));
|
|
}
|
|
|
|
updateVoiceStateExplicit(params: GatewayVoiceStateUpdateParams): boolean {
|
|
return this.sendPayload(this.buildVoiceStatePayload(params, {useCurrentConnectionFallback: false}));
|
|
}
|
|
|
|
requestGuildMembers(params: {
|
|
guildId?: string;
|
|
guildIds?: Array<string>;
|
|
query?: string;
|
|
limit?: number;
|
|
userIds?: Array<string>;
|
|
presences?: boolean;
|
|
nonce?: string;
|
|
}): void {
|
|
if (!this.isConnected()) return;
|
|
const guildIds = params['guildIds'] ? [...new Set(params['guildIds'].filter((guildId) => guildId.length > 0))] : [];
|
|
const guildPayload: {
|
|
guild_id?: string;
|
|
guild_ids?: Array<string>;
|
|
} = {};
|
|
if (guildIds.length > 0) {
|
|
guildPayload.guild_ids = guildIds;
|
|
} else if (params['guildId']) {
|
|
guildPayload.guild_id = params['guildId'];
|
|
}
|
|
if (!guildPayload.guild_id && !guildPayload.guild_ids) {
|
|
return;
|
|
}
|
|
this.sendPayload({
|
|
op: GatewayOpcodes.REQUEST_GUILD_MEMBERS,
|
|
d: {
|
|
...guildPayload,
|
|
...(params['query'] !== undefined && {query: params['query']}),
|
|
...(params['limit'] !== undefined && {limit: params['limit']}),
|
|
...(params['userIds'] !== undefined && {user_ids: [...new Set(params['userIds'])]}),
|
|
...(params['presences'] !== undefined && {presences: params['presences']}),
|
|
...(params['nonce'] !== undefined && {nonce: params['nonce']}),
|
|
},
|
|
});
|
|
}
|
|
|
|
updateGuildSubscriptions(params: {
|
|
subscriptions: Record<
|
|
string,
|
|
{
|
|
active?: boolean;
|
|
member_list_channels?: Record<string, Array<[number, number]>>;
|
|
typing?: boolean;
|
|
members?: Array<string>;
|
|
sync?: boolean;
|
|
}
|
|
>;
|
|
}): void {
|
|
if (!this.isConnected()) return;
|
|
this.sendPayload({
|
|
op: GatewayOpcodes.LAZY_REQUEST,
|
|
d: params,
|
|
});
|
|
}
|
|
|
|
requestGuildCounts(guildIds: Array<string>): void {
|
|
if (!this.isConnected()) return;
|
|
const uniqueGuildIds = [...new Set(guildIds.filter((id) => id.length > 0))];
|
|
if (uniqueGuildIds.length === 0) return;
|
|
this.sendPayload({
|
|
op: GatewayOpcodes.REQUEST_GUILD_COUNTS,
|
|
d: {guild_ids: uniqueGuildIds},
|
|
});
|
|
}
|
|
|
|
requestChannelMemberCounts(params: {guildId: string; channelIds: Array<string>; nonce?: string}): void {
|
|
if (!this.isConnected()) return;
|
|
if (params.guildId.length === 0) return;
|
|
const uniqueChannelIds = [...new Set(params.channelIds.filter((id) => id.length > 0))];
|
|
if (uniqueChannelIds.length === 0) return;
|
|
this.sendPayload({
|
|
op: GatewayOpcodes.REQUEST_CHANNEL_MEMBER_COUNTS,
|
|
d: {
|
|
guild_id: params.guildId,
|
|
channel_ids: uniqueChannelIds,
|
|
...(params.nonce !== undefined && {nonce: params.nonce}),
|
|
},
|
|
});
|
|
}
|
|
|
|
setToken(token: string): void {
|
|
this.options.token = token;
|
|
}
|
|
|
|
getState(): GatewayState {
|
|
return this.connectionState;
|
|
}
|
|
|
|
getSessionId(): string | null {
|
|
return this.activeSessionId;
|
|
}
|
|
|
|
getSequence(): number {
|
|
return this.lastSequenceNumber;
|
|
}
|
|
|
|
isConnected(): boolean {
|
|
return this.connectionState === GatewayState.Connected && this.socket?.readyState === WebSocket.OPEN;
|
|
}
|
|
|
|
isConnecting(): boolean {
|
|
return this.connectionState === GatewayState.Connecting;
|
|
}
|
|
|
|
private openSocket(): void {
|
|
this.teardownSocket();
|
|
this.buildGatewayUrl()
|
|
.then((url) => {
|
|
const adopted = takeFastConnect(url);
|
|
this.log.debug(`Opening WebSocket connection to ${url}`);
|
|
try {
|
|
this.socket = adopted ? adopted.ws : new WebSocket(url);
|
|
const compression: CompressionType = this.options.compression ?? 'zstd-stream';
|
|
if (compression !== 'none') {
|
|
this.socket.binaryType = 'arraybuffer';
|
|
this.payloadDecompressor = new GatewayCompression(compression, true);
|
|
void this.payloadDecompressor.warmup();
|
|
} else {
|
|
this.socket.binaryType = 'blob';
|
|
this.payloadDecompressor = null;
|
|
}
|
|
this.compressionFallbackInProgress = false;
|
|
this.socket.addEventListener('open', this.handleSocketOpen);
|
|
this.socket.addEventListener('message', this.handleSocketMessage);
|
|
this.socket.addEventListener('close', this.handleSocketClose);
|
|
this.socket.addEventListener('error', this.handleSocketError);
|
|
this.startHelloTimeout();
|
|
this.emitDeferred('connecting');
|
|
if (adopted) {
|
|
this.log.info(
|
|
`Adopted fast connect socket opened ${Date.now() - adopted.state.startedAt}ms ago with ${adopted.state.messages.length} buffered message(s)`,
|
|
);
|
|
if (adopted.state.open || this.socket.readyState === WebSocket.OPEN) {
|
|
this.handleSocketOpen(new Event('open'));
|
|
}
|
|
for (const message of adopted.state.messages) {
|
|
void this.handleSocketMessage(message);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
this.log.error('Failed to create WebSocket', error);
|
|
this.handleConnectionFailure();
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
this.log.error('Failed to build gateway URL', error);
|
|
this.handleConnectionFailure();
|
|
});
|
|
}
|
|
|
|
private teardownSocket(): void {
|
|
if (this.payloadDecompressor) {
|
|
this.payloadDecompressor.destroy();
|
|
this.payloadDecompressor = null;
|
|
}
|
|
if (!this.socket) return;
|
|
try {
|
|
this.socket.removeEventListener('open', this.handleSocketOpen);
|
|
this.socket.removeEventListener('message', this.handleSocketMessage);
|
|
this.socket.removeEventListener('close', this.handleSocketClose);
|
|
this.socket.removeEventListener('error', this.handleSocketError);
|
|
if (this.socket.readyState === WebSocket.OPEN) {
|
|
this.socket.close(1000, 'Disposing stale socket');
|
|
}
|
|
} catch (error) {
|
|
this.log.error('Error while disposing socket', error);
|
|
} finally {
|
|
this.socket = null;
|
|
}
|
|
}
|
|
|
|
private handleSocketOpen = (event: Event): void => {
|
|
if (!this.isCurrentSocketEvent(event)) return;
|
|
this.log.info('WebSocket connection established');
|
|
this.emitDeferred('connected');
|
|
};
|
|
private handleSocketMessage = async (event: MessageEvent): Promise<void> => {
|
|
try {
|
|
if (!this.isCurrentSocketEvent(event)) return;
|
|
const json = await this.extractPayload(event);
|
|
if (!this.isCurrentSocketEvent(event)) return;
|
|
if (!json) return;
|
|
const payload = parseGatewayPayload(json);
|
|
this.lastGatewayMessageAt = Date.now();
|
|
this.log.debug('Gateway message received', payload);
|
|
if (
|
|
payload.op === GatewayOpcodes.DISPATCH &&
|
|
typeof payload.s === 'number' &&
|
|
payload.s > this.lastSequenceNumber
|
|
) {
|
|
this.lastSequenceNumber = payload.s;
|
|
}
|
|
this.routeGatewayPayload(payload);
|
|
this.emitDeferred('message', payload);
|
|
} catch (error) {
|
|
const fatalError = error instanceof Error ? error : new Error(String(error));
|
|
if (this.handleRecoverableCompressionDecodeError(fatalError)) {
|
|
return;
|
|
}
|
|
this.log.fatal('Fatal gateway decode/parsing error', fatalError);
|
|
this.disconnect(GatewayCloseCodes.DECODE_ERROR, 'Fatal message decode error', false);
|
|
this.emit('fatalError', fatalError);
|
|
throw fatalError;
|
|
}
|
|
};
|
|
|
|
private isCurrentSocketEvent(event: Event): boolean {
|
|
return event.target == null || event.target === this.socket;
|
|
}
|
|
|
|
private async extractPayload(event: MessageEvent): Promise<string | null> {
|
|
if (event.data instanceof ArrayBuffer) {
|
|
if (!this.payloadDecompressor) {
|
|
throw new Error('Received binary data but no decompressor is configured');
|
|
}
|
|
const chunk = await this.payloadDecompressor.decompress(event.data);
|
|
if (!chunk) {
|
|
this.log.debug('Awaiting additional compressed chunks');
|
|
return null;
|
|
}
|
|
return chunk;
|
|
}
|
|
if (event.data instanceof Blob) {
|
|
return await event.data.text();
|
|
}
|
|
if (typeof event.data === 'string') {
|
|
return event.data;
|
|
}
|
|
throw new Error(`Unsupported gateway payload type: ${typeof event.data}`);
|
|
}
|
|
|
|
private handleRecoverableCompressionDecodeError(error: Error): boolean {
|
|
if (!isGatewayCompressionError(error)) {
|
|
return false;
|
|
}
|
|
if (this.compressionFallbackInProgress) {
|
|
this.log.debug('Ignoring compressed gateway message while reconnecting without compression');
|
|
return true;
|
|
}
|
|
if ((this.options.compression ?? 'zstd-stream') === 'none') {
|
|
return false;
|
|
}
|
|
this.compressionFallbackInProgress = true;
|
|
this.options.compression = 'none';
|
|
this.shouldReconnectImmediately = true;
|
|
this.log.warn('Gateway compression decode failed; reconnecting with compression disabled', error);
|
|
if (this.socket) {
|
|
this.socket.removeEventListener('message', this.handleSocketMessage);
|
|
}
|
|
this.disconnect(GatewayCloseCodes.DECODE_ERROR, 'Retrying without compression', true);
|
|
return true;
|
|
}
|
|
|
|
private handleSocketClose = (event: CloseEvent): void => {
|
|
if (!this.isCurrentSocketEvent(event)) return;
|
|
this.log.warn(`WebSocket closed [${event.code}] ${event.reason || ''}`);
|
|
this.clearHelloTimeout();
|
|
this.stopHeartbeat();
|
|
if (this.invalidSessionTimeoutId != null) {
|
|
clearTimeout(this.invalidSessionTimeoutId);
|
|
this.invalidSessionTimeoutId = null;
|
|
}
|
|
const resumeWasPending = this.resumeTimeoutId != null;
|
|
this.clearResumeTimeout();
|
|
this.emitDeferred('disconnect', {
|
|
code: event.code,
|
|
reason: event.reason,
|
|
wasClean: event.wasClean,
|
|
});
|
|
if (event.code === GatewayCloseCodes.AUTHENTICATION_FAILED) {
|
|
this.handleAuthFailure();
|
|
return;
|
|
}
|
|
if (
|
|
event.code === GatewayCloseCodes.INVALID_SEQ ||
|
|
event.code === GatewayCloseCodes.SESSION_TIMEOUT ||
|
|
resumeWasPending
|
|
) {
|
|
this.log.info(`Resume failed or session invalidated [${event.code}], clearing session to re-identify`);
|
|
this.clearSession();
|
|
}
|
|
if (!this.isUserInitiatedDisconnect) {
|
|
this.handleConnectionFailure();
|
|
} else {
|
|
this.updateState(GatewayState.Disconnected);
|
|
}
|
|
};
|
|
private handleSocketError = (event: Event): void => {
|
|
if (!this.isCurrentSocketEvent(event)) return;
|
|
this.log.error('WebSocket error', event);
|
|
this.emitDeferred('error', event);
|
|
this.handleConnectionFailure();
|
|
};
|
|
|
|
private routeGatewayPayload(payload: GatewayPayload): void {
|
|
switch (payload.op) {
|
|
case GatewayOpcodes.DISPATCH:
|
|
this.handleDispatchPayload(payload);
|
|
break;
|
|
case GatewayOpcodes.HEARTBEAT:
|
|
this.log.debug('Heartbeat requested by server');
|
|
this.sendHeartbeat(true);
|
|
break;
|
|
case GatewayOpcodes.HEARTBEAT_ACK:
|
|
this.handleHeartbeatAck();
|
|
break;
|
|
case GatewayOpcodes.HELLO:
|
|
this.handleHelloPayload(payload);
|
|
break;
|
|
case GatewayOpcodes.INVALID_SESSION:
|
|
this.handleInvalidSessionPayload(payload);
|
|
break;
|
|
case GatewayOpcodes.RECONNECT:
|
|
this.log.info('Server requested reconnect');
|
|
this.shouldReconnectImmediately = true;
|
|
this.disconnect(4000, 'Server requested reconnect', true);
|
|
break;
|
|
case GatewayOpcodes.GATEWAY_ERROR: {
|
|
if (!isGatewayErrorData(payload.d)) {
|
|
this.log.warn('Gateway error payload had invalid shape');
|
|
break;
|
|
}
|
|
const errorData = payload.d;
|
|
this.log.warn(`Gateway error received [${errorData.code}] ${errorData.message}`);
|
|
this.emitDeferred('gatewayError', errorData);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
private handleDispatchPayload(payload: GatewayPayload): void {
|
|
if (!payload.t) return;
|
|
switch (payload.t) {
|
|
case 'READY': {
|
|
const data = payload.d as {
|
|
session_id: string;
|
|
};
|
|
this.activeSessionId = data.session_id;
|
|
this.resetHeartbeatHistory();
|
|
this.resetBackoffInternal();
|
|
this.updateState(GatewayState.Connected);
|
|
const readyMs = this.connectStartedAt > 0 ? Date.now() - this.connectStartedAt : 0;
|
|
this.log.info(`Gateway READY, session=${this.activeSessionId}, ready_ms=${readyMs}`);
|
|
this.logDispatchTimings('READY', payload.d);
|
|
this.emitDeferred('ready', payload.d);
|
|
break;
|
|
}
|
|
case 'RESUMED':
|
|
this.clearResumeTimeout();
|
|
this.resetHeartbeatHistory();
|
|
this.updateState(GatewayState.Connected);
|
|
this.resetBackoffInternal();
|
|
this.log.info('Gateway session resumed');
|
|
this.logDispatchTimings('RESUMED', payload.d);
|
|
this.emitDeferred('resumed', payload.d);
|
|
break;
|
|
}
|
|
this.emitDeferred('dispatch', payload.t, payload.d);
|
|
}
|
|
|
|
private logDispatchTimings(eventName: 'READY' | 'RESUMED', data: unknown): void {
|
|
if (!this.log.isLevelEnabled(LogLevel.Debug) || !isRecord(data)) {
|
|
return;
|
|
}
|
|
const timed = data as {_timings?: RpcTimings; _timings_gw?: GatewayTimings};
|
|
const tree = formatGatewayReadyTimings(timed._timings_gw, timed._timings);
|
|
if (tree) {
|
|
this.log.debug(`Gateway ${eventName} timings:\n${tree}`);
|
|
}
|
|
}
|
|
|
|
private handleHelloPayload(payload: GatewayPayload): void {
|
|
this.clearHelloTimeout();
|
|
if (this.invalidSessionTimeoutId != null) {
|
|
clearTimeout(this.invalidSessionTimeoutId);
|
|
this.invalidSessionTimeoutId = null;
|
|
}
|
|
const helloData = payload.d as {
|
|
heartbeat_interval: number;
|
|
};
|
|
this.startHeartbeat(helloData.heartbeat_interval);
|
|
if (this.canResumeSession()) {
|
|
this.sendResume();
|
|
} else {
|
|
this.sendIdentify();
|
|
}
|
|
}
|
|
|
|
private handleInvalidSessionPayload(payload: GatewayPayload): void {
|
|
const isResumable = payload.d as boolean;
|
|
this.log.info(`Session invalidated (resumable=${isResumable})`);
|
|
if (this.invalidSessionTimeoutId != null) {
|
|
clearTimeout(this.invalidSessionTimeoutId);
|
|
this.invalidSessionTimeoutId = null;
|
|
}
|
|
const delay = 2500 + Math.random() * 1000;
|
|
this.invalidSessionTimeoutId = window.setTimeout(() => {
|
|
this.invalidSessionTimeoutId = null;
|
|
if (!isResumable) {
|
|
this.clearSession();
|
|
}
|
|
this.shouldReconnectImmediately = true;
|
|
this.disconnect(4000, `Invalid session (resumable=${isResumable})`, true);
|
|
}, delay);
|
|
}
|
|
|
|
private sendIdentify(): void {
|
|
this.log.info('Sending IDENTIFY to gateway');
|
|
const flags = this.options.identifyFlags ?? 0;
|
|
this.sendPayload({
|
|
op: GatewayOpcodes.IDENTIFY,
|
|
d: {
|
|
token: this.options.token,
|
|
properties: this.options.properties,
|
|
...(this.options.presence && {presence: this.options.presence}),
|
|
flags,
|
|
...(this.options.initialGuildId ? {initial_guild_id: this.options.initialGuildId} : {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
private sendResume(): void {
|
|
if (!this.activeSessionId) {
|
|
this.log.warn('Cannot RESUME without an active session, falling back to IDENTIFY');
|
|
this.sendIdentify();
|
|
return;
|
|
}
|
|
this.log.info(`Sending RESUME for session ${this.activeSessionId}`);
|
|
this.sendPayload({
|
|
op: GatewayOpcodes.RESUME,
|
|
d: {
|
|
token: this.options.token,
|
|
session_id: this.activeSessionId,
|
|
seq: this.lastSequenceNumber,
|
|
},
|
|
});
|
|
this.startResumeTimeout();
|
|
}
|
|
|
|
private startHeartbeat(intervalMs: number): void {
|
|
this.stopHeartbeat();
|
|
this.heartbeatIntervalMs = intervalMs;
|
|
const initialDelay = this.computeNextHeartbeatDelay();
|
|
this.scheduleHeartbeat(initialDelay);
|
|
this.log.debug(`Heartbeat scheduled (interval=${intervalMs}ms, next=${initialDelay}ms)`);
|
|
}
|
|
|
|
private computeNextHeartbeatDelay(): number {
|
|
if (!this.heartbeatIntervalMs || this.heartbeatIntervalMs <= 0) {
|
|
return 1000;
|
|
}
|
|
const base = Math.max(1000, Math.floor(this.heartbeatIntervalMs * 0.8));
|
|
const jitter = Math.min(1500, Math.floor(this.heartbeatIntervalMs * 0.05));
|
|
return base + Math.floor(Math.random() * (jitter + 1));
|
|
}
|
|
|
|
private scheduleHeartbeat(delayMs?: number): void {
|
|
if (!this.heartbeatIntervalMs) return;
|
|
const delay = delayMs ?? this.computeNextHeartbeatDelay();
|
|
if (this.heartbeatTimeoutId != null) {
|
|
clearTimeout(this.heartbeatTimeoutId);
|
|
}
|
|
this.heartbeatTimeoutId = window.setTimeout(() => this.handleHeartbeatTick(), delay);
|
|
}
|
|
|
|
private handleHeartbeatTick(): void {
|
|
this.heartbeatTimeoutId = null;
|
|
this.sendHeartbeat();
|
|
if (this.heartbeatIntervalMs) {
|
|
this.scheduleHeartbeat();
|
|
}
|
|
}
|
|
|
|
private heartbeatSkipThreshold(): number {
|
|
if (!this.heartbeatIntervalMs || this.heartbeatIntervalMs <= 0) {
|
|
return GATEWAY_TIMEOUTS.HeartbeatAck;
|
|
}
|
|
const derived = Math.floor(this.heartbeatIntervalMs * 0.75);
|
|
return Math.max(500, Math.min(GATEWAY_TIMEOUTS.HeartbeatAck, derived));
|
|
}
|
|
|
|
private sendHeartbeat(serverRequested = false): void {
|
|
if (this.awaitingHeartbeatAck && !serverRequested) {
|
|
const now = Date.now();
|
|
const elapsedSinceLastHeartbeat = this.lastHeartbeatSentAt ? now - this.lastHeartbeatSentAt : 0;
|
|
const skipThreshold = this.heartbeatSkipThreshold();
|
|
if (elapsedSinceLastHeartbeat < skipThreshold) {
|
|
const retryDelay = Math.max(500, skipThreshold - elapsedSinceLastHeartbeat);
|
|
this.log.debug(`Deferring heartbeat while awaiting ACK (retry in ${retryDelay}ms)`);
|
|
this.scheduleHeartbeat(retryDelay);
|
|
return;
|
|
}
|
|
if (elapsedSinceLastHeartbeat < GATEWAY_TIMEOUTS.HeartbeatAck) {
|
|
const retryDelay = Math.max(500, GATEWAY_TIMEOUTS.HeartbeatAck - elapsedSinceLastHeartbeat);
|
|
this.log.debug(`Still waiting for heartbeat ACK, delaying retry by ${retryDelay}ms`);
|
|
this.scheduleHeartbeat(retryDelay);
|
|
return;
|
|
}
|
|
this.log.warn('Heartbeat ACK not received, forcing reconnect');
|
|
this.handleHeartbeatFailure();
|
|
return;
|
|
}
|
|
const didSend = this.sendPayload({
|
|
op: GatewayOpcodes.HEARTBEAT,
|
|
d: this.lastSequenceNumber,
|
|
});
|
|
if (!didSend) {
|
|
this.log.error('Failed to transmit heartbeat');
|
|
this.handleHeartbeatFailure();
|
|
return;
|
|
}
|
|
this.awaitingHeartbeatAck = true;
|
|
this.lastHeartbeatSentAt = Date.now();
|
|
this.emitDeferred('heartbeat', this.lastSequenceNumber);
|
|
if (serverRequested && this.heartbeatAckTimeoutId != null) {
|
|
clearTimeout(this.heartbeatAckTimeoutId);
|
|
}
|
|
this.startHeartbeatAckTimeout();
|
|
if (serverRequested && this.heartbeatIntervalMs) {
|
|
this.scheduleHeartbeat();
|
|
}
|
|
this.log.debug(`Heartbeat sent (seq=${this.lastSequenceNumber}${serverRequested ? ', serverRequested' : ''})`);
|
|
}
|
|
|
|
private startHeartbeatAckTimeout(): void {
|
|
this.heartbeatAckTimeoutId = window.setTimeout(() => {
|
|
if (!this.awaitingHeartbeatAck) return;
|
|
this.log.warn('Heartbeat ACK timeout');
|
|
this.handleHeartbeatFailure();
|
|
}, GATEWAY_TIMEOUTS.HeartbeatAck);
|
|
}
|
|
|
|
private handleHeartbeatAck(): void {
|
|
this.awaitingHeartbeatAck = false;
|
|
this.lastHeartbeatAckAt = Date.now();
|
|
if (this.heartbeatAckTimeoutId != null) {
|
|
clearTimeout(this.heartbeatAckTimeoutId);
|
|
this.heartbeatAckTimeoutId = null;
|
|
}
|
|
this.log.debug('Heartbeat acknowledgment received');
|
|
this.emitDeferred('heartbeatAck');
|
|
}
|
|
|
|
private handleHeartbeatFailure(): void {
|
|
this.log.warn('Heartbeat failed, reconnecting');
|
|
this.shouldReconnectImmediately = true;
|
|
this.disconnect(4000, 'Heartbeat ACK timeout', true);
|
|
}
|
|
|
|
private stopHeartbeat(): void {
|
|
if (this.heartbeatTimeoutId != null) {
|
|
clearTimeout(this.heartbeatTimeoutId);
|
|
this.heartbeatTimeoutId = null;
|
|
}
|
|
if (this.heartbeatAckTimeoutId != null) {
|
|
clearTimeout(this.heartbeatAckTimeoutId);
|
|
this.heartbeatAckTimeoutId = null;
|
|
}
|
|
this.awaitingHeartbeatAck = false;
|
|
this.heartbeatIntervalMs = null;
|
|
this.log.debug('Heartbeat stopped');
|
|
}
|
|
|
|
private resetHeartbeatHistory(): void {
|
|
this.lastHeartbeatAckAt = null;
|
|
this.lastHeartbeatSentAt = null;
|
|
}
|
|
|
|
private handleConnectionFailure(): void {
|
|
if (this.isUserInitiatedDisconnect) {
|
|
this.updateState(GatewayState.Disconnected);
|
|
return;
|
|
}
|
|
this.updateState(GatewayState.Reconnecting);
|
|
this.scheduleReconnect();
|
|
}
|
|
|
|
private scheduleReconnect(options: {allowImmediate?: boolean} = {}): void {
|
|
if (this.reconnectTimeoutId != null) {
|
|
this.log.debug('Reconnect already scheduled, ignoring');
|
|
return;
|
|
}
|
|
const allowImmediate = options.allowImmediate ?? true;
|
|
const wasImmediate = allowImmediate && this.shouldReconnectImmediately;
|
|
const delay = wasImmediate ? this.reconnectSpread() : this.nextReconnectDelay();
|
|
this.shouldReconnectImmediately = false;
|
|
this.log.info(`Scheduling reconnect in ${delay}ms${wasImmediate ? ' (immediate)' : ''}`);
|
|
this.reconnectTimeoutId = window.setTimeout(() => {
|
|
this.reconnectTimeoutId = null;
|
|
if (!this.canResumeSession()) {
|
|
this.log.info('Session no longer resumable, clearing state');
|
|
this.clearSession();
|
|
}
|
|
this.connect();
|
|
}, delay);
|
|
}
|
|
|
|
private reconnectSpread(): number {
|
|
return Math.floor(Math.random() * GATEWAY_TIMEOUTS.ReconnectSpread);
|
|
}
|
|
|
|
private nextReconnectDelay(): number {
|
|
const now = Date.now();
|
|
const elapsed = now - this.lastReconnectAt;
|
|
if (elapsed < GATEWAY_TIMEOUTS.MinReconnect) {
|
|
this.log.debug(`Last reconnect ${elapsed}ms ago, enforcing minimum delay (${GATEWAY_TIMEOUTS.MinReconnect}ms)`);
|
|
return GATEWAY_TIMEOUTS.MinReconnect + this.reconnectSpread();
|
|
}
|
|
this.lastReconnectAt = now;
|
|
const delay = this.reconnectBackoff.next();
|
|
this.log.debug(`Reconnect backoff attempt=${this.reconnectBackoff.getCurrentAttempts()} delay=${delay}ms`);
|
|
return delay;
|
|
}
|
|
|
|
private resetBackoffInternal(): void {
|
|
this.reconnectBackoff.reset();
|
|
}
|
|
|
|
private canResumeSession(): boolean {
|
|
const now = Date.now();
|
|
if (!this.activeSessionId) return false;
|
|
const lastActivityAt = this.lastGatewayActivityAt();
|
|
if (lastActivityAt != null) {
|
|
return now - lastActivityAt <= GATEWAY_TIMEOUTS.ResumeWindow;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private lastGatewayActivityAt(): number | null {
|
|
const lastHeartbeatAt = this.lastHeartbeatActivityAt();
|
|
if (this.lastGatewayMessageAt == null) return lastHeartbeatAt;
|
|
if (lastHeartbeatAt == null) return this.lastGatewayMessageAt;
|
|
return Math.max(this.lastGatewayMessageAt, lastHeartbeatAt);
|
|
}
|
|
|
|
private lastHeartbeatActivityAt(): number | null {
|
|
if (this.lastHeartbeatAckAt == null) return this.lastHeartbeatSentAt;
|
|
if (this.lastHeartbeatSentAt == null) return this.lastHeartbeatAckAt;
|
|
return Math.max(this.lastHeartbeatAckAt, this.lastHeartbeatSentAt);
|
|
}
|
|
|
|
private clearSession(): void {
|
|
const hadSession = Boolean(this.activeSessionId);
|
|
this.activeSessionId = null;
|
|
this.lastSequenceNumber = 0;
|
|
this.lastGatewayMessageAt = null;
|
|
this.resetHeartbeatHistory();
|
|
if (hadSession) {
|
|
this.log.info('Gateway session cleared');
|
|
}
|
|
}
|
|
|
|
private startHelloTimeout(): void {
|
|
this.clearHelloTimeout();
|
|
this.helloTimeoutId = window.setTimeout(() => {
|
|
this.log.warn('HELLO not received in time');
|
|
this.disconnect(4000, 'Hello timeout', true);
|
|
}, GATEWAY_TIMEOUTS.Hello);
|
|
}
|
|
|
|
private clearHelloTimeout(): void {
|
|
if (this.helloTimeoutId != null) {
|
|
clearTimeout(this.helloTimeoutId);
|
|
this.helloTimeoutId = null;
|
|
}
|
|
}
|
|
|
|
private startResumeTimeout(): void {
|
|
this.clearResumeTimeout();
|
|
this.resumeTimeoutId = window.setTimeout(() => {
|
|
this.resumeTimeoutId = null;
|
|
this.log.warn('RESUMED not received in time, closing connection');
|
|
this.clearSession();
|
|
this.disconnect(4000, 'Resume response timeout', false);
|
|
this.isUserInitiatedDisconnect = false;
|
|
this.handleConnectionFailure();
|
|
}, GATEWAY_TIMEOUTS.ResumeResponse);
|
|
}
|
|
|
|
private clearResumeTimeout(): void {
|
|
if (this.resumeTimeoutId != null) {
|
|
clearTimeout(this.resumeTimeoutId);
|
|
this.resumeTimeoutId = null;
|
|
}
|
|
}
|
|
|
|
private async buildGatewayUrl(): Promise<string> {
|
|
const url = new URL(this.gatewayUrlBase);
|
|
url.searchParams.set('v', this.options.apiVersion.toString());
|
|
url.searchParams.set('encoding', 'json');
|
|
const compression: CompressionType = this.options.compression ?? 'zstd-stream';
|
|
url.searchParams.set('compress', compression);
|
|
if (compression === 'zstd-stream') {
|
|
url.searchParams.set('stream', '1');
|
|
}
|
|
const built = url.toString();
|
|
return this.gatewayUrlWrapper ? this.gatewayUrlWrapper(built) : built;
|
|
}
|
|
|
|
private sendPayload(payload: GatewayPayload): boolean {
|
|
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
this.log.warn('Attempted to send gateway payload while socket is not open');
|
|
return false;
|
|
}
|
|
try {
|
|
const data = JSON.stringify(payload);
|
|
const codec = this.payloadDecompressor;
|
|
if (codec?.canCompress()) {
|
|
let compressed: Uint8Array;
|
|
try {
|
|
compressed = codec.compress(data);
|
|
} catch (error) {
|
|
this.log.error('Gateway outbound compression failed; recovering without compression', error);
|
|
this.handleRecoverableCompressionDecodeError(error instanceof Error ? error : new Error(String(error)));
|
|
return false;
|
|
}
|
|
this.socket.send(new Uint8Array(compressed));
|
|
} else {
|
|
this.socket.send(data);
|
|
}
|
|
this.log.debug('Gateway payload sent', payload);
|
|
return true;
|
|
} catch (error) {
|
|
this.log.error('Error while sending gateway payload', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private updateState(nextState: GatewayState): void {
|
|
if (this.connectionState === nextState) return;
|
|
const previous = this.connectionState;
|
|
this.connectionState = nextState;
|
|
this.log.info(`Gateway state ${previous} -> ${nextState}`);
|
|
this.emitDeferred('stateChange', nextState, previous);
|
|
}
|
|
|
|
private handleAuthFailure(): void {
|
|
this.log.error('Authentication failed: clearing client state and logging out');
|
|
this.updateState(GatewayState.Disconnected);
|
|
AppStorage.clearExcept(PRESERVED_RESET_STORAGE_KEYS);
|
|
LayerManager.closeAll();
|
|
GatewayConnection.logout();
|
|
Authentication.handleConnectionClosed({code: 4004});
|
|
}
|
|
}
|