UNPKG

twitch-eventsub-client

Version:

A Twitch EventSub WebSocket client for Node.js

969 lines (962 loc) 36.6 kB
// src/EventSubClient.ts import { EventEmitter } from "events"; // src/core/apiClient.ts import axios from "axios"; var DEFAULT_API_BASE_URL = "https://api.twitch.tv/helix"; var ApiClient = class { constructor(emitter, clientId, token, options) { this.emitter = emitter; this.clientId = clientId; this.token = token; this.baseApiUrl = options?.apiBaseUrl ?? DEFAULT_API_BASE_URL; this.axiosInstance = axios.create({ baseURL: this.baseApiUrl, headers: { "Client-ID": this.clientId, Authorization: `Bearer ${this.token}`, "Content-Type": "application/json" } }); this.axiosInstance.interceptors.response.use( (response) => response, (error) => { if (error.response) { this.emitter.emit("apiError", error.response.data, error.response); } else if (error.request) { this.emitter.emit("apiError", { error: "Network Error", status: 0, message: error.message || "No response received from Twitch API" }); } else { this.emitter.emit("apiError", { error: "Request Setup Error", status: 0, message: error.message || "Error setting up the request to Twitch API" }); } return Promise.reject(error); } ); } updateToken(newToken) { this.token = newToken; if (this.axiosInstance.defaults.headers.common) { this.axiosInstance.defaults.headers.common["Authorization"] = `Bearer ${newToken}`; } else { this.axiosInstance.defaults.headers["Authorization"] = `Bearer ${newToken}`; } } async createSubscriptions(sessionId, requests) { const requestArray = Array.isArray(requests) ? requests : [requests]; if (requestArray.length === 0) { return []; } const promises = requestArray.map( (request) => this.axiosInstance.post("/eventsub/subscriptions", { type: request.type, version: request.version, condition: request.condition, transport: { method: "websocket", session_id: sessionId } }).then((response) => response.data).catch((error) => { this.emitter.emit( "error", `Failed to subscribe to the Event "${request.type}": ${JSON.stringify(error.response?.data) || error.message}` ); return null; }) ); const results = await Promise.all(promises); return results.filter( (result) => result !== null ); } /** * Deletes one or more EventSub subscriptions by ID. * @param ids A single subscription ID or an array of them. * @returns A promise that resolves when all deletions are attempted. * @throws {AxiosError} Rejects with the first AxiosError encountered on failure. */ async deleteSubscriptions(ids) { const idArray = Array.isArray(ids) ? ids : [ids]; if (idArray.length === 0) { return []; } const deletePromises = idArray.map(async (id) => { await this.axiosInstance.delete("/eventsub/subscriptions", { params: { id } }).catch((error) => { this.emitter.emit( "error", `Error deleting Subscription with the ID "${id}": ${JSON.stringify(error.response?.data) || error.message}` ); return null; }); }); return Promise.allSettled(deletePromises); } /** * Gets EventSub subscriptions, optionally filtered. * Note: Filters are mutually exclusive according to Twitch docs. * @param filter Optional filter object (status, type, or user_id). * @param after Optional pagination cursor. * @returns A promise resolving with the list of subscriptions. * @throws {AxiosError} Rejects on API failure. */ async getSubscriptions(filter, after) { const params = {}; if (filter) { const filterKey = Object.keys(filter)[0]; params[filterKey] = filter[filterKey]; } if (after) { params.after = after; } const response = await this.axiosInstance.get("/eventsub/subscriptions", { params }).catch((error) => { this.emitter.emit( "error", `An Error occured while getting the Subscriptions: ${JSON.stringify(error.response?.data) || error.message}` ); throw error; }); return response.data; } /** * Retrieves all subscriptions by handling pagination automatically. * @param filter Optional filter object. * @returns A promise resolving with *all* matching subscriptions. * @throws {AxiosError} Rejects on API failure during pagination. */ async getAllSubscriptions(filter) { let allSubscriptions = []; let cursor = void 0; let pages = 0; const maxPages = 100; do { pages++; if (pages > maxPages) { break; } const response = await this.getSubscriptions(filter, cursor); allSubscriptions = allSubscriptions.concat(response.data); cursor = response.pagination?.cursor; } while (cursor); return allSubscriptions; } }; // src/core/websocketHandler.ts import WebSocket from "ws"; // src/types/eventsub-types/notification.ts var NotificationMessage = class { constructor(rawMessage) { this.metadata = rawMessage?.metadata; this.payload = rawMessage?.payload; } isAutomodMessageHoldV1() { return this.metadata?.subscription_type === "automod.message.hold" && this.metadata?.subscription_version === "1"; } isAutomodMessageHoldV2() { return this.metadata?.subscription_type === "automod.message.hold" && this.metadata?.subscription_version === "2"; } isAutomodMessageUpdateV1() { return this.metadata?.subscription_type === "automod.message.update" && this.metadata?.subscription_version === "1"; } isAutomodMessageUpdateV2() { return this.metadata?.subscription_type === "automod.message.update" && this.metadata?.subscription_version === "2"; } isAutomodSettingsUpdateV1() { return this.metadata?.subscription_type === "automod.settings.update" && this.metadata?.subscription_version === "1"; } isAutomodTermsUpdateV1() { return this.metadata?.subscription_type === "automod.terms.update" && this.metadata?.subscription_version === "1"; } isChannelBitsUseV1() { return this.metadata?.subscription_type === "channel.bits.use" && this.metadata?.subscription_version === "1"; } isChannelUpdateV2() { return this.metadata?.subscription_type === "channel.update" && this.metadata?.subscription_version === "2"; } isChannelFollowV2() { return this.metadata?.subscription_type === "channel.follow" && this.metadata?.subscription_version === "2"; } isChannelAdBreakBeginV1() { return this.metadata?.subscription_type === "channel.ad_break.begin" && this.metadata?.subscription_version === "1"; } isChannelChatClearV1() { return this.metadata?.subscription_type === "channel.chat.clear" && this.metadata?.subscription_version === "1"; } isChannelChatClearUserMessagesV1() { return this.metadata?.subscription_type === "channel.chat.clear_user_messages" && this.metadata?.subscription_version === "1"; } isChannelChatMessageV1() { return this.metadata?.subscription_type === "channel.chat.message" && this.metadata?.subscription_version === "1"; } isChannelChatMessageDeleteV1() { return this.metadata?.subscription_type === "channel.chat.message_delete" && this.metadata?.subscription_version === "1"; } isChannelChatNotificationV1() { return this.metadata?.subscription_type === "channel.chat.notification" && this.metadata?.subscription_version === "1"; } isChannelChatSettingsUpdateV1() { return this.metadata?.subscription_type === "channel.chat_settings.update" && this.metadata?.subscription_version === "1"; } isChannelChatUserMessageHoldV1() { return this.metadata?.subscription_type === "channel.chat.user_message_hold" && this.metadata?.subscription_version === "1"; } isChannelChatUserMessageUpdateV1() { return this.metadata?.subscription_type === "channel.chat.user_message_update" && this.metadata?.subscription_version === "1"; } isChannelSharedChatBeginV1() { return this.metadata?.subscription_type === "channel.shared_chat.begin" && this.metadata?.subscription_version === "1"; } isChannelSharedChatUpdateV1() { return this.metadata?.subscription_type === "channel.shared_chat.update" && this.metadata?.subscription_version === "1"; } isChannelSharedChatEndV1() { return this.metadata?.subscription_type === "channel.shared_chat.end" && this.metadata?.subscription_version === "1"; } isChannelSubscribeV1() { return this.metadata?.subscription_type === "channel.subscribe" && this.metadata?.subscription_version === "1"; } isChannelSubscriptionEndV1() { return this.metadata?.subscription_type === "channel.subscription.end" && this.metadata?.subscription_version === "1"; } isChannelSubscriptionGiftV1() { return this.metadata?.subscription_type === "channel.subscription.gift" && this.metadata?.subscription_version === "1"; } isChannelSubscriptionMessageV1() { return this.metadata?.subscription_type === "channel.subscription.message" && this.metadata?.subscription_version === "1"; } isChannelCheerV1() { return this.metadata?.subscription_type === "channel.cheer" && this.metadata?.subscription_version === "1"; } isChannelRaidV1() { return this.metadata?.subscription_type === "channel.raid" && this.metadata?.subscription_version === "1"; } isChannelBanV1() { return this.metadata?.subscription_type === "channel.ban" && this.metadata?.subscription_version === "1"; } isChannelUnbanV1() { return this.metadata?.subscription_type === "channel.unban" && this.metadata?.subscription_version === "1"; } isChannelUnbanRequestCreateV1() { return this.metadata?.subscription_type === "channel.unban_request.create" && this.metadata?.subscription_version === "1"; } isChannelUnbanRequestResolveV1() { return this.metadata?.subscription_type === "channel.unban_request.resolve" && this.metadata?.subscription_version === "1"; } isChannelModerateV1() { return this.metadata?.subscription_type === "channel.moderate" && this.metadata?.subscription_version === "1"; } isChannelModerateV2() { return this.metadata?.subscription_type === "channel.moderate" && this.metadata?.subscription_version === "2"; } isChannelModeratorAddV1() { return this.metadata?.subscription_type === "channel.moderator.add" && this.metadata?.subscription_version === "1"; } isChannelModeratorRemoveV1() { return this.metadata?.subscription_type === "channel.moderator.remove" && this.metadata?.subscription_version === "1"; } isChannelGuestStarSessionBeginBeta() { return this.metadata?.subscription_type === "channel.guest_star_session.begin" && this.metadata?.subscription_version === "beta"; } isChannelGuestStarSessionEndBeta() { return this.metadata?.subscription_type === "channel.guest_star_session.end" && this.metadata?.subscription_version === "beta"; } isChannelGuestStarGuestUpdateBeta() { return this.metadata?.subscription_type === "channel.guest_star_guest.update" && this.metadata?.subscription_version === "beta"; } isChannelGuestStarSettingsUpdateBeta() { return this.metadata?.subscription_type === "channel.guest_star_settings.update" && this.metadata?.subscription_version === "beta"; } isChannelChannelPointsAutomaticRewardRedemptionAddV1() { return this.metadata?.subscription_type === "channel.channel_points_automatic_reward_redemption.add" && this.metadata?.subscription_version === "1"; } isChannelChannelPointsAutomaticRewardRedemptionAddV2() { return this.metadata?.subscription_type === "channel.channel_points_automatic_reward_redemption.add" && this.metadata?.subscription_version === "2"; } isChannelChannelPointsCustomRewardAddV1() { return this.metadata?.subscription_type === "channel.channel_points_custom_reward.add" && this.metadata?.subscription_version === "1"; } isChannelChannelPointsCustomRewardUpdateV1() { return this.metadata?.subscription_type === "channel.channel_points_custom_reward.update" && this.metadata?.subscription_version === "1"; } isChannelChannelPointsCustomRewardRemoveV1() { return this.metadata?.subscription_type === "channel.channel_points_custom_reward.remove" && this.metadata?.subscription_version === "1"; } isChannelChannelPointsCustomRewardRedemptionAddV1() { return this.metadata?.subscription_type === "channel.channel_points_custom_reward_redemption.add" && this.metadata?.subscription_version === "1"; } isChannelChannelPointsCustomRewardRedemptionUpdateV1() { return this.metadata?.subscription_type === "channel.channel_points_custom_reward_redemption.update" && this.metadata?.subscription_version === "1"; } isChannelPollBeginV1() { return this.metadata?.subscription_type === "channel.poll.begin" && this.metadata?.subscription_version === "1"; } isChannelPollProgressV1() { return this.metadata?.subscription_type === "channel.poll.progress" && this.metadata?.subscription_version === "1"; } isChannelPollEndV1() { return this.metadata?.subscription_type === "channel.poll.end" && this.metadata?.subscription_version === "1"; } isChannelPredictionBeginV1() { return this.metadata?.subscription_type === "channel.prediction.begin" && this.metadata?.subscription_version === "1"; } isChannelPredictionProgressV1() { return this.metadata?.subscription_type === "channel.prediction.progress" && this.metadata?.subscription_version === "1"; } isChannelPredictionLockV1() { return this.metadata?.subscription_type === "channel.prediction.lock" && this.metadata?.subscription_version === "1"; } isChannelPredictionEndV1() { return this.metadata?.subscription_type === "channel.prediction.end" && this.metadata?.subscription_version === "1"; } isChannelSuspiciousUserMessageV1() { return this.metadata?.subscription_type === "channel.suspicious_user.message" && this.metadata?.subscription_version === "1"; } isChannelSuspiciousUserUpdateV1() { return this.metadata?.subscription_type === "channel.suspicious_user.update" && this.metadata?.subscription_version === "1"; } isChannelVipAddV1() { return this.metadata?.subscription_type === "channel.vip.add" && this.metadata?.subscription_version === "1"; } isChannelVipRemoveV1() { return this.metadata?.subscription_type === "channel.vip.remove" && this.metadata?.subscription_version === "1"; } isChannelWarningAcknowledgeV1() { return this.metadata?.subscription_type === "channel.warning.acknowledge" && this.metadata?.subscription_version === "1"; } isChannelWarningSendV1() { return this.metadata?.subscription_type === "channel.warning.send" && this.metadata?.subscription_version === "1"; } isChannelCharityCampaignDonateV1() { return this.metadata?.subscription_type === "channel.charity_campaign.donate" && this.metadata?.subscription_version === "1"; } isChannelCharityCampaignStartV1() { return this.metadata?.subscription_type === "channel.charity_campaign.start" && this.metadata?.subscription_version === "1"; } isChannelCharityCampaignProgressV1() { return this.metadata?.subscription_type === "channel.charity_campaign.progress" && this.metadata?.subscription_version === "1"; } isChannelCharityCampaignStopV1() { return this.metadata?.subscription_type === "channel.charity_campaign.stop" && this.metadata?.subscription_version === "1"; } isConduitShardDisabledV1() { return this.metadata?.subscription_type === "conduit.shard.disabled" && this.metadata?.subscription_version === "1"; } isDropEntitlementGrantV1() { return this.metadata?.subscription_type === "drop.entitlement.grant" && this.metadata?.subscription_version === "1"; } isExtensionBitsTransactionCreateV1() { return this.metadata?.subscription_type === "extension.bits_transaction.create" && this.metadata?.subscription_version === "1"; } isChannelGoalBeginV1() { return this.metadata?.subscription_type === "channel.goal.begin" && this.metadata?.subscription_version === "1"; } isChannelGoalProgressV1() { return this.metadata?.subscription_type === "channel.goal.progress" && this.metadata?.subscription_version === "1"; } isChannelGoalEndV1() { return this.metadata?.subscription_type === "channel.goal.end" && this.metadata?.subscription_version === "1"; } /** * @deprecated This method is deprecated use isChannelHypeTrainBeginV2 instead. */ isChannelHypeTrainBeginV1() { return this.metadata?.subscription_type === "channel.hype_train.begin" && this.metadata?.subscription_version === "1"; } isChannelHypeTrainBeginV2() { return this.metadata?.subscription_type === "channel.hype_train.begin" && this.metadata?.subscription_version === "2"; } /** * @deprecated This method is deprecated use isChannelHypeTrainProgressV2 instead. */ isChannelHypeTrainProgressV1() { return this.metadata?.subscription_type === "channel.hype_train.progress" && this.metadata?.subscription_version === "1"; } isChannelHypeTrainProgressV2() { return this.metadata?.subscription_type === "channel.hype_train.progress" && this.metadata?.subscription_version === "2"; } /** * @deprecated This method is deprecated use isChannelHypeTrainEndV2 instead. */ isChannelHypeTrainEndV1() { return this.metadata?.subscription_type === "channel.hype_train.end" && this.metadata?.subscription_version === "1"; } isChannelHypeTrainEndV2() { return this.metadata?.subscription_type === "channel.hype_train.end" && this.metadata?.subscription_version === "2"; } isChannelShieldModeBeginV1() { return this.metadata?.subscription_type === "channel.shield_mode.begin" && this.metadata?.subscription_version === "1"; } isChannelShieldModeEndV1() { return this.metadata?.subscription_type === "channel.shield_mode.end" && this.metadata?.subscription_version === "1"; } isChannelShoutoutCreateV1() { return this.metadata?.subscription_type === "channel.shoutout.create" && this.metadata?.subscription_version === "1"; } isChannelShoutoutReceiveV1() { return this.metadata?.subscription_type === "channel.shoutout.receive" && this.metadata?.subscription_version === "1"; } isStreamOnlineV1() { return this.metadata?.subscription_type === "stream.online" && this.metadata?.subscription_version === "1"; } isStreamOfflineV1() { return this.metadata?.subscription_type === "stream.offline" && this.metadata?.subscription_version === "1"; } isUserAuthorizationGrantV1() { return this.metadata?.subscription_type === "user.authorization.grant" && this.metadata?.subscription_version === "1"; } isUserAuthorizationRevokeV1() { return this.metadata?.subscription_type === "user.authorization.revoke" && this.metadata?.subscription_version === "1"; } isUserUpdateV1() { return this.metadata?.subscription_type === "user.update" && this.metadata?.subscription_version === "1"; } isUserWhisperMessageV1() { return this.metadata?.subscription_type === "user.whisper.message" && this.metadata?.subscription_version === "1"; } }; // src/utils/backoffTimer.ts function calculateNextBackoff(currentDelay, initialDelay, maxDelay, multiplier) { if (currentDelay === 0) { return initialDelay; } const nextDelay = currentDelay * multiplier; return Math.min(nextDelay, maxDelay); } var BackoffTimer = class { constructor(options = {}) { this.currentDelay = 0; this.retries = 0; this.timerId = null; this.initialDelay = options.initialDelay ?? 500; this.maxDelay = options.maxDelay ?? 3e4; this.multiplier = options.multiplier ?? 2; this.maxRetries = options.maxRetries ?? Infinity; } scheduleRetry(callback) { this.clear(); if (this.retries >= this.maxRetries) { this.reset(); return false; } this.currentDelay = calculateNextBackoff( this.currentDelay, this.initialDelay, this.maxDelay, this.multiplier ); this.retries++; this.timerId = setTimeout(() => { this.timerId = null; try { callback(); } catch (error) { console.log(error); } }, this.currentDelay); return true; } reset() { this.clear(); this.currentDelay = 0; this.retries = 0; } clear() { if (this.timerId) { clearTimeout(this.timerId); this.timerId = null; } } }; // src/core/websocketHandler.ts var DEFAULT_WEBSOCKET_URL = "wss://eventsub.wss.twitch.tv/ws"; var RECONNECT_GRACE_PERIOD_MS = 35 * 1e3; var WELCOME_TIMEOUT_MS = 10 * 1e3; var WebSocketHandler = class { constructor(emitter, options) { this.ws = null; this.reconnectWs = null; this.state = "disconnected"; this.keepaliveTimeoutMs = 30 * 1e3; this.keepaliveTimer = null; this.welcomeTimer = null; this.reconnectGraceTimer = null; this.currentSessionId = null; this.currentReconnectUrl = null; this.emitter = emitter; this.baseWebsocketUrl = options?.websocketUrl ?? DEFAULT_WEBSOCKET_URL; this.requestedKeepaliveSeconds = options?.keepaliveTimeoutSeconds ?? 30; this.skipKeepaliveValidation = options?.skipKeepaliveValidation ?? false; this.reconnectBackoffTimer = new BackoffTimer(options?.reconnectBackoff); this.validateKeepaliveSeconds(); } validateKeepaliveSeconds() { if (this.skipKeepaliveValidation) return; if (!Number.isInteger(this.requestedKeepaliveSeconds) || this.requestedKeepaliveSeconds < 10 || this.requestedKeepaliveSeconds > 600) { Object.defineProperty(this, "requestedKeepaliveSeconds", { value: 30, writable: true }); } } buildConnectionUrl() { const url = new URL(this.baseWebsocketUrl); url.searchParams.set( "keepalive_timeout_seconds", String(this.requestedKeepaliveSeconds) ); return url.toString(); } connect() { if (this.state !== "disconnected") { return; } this.state = "connecting"; this.reconnectBackoffTimer.clear(); this._initiateConnection(this.buildConnectionUrl()); } disconnect(code = 1e3, reason = "Client initiated disconnect") { if (this.state === "disconnected" || this.state === "disconnecting") { return; } this.state = "disconnecting"; this.clearKeepaliveTimer(); this.clearWelcomeTimer(); this.clearReconnectGraceTimer(); this.reconnectBackoffTimer.clear(); if (this.ws) { try { this.removeWebSocketListeners(this.ws); if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) { this.ws.close(code, reason); } } catch (err) { this.emitter.emit("error", err); } finally { this.ws = null; } } if (this.reconnectWs) { try { this.removeWebSocketListeners(this.reconnectWs); if (this.reconnectWs.readyState === WebSocket.OPEN || this.reconnectWs.readyState === WebSocket.CONNECTING) { this.reconnectWs.close(code, reason); } } catch (err) { this.emitter.emit("error", err); } finally { this.reconnectWs = null; } } this.state = "disconnected"; this.currentSessionId = null; this.currentReconnectUrl = null; this.emitter.emit("disconnect", { code, reason }); } getSessionId() { return this.currentSessionId; } isConnected() { return this.state === "connected"; } _initiateConnection(url, isReconnectAttempt = false) { this.clearKeepaliveTimer(); this.clearWelcomeTimer(); const ws = new WebSocket(url); if (isReconnectAttempt) { this.reconnectWs = ws; } else { this.ws = ws; } ws.on("open", this.onOpen.bind(this, ws, isReconnectAttempt)); ws.on("message", this.onMessage.bind(this, ws)); ws.on("error", this.onError.bind(this, ws)); ws.on("close", this.onClose.bind(this, ws, url)); } removeWebSocketListeners(wsInstance) { if (!wsInstance) return; wsInstance.removeAllListeners("open"); wsInstance.removeAllListeners("message"); wsInstance.removeAllListeners("error"); wsInstance.removeAllListeners("close"); } onOpen(wsInstance, isReconnectAttempt) { this.clearWelcomeTimer(); this.welcomeTimer = setTimeout(() => { this.welcomeTimer = null; this.disconnect(4007, "Welcome message timeout"); }, WELCOME_TIMEOUT_MS); } onMessage(wsInstance, data, isBinary) { this.resetKeepaliveTimer(); if (isBinary) { return; } let message; try { message = JSON.parse(data.toString("utf-8")); if (!message.metadata || !message.payload || !message.metadata.message_type) { throw new Error("Invalid message structure received."); } } catch (error) { this.emitter.emit( "error", `Failed to parse message: ${error.message ?? "Unknown error"}` ); return; } try { this.emitter.emit("rawMessage", message); } catch (emitError) { this.emitter.emit( "error", emitError.message ?? "Unknown error" ); } try { switch (message.metadata.message_type) { case "session_welcome": this.handleWelcome(wsInstance, message); this.emitter.emit("welcome", message); break; case "session_keepalive": this.emitter.emit("keepalive", message); break; case "session_reconnect": this.handleReconnect(message); this.emitter.emit("reconnect", message); break; case "notification": const msg = new NotificationMessage(message); this.emitter.emit("notification", msg); break; case "revocation": this.emitter.emit("revocation", message); break; default: console.log( message.metadata.message_type ); } } catch (emitError) { this.emitter.emit("error", emitError); } } // private onPing(wsInstance: WebSocket, data: Buffer): void { // if (wsInstance.readyState === WebSocket.OPEN) { // wsInstance.pong(data); // } // } onError(wsInstance, error) { this.emitter.emit("error", error); if (this.state === "connecting" || this.state === "reconnecting_new") { if (wsInstance === this.ws) this.ws = null; if (wsInstance === this.reconnectWs) this.reconnectWs = null; this.removeWebSocketListeners(wsInstance); this.handleUnexpectedClose( wsInstance === this.reconnectWs ? this.currentReconnectUrl : this.buildConnectionUrl() ); } } onClose(wsInstance, url, code, reasonBuffer) { const reason = reasonBuffer.toString("utf-8") || "No reason provided"; this.clearKeepaliveTimer(); this.clearWelcomeTimer(); if (this.state === "disconnecting" || code === 4004 && this.state === "reconnecting_new") { if (wsInstance === this.ws) this.ws = null; if (wsInstance === this.reconnectWs) this.reconnectWs = null; if (!(code === 4004 && this.state === "reconnecting_new")) { if (this.state === "disconnecting") { this.disconnect(code, reason); } } return; } if (this.state === "reconnecting_init") { this.clearReconnectGraceTimer(); } if (wsInstance === this.ws) this.ws = null; if (wsInstance === this.reconnectWs) this.reconnectWs = null; if (this.state !== "disconnected") { this.emitter.emit("disconnect", { code, reason }); } this.state = "disconnected"; this.currentSessionId = null; this.handleUnexpectedClose(url); } handleWelcome(wsInstance, message) { this.clearWelcomeTimer(); const session = message.payload.session; const newSessionId = session.id; this.keepaliveTimeoutMs = (session.keepalive_timeout_seconds ?? 10) * 1e3 + 5e3; if (this.state === "reconnecting_init" && wsInstance === this.reconnectWs && session.reconnect_url === null) { this.state = "connected"; this.currentSessionId = newSessionId; this.resetKeepaliveTimer(); this.currentReconnectUrl = null; this.reconnectBackoffTimer.reset(); this.clearReconnectGraceTimer(); if (this.ws) { const oldWs = this.ws; this.ws = null; this.removeWebSocketListeners(oldWs); if (oldWs.readyState === WebSocket.OPEN || oldWs.readyState === WebSocket.CONNECTING) { oldWs.close( 1e3, "Client closed old connection after successful reconnect" ); } } this.ws = this.reconnectWs; this.reconnectWs = null; this.emitter.emit("connect", session); } else if ((this.state === "connecting" || this.state === "reconnecting_new") && wsInstance === this.ws) { this.state = "connected"; this.currentSessionId = newSessionId; this.resetKeepaliveTimer(); this.reconnectBackoffTimer.reset(); this.emitter.emit("connect", session); } else { if (wsInstance.readyState === WebSocket.OPEN || wsInstance.readyState === WebSocket.CONNECTING) { wsInstance.close(4e3, "Received unexpected welcome message"); } } } handleReconnect(message) { if (this.state !== "connected") { return; } const newUrl = message.payload.session.reconnect_url; if (!newUrl) { this.emitter.emit("error", "Reconnect message missing reconnect_url"); return; } this.state = "reconnecting_init"; this.currentReconnectUrl = newUrl; this.clearKeepaliveTimer(); this.clearReconnectGraceTimer(); this._initiateConnection(newUrl, true); this.reconnectGraceTimer = setTimeout(() => { this.reconnectGraceTimer = null; if (this.state === "reconnecting_init") { if (this.ws) { const oldWs = this.ws; this.ws = null; this.removeWebSocketListeners(oldWs); if (oldWs.readyState === WebSocket.OPEN || oldWs.readyState === WebSocket.CONNECTING) { oldWs.close(4004, "Reconnect grace time expired"); } } if (this.reconnectWs) { const stuckWs = this.reconnectWs; this.reconnectWs = null; this.removeWebSocketListeners(stuckWs); if (stuckWs.readyState === WebSocket.OPEN || stuckWs.readyState === WebSocket.CONNECTING) { stuckWs.close(1e3, "Client closing stuck reconnect attempt"); } } this.state = "disconnected"; this.currentSessionId = null; this.handleUnexpectedClose(this.buildConnectionUrl()); } }, RECONNECT_GRACE_PERIOD_MS); } resetKeepaliveTimer() { this.clearKeepaliveTimer(); if (this.state === "connected") { this.keepaliveTimer = setTimeout(() => { this.keepaliveTimer = null; this.handleUnexpectedClose(this.buildConnectionUrl()); }, this.keepaliveTimeoutMs); } } clearKeepaliveTimer() { if (this.keepaliveTimer) { clearTimeout(this.keepaliveTimer); this.keepaliveTimer = null; } } clearWelcomeTimer() { if (this.welcomeTimer) { clearTimeout(this.welcomeTimer); this.welcomeTimer = null; } } clearReconnectGraceTimer() { if (this.reconnectGraceTimer) { clearTimeout(this.reconnectGraceTimer); this.reconnectGraceTimer = null; } } handleUnexpectedClose(closedUrl) { if (this.state === "disconnected") { this.reconnectBackoffTimer.scheduleRetry(() => { this.state = "connecting"; this._initiateConnection(this.buildConnectionUrl()); }); } else if (this.state === "disconnecting") { this.state = "disconnected"; this.handleUnexpectedClose(closedUrl); } } }; // src/EventSubClient.ts var EventSubClient = class extends EventEmitter { constructor(options) { super(); this.activeSubscriptions = /* @__PURE__ */ new Map(); this.clientId = options.clientId; this.token = options.token; if (!options.clientId || !options.token) { throw new Error( "[EventSubClient] Client ID and User Access Token are required." ); } this.apiClient = new ApiClient( this, options.clientId, options.token, options ); this.wsHandler = new WebSocketHandler(this, options); this.wsHandler.emitter.once("connect", () => { this.handleReconnect(); }); } updateToken(newToken) { if (!newToken || typeof newToken !== "string") { const error = new Error( "[EventSubClient] Invalid token provided for update." ); this.emit("error", error); return; } this.token = newToken; this.apiClient.updateToken(newToken); } connect() { this.wsHandler.connect(); } disconnect() { this.wsHandler.disconnect(); } isConnected() { return this.wsHandler.isConnected(); } getSessionId() { return this.wsHandler.getSessionId(); } async subscribe(requests) { const subs = Array.isArray(requests) ? requests : [requests]; const sessionId = this.getSessionId(); const normalize = (cond) => Object.entries(cond).filter(([_, v]) => v !== "").sort(([a], [b]) => a.localeCompare(b)); const makeKey = (r) => `${r.type}|v${r.version}|${normalize(r.condition).map(([k, v]) => `${k}:${v}`).join("|")}`; try { const existing = await this.getAllSubscriptions({ status: "enabled" }); const newSubs = subs.filter((r) => { const key = makeKey(r); const match = existing.find((e) => makeKey(e) === key); const sameSession = match?.transport.session_id === sessionId; const inMap = this.activeSubscriptions.has(key); return (!sameSession || !match) && !inMap; }); if (newSubs.length === 0) return []; const results = sessionId && this.isConnected() ? await this._doSubscribe(newSubs) : await new Promise( (res, rej) => { const onConnect = async () => { try { res(await this._doSubscribe(newSubs)); } catch (e) { rej(e); } finally { this.off("connect", onConnect); } }; this.once("connect", onConnect); setTimeout(() => { this.off("connect", onConnect); const err = new Error("Timeout waiting for connection."); this.emit("error", err); rej(err); }, 15e3); } ); results.forEach((_, i) => { const key = makeKey(newSubs[i]); this.activeSubscriptions.set(key, newSubs[i]); }); return results; } catch (err) { this.emit("error", err); return []; } } async _doSubscribe(requests) { const sessionId = this.getSessionId(); if (!sessionId) { const error = new Error("Cannot subscribe: Session ID is missing."); this.emit("error", error); return []; } const requestArray = Array.isArray(requests) ? requests : [requests]; if (requestArray.length === 0) { return []; } try { const results = await this.apiClient.createSubscriptions( sessionId, requestArray ); return results; } catch (error) { this.emit("error", error); return []; } } async unsubscribe(ids) { const idArray = Array.isArray(ids) ? ids : [ids]; if (idArray.length === 0) { return; } try { await this.apiClient.deleteSubscriptions(idArray); idArray.forEach((id) => this.activeSubscriptions.delete(id)); } catch (error) { this.emit("error", error); } } async getAllSubscriptions(filter) { try { const subscriptions = await this.apiClient.getAllSubscriptions(filter); return subscriptions; } catch (error) { this.emit("error", error); throw error; } } async getSubscriptionsPage(filter, after) { try { const response = await this.apiClient.getSubscriptions(filter, after); return response; } catch (error) { this.emit("error", error); throw error; } } async handleReconnect() { if (this.activeSubscriptions.size === 0) { return; } await this.subscribe(Array.from(this.activeSubscriptions.values())); } }; var EventSubClient_default = EventSubClient; export { EventSubClient, NotificationMessage, EventSubClient_default as default };