twitch-eventsub-client
Version:
A Twitch EventSub WebSocket client for Node.js
996 lines (987 loc) • 38.6 kB
JavaScript
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var index_exports = {};
__export(index_exports, {
EventSubClient: () => EventSubClient,
NotificationMessage: () => NotificationMessage,
default: () => EventSubClient_default
});
module.exports = __toCommonJS(index_exports);
// src/EventSubClient.ts
var import_events = require("events");
// src/core/apiClient.ts
var import_axios = __toESM(require("axios"), 1);
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 = import_axios.default.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
var import_ws = __toESM(require("ws"), 1);
// 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 === import_ws.default.OPEN || this.ws.readyState === import_ws.default.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 === import_ws.default.OPEN || this.reconnectWs.readyState === import_ws.default.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 import_ws.default(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 === import_ws.default.OPEN || oldWs.readyState === import_ws.default.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 === import_ws.default.OPEN || wsInstance.readyState === import_ws.default.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 === import_ws.default.OPEN || oldWs.readyState === import_ws.default.CONNECTING) {
oldWs.close(4004, "Reconnect grace time expired");
}
}
if (this.reconnectWs) {
const stuckWs = this.reconnectWs;
this.reconnectWs = null;
this.removeWebSocketListeners(stuckWs);
if (stuckWs.readyState === import_ws.default.OPEN || stuckWs.readyState === import_ws.default.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 import_events.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;
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
EventSubClient,
NotificationMessage
});