UNPKG

twitch-eventsub-client

Version:

A Twitch EventSub WebSocket client for Node.js

958 lines (951 loc) 36.7 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, userAccessToken, options) { this.clientId = clientId; this.userAccessToken = userAccessToken; this.emitter = emitter; const baseURL = options?.apiBaseUrl ?? DEFAULT_API_BASE_URL; this.axiosInstance = axios.create({ baseURL, headers: { "Client-ID": this.clientId, Authorization: `Bearer ${this.userAccessToken}`, "Content-Type": "application/json" } }); this.axiosInstance.interceptors.response.use( (response) => response, (error) => { return Promise.reject(error); } ); } /** * Creates one or more EventSub subscriptions. * @param sessionId The WebSocket session ID. * @param requests A single subscription request object or an array of them. * @returns A promise that resolves with the API response on success. * @throws {AxiosError} Rejects with the AxiosError on failure. */ 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"; } isChannelHypeTrainBeginV1() { return this.metadata?.subscription_type === "channel.hype_train.begin" && this.metadata?.subscription_version === "1"; } isChannelHypeTrainProgressV1() { return this.metadata?.subscription_type === "channel.hype_train.progress" && this.metadata?.subscription_version === "1"; } isChannelHypeTrainEndV1() { return this.metadata?.subscription_type === "channel.hype_train.end" && this.metadata?.subscription_version === "1"; } 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.disconnect(4005, "Network timeout (keepalive)"); }, 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 === "disconnecting" || this.state === "disconnected") { this.reconnectBackoffTimer.scheduleRetry(() => { this.state = "connecting"; this._initiateConnection(this.buildConnectionUrl()); }); } } }; // src/EventSubClient.ts var EventSubClient = class extends EventEmitter { /** * Creates an instance of the EventSubClient. * @param clientId Your Twitch application's Client ID. * @param token A User Access Token with the required scopes for your subscriptions. * @param options Optional configuration for the client. */ constructor(options) { super(); 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); } /** * Initiates the WebSocket connection to the Twitch EventSub server. * Listens for the 'connect' event to confirm connection. */ connect() { this.wsHandler.connect(); } /** * Closes the WebSocket connection gracefully. */ disconnect() { this.wsHandler.disconnect(); } /** * Checks if the WebSocket connection is currently established and received a Welcome message. * @returns True if connected, false otherwise. */ isConnected() { return this.wsHandler.isConnected(); } /** * Gets the current WebSocket Session ID. Returns null if not connected. * @returns The Session ID string or null. */ getSessionId() { return this.wsHandler.getSessionId(); } /** * Subscribes to one or more EventSub topics. * Requires the client to be connected (must have received the Welcome message). * * @example * // Subscribe to channel follows for broadcaster '12345' (moderator/user '67890' has authorized) * client.subscribe({ * type: 'channel.follow', * version: '2', * condition: { broadcaster_user_id: '12345', moderator_user_id: '67890' } * }); * * // Subscribe to multiple events * client.subscribe([ * { type: 'stream.online', version: '1', condition: { broadcaster_user_id: '12345' } }, * { type: 'stream.offline', version: '1', condition: { broadcaster_user_id: '12345' } } * ]); * * @param requests A single subscription request object or an array of them. * @returns A Promise that resolves with the Twitch API response(s) on success. * @throws {AxiosError} Rejects with the API error on failure. Emits 'error' event. */ async subscribe(requests) { if (this.isConnected() && this.getSessionId()) { return this._doSubscribe(requests); } else { return new Promise((resolve, reject) => { const subscribeOnConnect = async () => { try { const results = await this._doSubscribe(requests); resolve(results); } catch (error) { reject(error); } finally { this.off("connect", subscribeOnConnect); } }; this.once("connect", subscribeOnConnect); const timeout = setTimeout(() => { this.off("connect", subscribeOnConnect); const error = new Error( "Subscription timed out while waiting for connection." ); this.emit("error", error); reject(error); }, 15e3); }); } } 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 Promise.resolve([]); } try { const results = await this.apiClient.createSubscriptions( sessionId, requestArray ); return results; } catch (error) { this.emit("error", error); return []; } } /** * Unsubscribes from one or more EventSub topics using their subscription IDs. * * @param ids A single subscription ID string or an array of IDs. * @returns A Promise that resolves when all deletion requests are successfully sent (204 No Content). * @throws {AxiosError} Rejects with the first API error encountered on failure. Emits 'error' event. */ async unsubscribe(ids) { const idArray = Array.isArray(ids) ? ids : [ids]; if (idArray.length === 0) { return Promise.resolve(); } try { await this.apiClient.deleteSubscriptions(idArray); } catch (error) { this.emit("error", error); } } /** * Fetches the list of current EventSub subscriptions created by this client's credentials. * Handles pagination automatically to retrieve all subscriptions. * * @param filter Optional filter object (e.g., { status: 'enabled' } or { type: 'stream.online' }). Filters are mutually exclusive. * @returns A Promise resolving with an array of all matching subscription data objects. * @throws {AxiosError} Rejects with the API error on failure. Emits 'error' event. */ async getAllSubscriptions(filter) { try { const subscriptions = await this.apiClient.getAllSubscriptions(filter); return subscriptions; } catch (error) { this.emit("error", error); throw error; } } /** * Fetches a single page of EventSub subscriptions created by this client's credentials. * * @param filter Optional filter object (e.g., { status: 'enabled' } or { type: 'stream.online' }). Filters are mutually exclusive. * @param after Optional pagination cursor from a previous response. * @returns A Promise resolving with the API response containing a page of subscriptions and pagination info. * @throws {AxiosError} Rejects with the API error on failure. Emits 'error' event. */ async getSubscriptionsPage(filter, after) { try { const response = await this.apiClient.getSubscriptions(filter, after); return response; } catch (error) { this.emit("error", error); throw error; } } }; var EventSubClient_default = EventSubClient; export { EventSubClient, NotificationMessage, EventSubClient_default as default };