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.

+ +
+ + + + +
+ +
+ Loading clients… + Matches: 0 + Differences: 0 + Unpaired: 0 +
+ + + + +
ResultCallback / keyv1 argumentsv2 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'); + }); +});