twitch-eventsub-client
Version:
A Twitch EventSub WebSocket client for Node.js
969 lines (962 loc) • 36.6 kB
JavaScript
// 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
};