diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..62601ec
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,45 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches:
+ - v2
+ - 'agent/**'
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ name: Node ${{ matrix.node }}
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ node: [20, 22, 24]
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node }}
+ cache: npm
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Typecheck
+ run: npm run typecheck
+
+ - name: Test
+ run: npm run test:run
+
+ - name: Build
+ run: npm run build
+
+ - name: Verify package contents
+ run: npm pack --dry-run
diff --git a/docs/BACKCOMPAT.md b/docs/BACKCOMPAT.md
new file mode 100644
index 0000000..afc43e2
--- /dev/null
+++ b/docs/BACKCOMPAT.md
@@ -0,0 +1,102 @@
+# ComfyJS v2 Backward-Compatibility Contract
+
+ComfyJS v2 is allowed to add capabilities, but it must not require an existing v1 integration to change code.
+
+This document defines the release gate. A checklist is not evidence by itself: each gate should be executable or backed by a recorded live differential run.
+
+## Compatibility rule
+
+The released v1 public surface is frozen:
+
+- existing method names remain available
+- existing synchronous return behavior remains available where callers could observe it
+- existing callback names, argument order, and legacy payloads remain stable
+- browser global, CommonJS, and ESM/package entry points remain supported
+- new information is exposed additively through new methods/callbacks, not by mutating a legacy contract
+
+`Init()` therefore remains the v1 fire-and-forget API. v2 adds `InitAsync()` for callers that want to await connection readiness.
+
+For EventSub, legacy named callbacks remain compatibility APIs. v2 additionally exposes `onEventSub(type, event, version)` and `SubscribeEventSub(type, version, condition)` so Twitch can add subscription types without requiring a ComfyJS release.
+
+## Automated release gates
+
+Every pull request runs on Node 20, 22, and 24 and must pass:
+
+1. TypeScript typecheck
+2. deterministic parser/unit tests
+3. captured real-IRC regression tests
+4. v1 compatibility-contract tests
+5. modern EventSub extension tests
+6. ESM, CommonJS, and browser bundle builds
+7. npm package-content verification
+
+The compatibility tests should be expanded whenever a production regression or old integration pattern is discovered. A regression is not considered fixed until a test reproduces it.
+
+## Live differential gate
+
+Run the released v1 library and the candidate v2 build side by side against the same Twitch channel:
+
+```bash
+npm ci
+npm run build
+python3 -m http.server 8080
+```
+
+Then open:
+
+`http://localhost:8080/examples/differential-live.html`
+
+The harness loads released `comfy.js@1.1.30` in one isolated iframe and the local candidate bundle in another. Both connect to the same channel. Public callbacks are correlated by Twitch message/event identity where possible and their complete serialized argument arrays are compared.
+
+OAuth is optional. Anonymous mode is enough for IRC chat coverage. An OAuth token is needed to exercise authenticated/EventSub behavior. The harness does not persist the token.
+
+### Minimum live matrix before release
+
+Record a zero-unexplained-difference run covering as many of these as Twitch makes practical:
+
+- regular chat
+- subscriber/mod/VIP chat
+- `!command`
+- `@mention !command`
+- `/me` action
+- highlighted message
+- channel-points message
+- cheer
+- join / part
+- timeout / ban / deleted message
+- sub / resub
+- gift sub / mystery gift / gift continuation
+- raid
+- room-state changes
+- reward redemption
+- poll
+- prediction
+- hype train
+- shoutout
+- whisper
+- reconnect
+
+Some events cannot be forced cheaply on demand. For those, retain real captured payloads as deterministic fixtures and accumulate live samples over time.
+
+## Candidate-release process
+
+Do not publish v2 directly over `latest`.
+
+1. Merge compatibility work into `v2` only after CI is green.
+2. Publish a prerelease such as `2.0.0-rc.1` under the npm `next` tag.
+3. Run existing ComfyJS examples and representative real projects against that exact package artifact.
+4. Run the live differential matrix from the packaged candidate, not only from source.
+5. Leave the release candidate available long enough for opt-in community testing.
+6. Promote the exact tested artifact/version to the stable release only after there are no unexplained compatibility differences.
+
+## What “perfect backward compatibility” means here
+
+No finite test suite can mathematically prove compatibility for every possible JavaScript program, especially for users that reached through `GetClient()` into undocumented `tmi.js` internals. The release standard is therefore:
+
+- every documented v1 contract is executable as a regression test
+- known real-world usage patterns are represented
+- real Twitch traffic is differentially compared to the released v1 implementation
+- package/runtime surfaces are smoke-tested
+- any remaining escape-hatch differences are explicitly documented before stable release
+
+`GetClient()` is the main area that deserves special scrutiny because v1 exposed the underlying `tmi.js` client. Projects using undocumented tmi internals through that escape hatch need to be collected and tested before calling v2 fully compatible.
diff --git a/examples/differential-client.html b/examples/differential-client.html
new file mode 100644
index 0000000..26caeeb
--- /dev/null
+++ b/examples/differential-client.html
@@ -0,0 +1,96 @@
+
+
+
+
+ ComfyJS Differential Client
+
+
+
+
+
diff --git a/examples/differential-live.html b/examples/differential-live.html
new file mode 100644
index 0000000..0d949f1
--- /dev/null
+++ b/examples/differential-live.html
@@ -0,0 +1,188 @@
+
+
+
+
+
+ ComfyJS v1 ↔ v2 Live Differential
+
+
+
+ ComfyJS live differential
+ Runs released 1.1.30 and the local candidate build against the same Twitch channel, then correlates public callbacks and compares their serialized arguments. OAuth is optional; without it you can still exercise IRC events. The token is kept in page memory and sent only to the two ComfyJS instances.
+
+
+
+
+ Start
+ Stop
+
+
+
+ Loading clients…
+ Matches: 0
+ Differences: 0
+ Unpaired: 0
+
+
+
+ Result Callback / key v1 arguments v2 arguments
+
+
+
+
+
+
+
+
+
diff --git a/package.json b/package.json
index 083ab1e..9b5ea4a 100644
--- a/package.json
+++ b/package.json
@@ -4,22 +4,22 @@
"description": "Twitch Chat & Events Made Easy",
"main": "dist/comfy.cjs",
"module": "dist/comfy.js",
- "types": "dist/index.d.ts",
+ "types": "dist/compat.d.ts",
"browser": "dist/comfy.min.js",
"exports": {
".": {
"require": "./dist/comfy.cjs",
"import": "./dist/comfy.js",
- "types": "./dist/index.d.ts"
+ "types": "./dist/compat.d.ts"
}
},
"scripts": {
"build": "npm-run-all --sequential build:*",
"build:clean": "rimraf dist",
"build:types": "tsc",
- "build:bundle": "esbuild src/index.ts --bundle --format=esm --sourcemap --outfile=dist/comfy.js --platform=browser",
- "build:cjs": "esbuild src/index.ts --bundle --format=cjs --sourcemap --outfile=dist/comfy.cjs --platform=node",
- "build:minify": "esbuild src/index.ts --bundle --minify --sourcemap --format=iife --global-name=ComfyJSModule --footer:js=\"window.ComfyJS=ComfyJSModule.default\" --outfile=dist/comfy.min.js --platform=browser",
+ "build:bundle": "esbuild src/compat.ts --bundle --format=esm --sourcemap --outfile=dist/comfy.js --platform=browser",
+ "build:cjs": "esbuild src/compat.ts --bundle --format=cjs --sourcemap --outfile=dist/comfy.cjs --platform=node",
+ "build:minify": "esbuild src/compat.ts --bundle --minify --sourcemap --format=iife --global-name=ComfyJSModule --footer:js=\"window.ComfyJS=ComfyJSModule.default\" --outfile=dist/comfy.min.js --platform=browser",
"dev": "tsc --watch",
"test": "vitest",
"test:run": "vitest run",
diff --git a/src/backcompat.test.ts b/src/backcompat.test.ts
new file mode 100644
index 0000000..84be122
--- /dev/null
+++ b/src/backcompat.test.ts
@@ -0,0 +1,119 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import ComfyJS from './compat';
+import { parseIRCMessage } from './parsers';
+
+const comfy = ComfyJS as any;
+
+function dispatch(raw: string): void {
+ comfy.handleIRCMessage(parseIRCMessage(raw));
+}
+
+const defaults = {
+ onError: ComfyJS.onError,
+ onChat: ComfyJS.onChat,
+ onCommand: ComfyJS.onCommand,
+ onJoin: ComfyJS.onJoin,
+ onPart: ComfyJS.onPart,
+ onRaid: ComfyJS.onRaid,
+ onCheer: ComfyJS.onCheer,
+};
+
+afterEach(() => {
+ Object.assign(ComfyJS, defaults);
+});
+
+describe('v1 callback compatibility contract', () => {
+ it('delivers highlighted messages through onChat as messageType=chat', () => {
+ const onChat = vi.fn();
+ ComfyJS.onChat = onChat;
+
+ dispatch('@badges=;color=#123456;display-name=Viewer;emotes=;id=msg-1;mod=0;msg-id=highlighted-message;room-id=123;subscriber=0;tmi-sent-ts=1000;user-id=42 :viewer!viewer@viewer.tmi.twitch.tv PRIVMSG #channel :hello');
+
+ expect(onChat).toHaveBeenCalledOnce();
+ const [user, message, flags, self, extra] = onChat.mock.calls[0];
+ expect(user).toBe('Viewer');
+ expect(message).toBe('hello');
+ expect(flags.highlighted).toBe(true);
+ expect(self).toBe(false);
+ expect(extra.messageType).toBe('chat');
+ });
+
+ it('delivers custom reward messages through onChat', () => {
+ const onChat = vi.fn();
+ ComfyJS.onChat = onChat;
+
+ dispatch('@badges=;custom-reward-id=reward-1;display-name=Viewer;emotes=;id=msg-2;mod=0;msg-id=channel_points_reward;room-id=123;subscriber=0;tmi-sent-ts=1001;user-id=42 :viewer!viewer@viewer.tmi.twitch.tv PRIVMSG #channel :reward text');
+
+ expect(onChat).toHaveBeenCalledOnce();
+ const [, message, flags, , extra] = onChat.mock.calls[0];
+ expect(message).toBe('reward text');
+ expect(flags.customReward).toBe(true);
+ expect(extra.customRewardId).toBe('reward-1');
+ expect(extra.messageType).toBe('chat');
+ });
+
+ it('preserves /me ACTION semantics', () => {
+ const onChat = vi.fn();
+ ComfyJS.onChat = onChat;
+
+ dispatch('@badges=;display-name=Viewer;emotes=;id=msg-3;mod=0;room-id=123;subscriber=0;tmi-sent-ts=1002;user-id=42 :viewer!viewer@viewer.tmi.twitch.tv PRIVMSG #channel :\u0001ACTION waves hello\u0001');
+
+ expect(onChat).toHaveBeenCalledOnce();
+ const [, message, , , extra] = onChat.mock.calls[0];
+ expect(message).toBe('waves hello');
+ expect(extra.messageType).toBe('action');
+ });
+
+ it('preserves the @mention !command form', () => {
+ const onCommand = vi.fn();
+ ComfyJS.onCommand = onCommand;
+
+ dispatch('@badges=;display-name=Viewer;emotes=;id=msg-4;mod=0;room-id=123;subscriber=0;tmi-sent-ts=1003;user-id=42 :viewer!viewer@viewer.tmi.twitch.tv PRIVMSG #channel :@bot !hello one two');
+
+ expect(onCommand).toHaveBeenCalledOnce();
+ const [user, command, message, , extra] = onCommand.mock.calls[0];
+ expect(user).toBe('Viewer');
+ expect(command).toBe('hello');
+ expect(message).toBe('one two');
+ expect(extra.messageType).toBe('chat');
+ });
+
+ it('keeps onJoin legacy extra shape', () => {
+ const onJoin = vi.fn();
+ ComfyJS.onJoin = onJoin;
+
+ dispatch(':viewer!viewer@viewer.tmi.twitch.tv JOIN #channel');
+
+ expect(onJoin).toHaveBeenCalledWith('viewer', false, { channel: 'channel' });
+ });
+
+ it('keeps onPart legacy extra shape', () => {
+ const onPart = vi.fn();
+ ComfyJS.onPart = onPart;
+
+ dispatch(':viewer!viewer@viewer.tmi.twitch.tv PART #channel');
+
+ expect(onPart).toHaveBeenCalledWith('viewer', false, { channel: 'channel' });
+ });
+
+ it('keeps onRaid legacy extra shape', () => {
+ const onRaid = vi.fn();
+ ComfyJS.onRaid = onRaid;
+
+ dispatch('@badge-info=;badges=broadcaster/1;color=#00FFFF;display-name=RaidingStreamer;emotes=;id=raid123;login=raidingstreamer;mod=0;msg-id=raid;msg-param-displayName=RaidingStreamer;msg-param-login=raidingstreamer;msg-param-viewerCount=500;room-id=12345;subscriber=0;tmi-sent-ts=1004;user-id=22222 :tmi.twitch.tv USERNOTICE #channel');
+
+ expect(onRaid).toHaveBeenCalledWith('RaidingStreamer', 500, { channel: 'channel' });
+ });
+
+ it('does not turn a cheer into a second onChat event', () => {
+ const onCheer = vi.fn();
+ const onChat = vi.fn();
+ ComfyJS.onCheer = onCheer;
+ ComfyJS.onChat = onChat;
+
+ dispatch('@badges=subscriber/12;bits=100;color=#FF69B4;display-name=Cheerful;emotes=;id=cheer123;mod=0;room-id=12345;subscriber=1;tmi-sent-ts=1005;user-id=33333 :cheerful!cheerful@cheerful.tmi.twitch.tv PRIVMSG #channel :Cheer100 Great stream!');
+
+ expect(onCheer).toHaveBeenCalledOnce();
+ expect(onChat).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/compat.ts b/src/compat.ts
new file mode 100644
index 0000000..774902b
--- /dev/null
+++ b/src/compat.ts
@@ -0,0 +1,449 @@
+import ComfyJS from './index';
+import type { ComfyJSInstance, IRCMessage, UserExtra } from './types';
+import type { EventSubNotification } from './eventsub';
+import { buildUserExtra, parseCommand, parseUserFlags } from './parsers';
+
+export type EventSubCallback = (
+ type: string,
+ event: Record,
+ version: string
+) => void;
+
+export interface ComfyJSModernExtensions {
+ /** Awaitable counterpart to the legacy fire-and-forget Init(). */
+ InitAsync(
+ username: string,
+ password?: string,
+ channels?: string | string[],
+ isDebug?: boolean
+ ): Promise;
+
+ /** Receives every EventSub notification before any convenience callback runs. */
+ onEventSub: EventSubCallback;
+
+ /** Subscribe to any Twitch EventSub type without waiting for a ComfyJS release. */
+ SubscribeEventSub(
+ type: string,
+ version: string,
+ condition: Record
+ ): Promise;
+
+ /** Remove an EventSub subscription by Twitch subscription ID. */
+ UnsubscribeEventSub(id: string): Promise;
+
+ /** Return Twitch's current EventSub subscription inventory for this token/app. */
+ GetEventSubSubscriptions(): Promise;
+
+ /** Get the currently pinned mod message for a channel. */
+ GetPinnedChatMessage(channel?: string): Promise;
+
+ /** Pin an existing chat message. Twitch currently accepts 30-1800 seconds. */
+ PinChatMessage(messageId: string, durationSeconds?: number, channel?: string): Promise;
+
+ /** Change the remaining duration of the current pinned chat message. */
+ UpdatePinnedChatMessage(messageId: string, durationSeconds?: number, channel?: string): Promise;
+
+ /** Unpin an existing chat message. */
+ UnpinChatMessage(messageId: string, channel?: string): Promise;
+}
+
+type LegacyInit = (
+ username: string,
+ password?: string,
+ channels?: string | string[],
+ isDebug?: boolean
+) => void;
+
+export type ComfyJSPublicInstance = Omit &
+ ComfyJSModernExtensions & {
+ /** v1-compatible fire-and-forget initialization. Use InitAsync to await readiness. */
+ Init: LegacyInit;
+ };
+
+/**
+ * Compatibility boundary for the public ComfyJS singleton.
+ *
+ * v2 internals are free to evolve, but the handlers installed here preserve the
+ * observable v1 contract. New data is exposed through additive APIs/events
+ * rather than changing legacy callbacks or method return values.
+ */
+const comfy = ComfyJS as any;
+
+type TimestampStore = {
+ global: Record;
+ users: Record>;
+};
+
+const timestamps: TimestampStore = {
+ global: {},
+ users: {},
+};
+
+function getTimePeriod(command: string, userId?: string): { any: number; user: number | null } {
+ const now = Date.now();
+ const previousGlobal = timestamps.global[command];
+ const any = previousGlobal === undefined ? 0 : now - previousGlobal;
+ timestamps.global[command] = now;
+
+ if (!userId) {
+ return { any, user: null };
+ }
+
+ timestamps.users[userId] ||= {};
+ const previousUser = timestamps.users[userId][command];
+ const user = previousUser === undefined ? 0 : now - previousUser;
+ timestamps.users[userId][command] = now;
+ return { any, user };
+}
+
+function normalizePrivmsg(msg: IRCMessage): { message: string; messageType: 'chat' | 'action' } {
+ const raw = msg.message || '';
+ const actionPrefix = '\u0001ACTION ';
+
+ if (raw.startsWith(actionPrefix) && raw.endsWith('\u0001')) {
+ return {
+ message: raw.slice(actionPrefix.length, -1),
+ messageType: 'action',
+ };
+ }
+
+ return { message: raw, messageType: 'chat' };
+}
+
+function legacyExtra(msg: IRCMessage, messageType: string): UserExtra {
+ return {
+ ...buildUserExtra(msg),
+ messageType,
+ };
+}
+
+function reportAsyncError(instance: any, error: unknown): void {
+ instance.onError(error instanceof Error ? error : new Error(String(error)));
+}
+
+function afterInit(instance: any, action: () => boolean | void | Promise): void {
+ const pending = instance.__comfyInitPromise as Promise | undefined;
+ const run = async (): Promise => {
+ if (pending) await pending;
+ await action();
+ };
+ void run().catch(error => reportAsyncError(instance, error));
+}
+
+async function resolveChannelId(instance: any, channel?: string): Promise {
+ const target = channel?.replace('#', '').toLowerCase();
+ if (!target || target === instance.mainChannel) {
+ if (!instance.channelId) throw new Error('Channel ID is not available');
+ return instance.channelId;
+ }
+ if (!instance.api) throw new Error('Twitch API is not initialized');
+ const user = await instance.api.getUserByLogin(target);
+ if (!user) throw new Error(`Twitch channel '${target}' was not found`);
+ return user.id;
+}
+
+async function twitchRequest(instance: any, path: string, init: RequestInit = {}): Promise {
+ if (!instance.password || !instance.clientId) {
+ throw new Error('An OAuth token is required for this Twitch API operation');
+ }
+ const response = await fetch(`https://api.twitch.tv/helix${path}`, {
+ ...init,
+ headers: {
+ 'Client-ID': instance.clientId,
+ 'Authorization': `Bearer ${instance.password}`,
+ ...(init.body ? { 'Content-Type': 'application/json' } : {}),
+ ...(init.headers || {}),
+ },
+ });
+ if (!response.ok) {
+ const body = await response.text().catch(() => '');
+ throw new Error(`Twitch API ${response.status}: ${body || response.statusText}`);
+ }
+ return response;
+}
+
+const originalInit = comfy.Init.bind(comfy);
+const originalSay = comfy.Say.bind(comfy);
+const originalReply = comfy.Reply.bind(comfy);
+const originalHandleUserNotice = comfy.handleUserNotice.bind(comfy);
+const originalHandleEventSubNotification = comfy.handleEventSubNotification.bind(comfy);
+
+function beginInit(
+ instance: any,
+ username: string,
+ password?: string,
+ channels?: string | string[],
+ isDebug?: boolean
+): Promise {
+ const promise = originalInit(username, password, channels, isDebug);
+ instance.__comfyInitPromise = promise;
+ void promise.finally(() => {
+ if (instance.__comfyInitPromise === promise) instance.__comfyInitPromise = undefined;
+ }).catch(() => {});
+ return promise;
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Frozen v1 method contract
+// ─────────────────────────────────────────────────────────────────────────────
+
+comfy.Init = function InitCompat(
+ username: string,
+ password?: string,
+ channels?: string | string[],
+ isDebug?: boolean
+): void {
+ void beginInit(this, username, password, channels, isDebug)
+ .catch(error => reportAsyncError(this, error));
+};
+
+comfy.InitAsync = function InitAsync(
+ username: string,
+ password?: string,
+ channels?: string | string[],
+ isDebug?: boolean
+): Promise {
+ return beginInit(this, username, password, channels, isDebug);
+};
+
+comfy.Say = function SayCompat(message: string, channel?: string): boolean {
+ if (this.irc) return originalSay(message, channel);
+ if (!this.__comfyInitPromise) return false;
+ afterInit(this, () => originalSay(message, channel));
+ return true;
+};
+
+comfy.Reply = function ReplyCompat(parentId: string, message: string, channel?: string): boolean {
+ if (this.irc) return originalReply(parentId, message, channel);
+ if (!this.__comfyInitPromise) return false;
+ afterInit(this, () => originalReply(parentId, message, channel));
+ return true;
+};
+
+comfy.Whisper = function WhisperCompat(message: string, user: string): boolean {
+ if (!this.irc && !this.__comfyInitPromise) return false;
+
+ afterInit(this, async () => {
+ if (!this.api || !this.userId) throw new Error('Whisper requires an authenticated ComfyJS connection');
+ const target = await this.api.getUserByLogin(user);
+ if (!target) throw new Error(`Twitch user '${user}' was not found`);
+ await twitchRequest(
+ this,
+ `/whispers?from_user_id=${encodeURIComponent(this.userId)}&to_user_id=${encodeURIComponent(target.id)}`,
+ { method: 'POST', body: JSON.stringify({ message }) }
+ );
+ });
+ return true;
+};
+
+comfy.DeleteMessage = function DeleteMessageCompat(id: string, channel?: string): boolean {
+ if (!this.irc && !this.__comfyInitPromise) return false;
+
+ afterInit(this, async () => {
+ if (!this.api || !this.userId) throw new Error('DeleteMessage requires an authenticated ComfyJS connection');
+ const broadcasterId = await resolveChannelId(this, channel);
+ await this.api.deleteMessage(broadcasterId, this.userId, id);
+ });
+ return true;
+};
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Frozen v1 IRC callback contract
+// ─────────────────────────────────────────────────────────────────────────────
+
+comfy.handlePrivmsg = function handlePrivmsgCompat(msg: IRCMessage): void {
+ try {
+ const channel = msg.channel?.replace('#', '') || '';
+ const username = msg.tags['display-name'] || msg.tags.username || msg.nick || '';
+ const { message, messageType } = normalizePrivmsg(msg);
+ const self = false;
+ const flags = parseUserFlags(msg.tags, channel);
+ const extra = legacyExtra(msg, messageType);
+
+ const bits = Number.parseInt(msg.tags.bits || '0', 10);
+ if (bits > 0) {
+ const cheerFlags = {
+ broadcaster: flags.broadcaster,
+ mod: flags.mod,
+ founder: flags.founder,
+ subscriber: flags.subscriber,
+ vip: flags.vip,
+ };
+ const cheerExtra = {
+ id: extra.id,
+ channel: extra.channel,
+ roomId: extra.roomId,
+ userId: extra.userId,
+ username: extra.username,
+ userColor: extra.userColor,
+ userBadges: extra.userBadges,
+ userState: extra.userState,
+ displayName: extra.displayName,
+ messageEmotes: extra.messageEmotes,
+ subscriber: msg.tags.subscriber || '',
+ };
+ this.onCheer(username, message, bits, cheerFlags, cheerExtra);
+ return;
+ }
+
+ const parsed = parseCommand(message);
+ if (!self && parsed) {
+ const sinceLastCommand = getTimePeriod(parsed.command, msg.tags['user-id']);
+ this.onCommand(username, parsed.command, parsed.args, flags, {
+ ...extra,
+ sinceLastCommand: {
+ any: sinceLastCommand.any,
+ user: sinceLastCommand.user ?? 0,
+ },
+ });
+ return;
+ }
+
+ this.onChat(username, message, flags, self, extra);
+ } catch (error) {
+ this.onError(error instanceof Error ? error : new Error(String(error)));
+ }
+};
+
+comfy.handleWhisper = function handleWhisperCompat(msg: IRCMessage): void {
+ try {
+ const username = msg.tags['display-name'] || msg.tags.username || msg.nick || '';
+ const message = msg.message || '';
+ const flags = parseUserFlags(msg.tags, msg.nick || '');
+ this.onWhisper(username, message, flags, false, legacyExtra(msg, 'whisper'));
+ } catch (error) {
+ this.onError(error instanceof Error ? error : new Error(String(error)));
+ }
+};
+
+comfy.handleJoin = function handleJoinCompat(msg: IRCMessage): void {
+ const channel = msg.channel?.replace('#', '') || '';
+ const username = msg.nick || msg.prefix?.split('!')[0] || '';
+ const self = username.toLowerCase() === this.irc?.username?.toLowerCase();
+ this.onJoin(username, self, { channel });
+};
+
+comfy.handlePart = function handlePartCompat(msg: IRCMessage): void {
+ const channel = msg.channel?.replace('#', '') || '';
+ const username = msg.nick || msg.prefix?.split('!')[0] || '';
+ const self = username.toLowerCase() === this.irc?.username?.toLowerCase();
+ this.onPart(username, self, { channel });
+};
+
+comfy.handleUserNotice = function handleUserNoticeCompat(msg: IRCMessage): void {
+ if (msg.tags['msg-id'] === 'raid') {
+ const username = msg.tags['display-name'] || msg.tags.login || '';
+ const viewers = Number.parseInt(msg.tags['msg-param-viewerCount'] || '0', 10);
+ this.onRaid(username, viewers, { channel: msg.channel?.replace('#', '') || '' });
+ return;
+ }
+
+ originalHandleUserNotice(msg);
+};
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Additive modern EventSub surface
+// ─────────────────────────────────────────────────────────────────────────────
+
+comfy.onEventSub = (_type: string, _event: Record, _version: string): void => {};
+
+comfy.handleEventSubNotification = function handleEventSubNotificationCompat(
+ notification: EventSubNotification
+): void {
+ try {
+ this.onEventSub(
+ notification.subscriptionType,
+ notification.event,
+ notification.subscriptionVersion
+ );
+ } catch (error) {
+ this.onError(error instanceof Error ? error : new Error(String(error)));
+ }
+
+ originalHandleEventSubNotification(notification);
+};
+
+comfy.SubscribeEventSub = async function SubscribeEventSub(
+ type: string,
+ version: string,
+ condition: Record
+): Promise {
+ if (!this.eventSub) {
+ throw new Error('EventSub is not initialized. Call ComfyJS.Init() with an OAuth token first.');
+ }
+ return this.eventSub.subscribe(type, version, condition);
+};
+
+comfy.UnsubscribeEventSub = async function UnsubscribeEventSub(id: string): Promise {
+ if (!this.api) {
+ throw new Error('Twitch API is not initialized. Call ComfyJS.Init() with an OAuth token first.');
+ }
+ await this.api.deleteEventSubSubscription(id);
+ this.eventSub?.unregisterSubscription(id);
+};
+
+comfy.GetEventSubSubscriptions = async function GetEventSubSubscriptions(): Promise {
+ if (!this.api) {
+ throw new Error('Twitch API is not initialized. Call ComfyJS.Init() with an OAuth token first.');
+ }
+ return this.api.getEventSubSubscriptions();
+};
+
+comfy.GetPinnedChatMessage = async function GetPinnedChatMessage(channel?: string): Promise {
+ const broadcasterId = await resolveChannelId(this, channel);
+ const response = await twitchRequest(
+ this,
+ `/chat/pins?broadcaster_id=${encodeURIComponent(broadcasterId)}&moderator_id=${encodeURIComponent(this.userId)}`
+ );
+ const data = await response.json() as { data?: unknown[] };
+ return data.data || [];
+};
+
+async function mutatePin(
+ instance: any,
+ method: 'PUT' | 'PATCH' | 'DELETE',
+ messageId: string,
+ durationSeconds?: number,
+ channel?: string
+): Promise {
+ const broadcasterId = await resolveChannelId(instance, channel);
+ const params = new URLSearchParams({
+ broadcaster_id: broadcasterId,
+ moderator_id: instance.userId,
+ message_id: messageId,
+ });
+ if (durationSeconds !== undefined && method !== 'DELETE') {
+ params.set('duration_seconds', String(durationSeconds));
+ }
+ await twitchRequest(instance, `/chat/pins?${params}`, { method });
+}
+
+comfy.PinChatMessage = function PinChatMessage(
+ messageId: string,
+ durationSeconds?: number,
+ channel?: string
+): Promise {
+ return mutatePin(this, 'PUT', messageId, durationSeconds, channel);
+};
+
+comfy.UpdatePinnedChatMessage = function UpdatePinnedChatMessage(
+ messageId: string,
+ durationSeconds?: number,
+ channel?: string
+): Promise {
+ return mutatePin(this, 'PATCH', messageId, durationSeconds, channel);
+};
+
+comfy.UnpinChatMessage = function UnpinChatMessage(messageId: string, channel?: string): Promise {
+ return mutatePin(this, 'DELETE', messageId, undefined, channel);
+};
+
+const PublicComfyJS = ComfyJS as unknown as ComfyJSPublicInstance;
+
+export default PublicComfyJS;
+export * from './types';
+export { IRCClient } from './irc';
+export { EventSubClient, EventSubTypes } from './eventsub';
+export { TwitchAPI } from './api';
+export { P2PCoordinator } from './p2p';
+export * from './parsers';
diff --git a/src/modern.test.ts b/src/modern.test.ts
new file mode 100644
index 0000000..64e7f53
--- /dev/null
+++ b/src/modern.test.ts
@@ -0,0 +1,57 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import ComfyJS from './compat';
+
+const comfy = ComfyJS as any;
+const defaultOnEventSub = ComfyJS.onEventSub;
+
+afterEach(() => {
+ ComfyJS.onEventSub = defaultOnEventSub;
+ comfy.eventSub = null;
+ comfy.api = null;
+});
+
+describe('modern EventSub surface', () => {
+ it('forwards unknown EventSub types through onEventSub', () => {
+ const handler = vi.fn();
+ ComfyJS.onEventSub = handler;
+
+ comfy.handleEventSubNotification({
+ subscriptionType: 'channel.future_event.add',
+ subscriptionVersion: '7',
+ event: { id: 'future-1', useful: true },
+ });
+
+ expect(handler).toHaveBeenCalledWith(
+ 'channel.future_event.add',
+ { id: 'future-1', useful: true },
+ '7'
+ );
+ });
+
+ it('can subscribe to arbitrary EventSub types', async () => {
+ const subscribe = vi.fn().mockResolvedValue({ id: 'sub-1' });
+ comfy.eventSub = { subscribe };
+
+ await expect(
+ ComfyJS.SubscribeEventSub('channel.future_event.add', '7', {
+ broadcaster_user_id: '123',
+ })
+ ).resolves.toEqual({ id: 'sub-1' });
+
+ expect(subscribe).toHaveBeenCalledWith('channel.future_event.add', '7', {
+ broadcaster_user_id: '123',
+ });
+ });
+
+ it('can unsubscribe without leaking the internal clients', async () => {
+ const deleteEventSubSubscription = vi.fn().mockResolvedValue(undefined);
+ const unregisterSubscription = vi.fn();
+ comfy.api = { deleteEventSubSubscription };
+ comfy.eventSub = { unregisterSubscription };
+
+ await ComfyJS.UnsubscribeEventSub('sub-1');
+
+ expect(deleteEventSubSubscription).toHaveBeenCalledWith('sub-1');
+ expect(unregisterSubscription).toHaveBeenCalledWith('sub-1');
+ });
+});