@single-studio/plugin-twitch 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Shaun Delaney
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # @single-studio/plugin-twitch
2
+
3
+ Twitch EventSub for
4
+ [Single Studio](https://fourcourtjester.github.io/Single-Studio/): chat, follows,
5
+ subs, gifts, cheers and raids, straight into a studio with no backend.
6
+
7
+ ```bash
8
+ npm i @single-studio/plugin-twitch
9
+ ```
10
+
11
+ ```js
12
+ import { twitch, TwitchHandler } from '@single-studio/plugin-twitch'
13
+
14
+ class MyShow extends TwitchHandler {
15
+ onFollow({ from }) {
16
+ this.mutate('set', { 'variables.alert.name': from.name, 'variables.alert.kind': 'follow' })
17
+ }
18
+ }
19
+
20
+ createVelcroHost({ name: STUDIO_ID, mutations, plugins: [twitch(MyShow)] })
21
+ ```
22
+
23
+ ## What it tells you
24
+
25
+ `onChat`, `onFollow`, `onSubscribe`, `onResubscribe`, `onGift`, `onCheer`, `onRaid`.
26
+
27
+ ## What the operator sets
28
+
29
+ A client id, the channel's numeric user id, their own user id, and an access token.
30
+ All four are facts about that person and their app, so they belong on the board rather
31
+ than in a build.
32
+
33
+ `Events` is a comma-separated list, blank for all of them. Subscribing to less than
34
+ everything is worth doing: each type is a separate subscription created at connect
35
+ time, and the ones you do not read still cost a round trip.
36
+
37
+ ## Two things worth knowing
38
+
39
+ **A reconnect is a handover, not a drop.** Twitch hands over a URL rather than closing,
40
+ and the old socket keeps delivering until the new one has welcomed — so nothing is
41
+ missed and no graphic flickers. The plugin does that for you.
42
+
43
+ **Some subscriptions can be refused while others succeed**, usually because a scope is
44
+ missing. The plugin keeps going with what it got and says which were refused on the
45
+ board, rather than treating a partial success as a failed connection.
46
+
47
+ ## Licence
48
+
49
+ MIT.
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@single-studio/plugin-twitch",
3
+ "version": "0.6.0",
4
+ "description": "Twitch EventSub for Single Studio: chat, follows, subs, gifts, cheers and raids, straight into a studio with no backend.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/FourCourtJester/Single-Studio.git",
9
+ "directory": "packages/plugin-twitch"
10
+ },
11
+ "homepage": "https://github.com/FourCourtJester/Single-Studio#readme",
12
+ "bugs": "https://github.com/FourCourtJester/Single-Studio/issues",
13
+ "type": "module",
14
+ "keywords": [
15
+ "single-studio",
16
+ "single-studio-plugin",
17
+ "obs",
18
+ "broadcast"
19
+ ],
20
+ "exports": {
21
+ ".": "./src/index.js"
22
+ },
23
+ "files": [
24
+ "src",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "scripts": {
29
+ "test": "vitest run"
30
+ },
31
+ "peerDependencies": {
32
+ "@single-studio/core": "^0.6.0"
33
+ },
34
+ "devDependencies": {
35
+ "@single-studio/core": "workspace:*",
36
+ "vitest": "^3.2.6"
37
+ }
38
+ }
package/src/events.js ADDED
@@ -0,0 +1,133 @@
1
+ // Twitch's payloads, turned into something a studio author would have written.
2
+ //
3
+ // The wire format is snake_case with the broadcaster's name repeated in three
4
+ // fields on every event, and a subscription tier of "1000". None of that is wrong,
5
+ // and all of it is Twitch's shape rather than a show's. A studio should write
6
+ // `onCheer({ bits, from })`, not reach through `event.user_name` and remember that
7
+ // anonymous cheers set it to null.
8
+ //
9
+ // Each of these is a pure function from one payload to one object, which is what
10
+ // makes the whole module testable against a recorded sample and nothing else.
11
+
12
+ /** Twitch sends tiers as "1000" | "2000" | "3000". Nobody says "tier one thousand". */
13
+ const tierOf = (raw) => (raw === '2000' ? 2 : raw === '3000' ? 3 : 1)
14
+
15
+ /**
16
+ * Who did it, in one shape, whoever they are.
17
+ *
18
+ * The prefix is the whole field stem, not a role: chat calls its author
19
+ * `chatter_user_id`, so the prefix is `chatter_user` rather than `chatter`. Getting
20
+ * that wrong reads every field as undefined and puts a nameless chatter on air,
21
+ * which is what happened the first time this was written.
22
+ */
23
+ const actor = (event, prefix = 'user') => ({
24
+ id: event?.[`${prefix}_id`] ?? null,
25
+ login: event?.[`${prefix}_login`] ?? null,
26
+ name: event?.[`${prefix}_name`] ?? event?.[`${prefix}_login`] ?? null,
27
+ })
28
+
29
+ /**
30
+ * The subset of `channel.chat.message` a graphic actually shows.
31
+ *
32
+ * `fragments` is kept because it is the only way to render emotes, and a chat
33
+ * overlay that cannot show emotes is not a chat overlay. Everything else about the
34
+ * message -- badges as ids, the source room for shared chat -- stays on `raw`.
35
+ */
36
+ const chat = (event) => ({
37
+ id: event?.message_id ?? null,
38
+ text: event?.message?.text ?? '',
39
+ fragments: event?.message?.fragments ?? [],
40
+ from: actor(event, 'chatter_user'),
41
+ colour: event?.color || null,
42
+ badges: (event?.badges ?? []).map((badge) => badge.set_id),
43
+ // A first message from somebody is worth greeting, and it is the sort of thing a
44
+ // studio wants without having to work out what "message_type" means.
45
+ first: event?.message_type === 'user_intro' || Boolean(event?.is_first_message),
46
+ reply: event?.reply ? { to: event.reply.parent_user_name ?? null, text: event.reply.parent_message_body ?? null } : null,
47
+ })
48
+
49
+ const follow = (event) => ({ from: actor(event), at: event?.followed_at ?? null })
50
+
51
+ const subscribe = (event) => ({
52
+ from: actor(event),
53
+ tier: tierOf(event?.tier),
54
+ gifted: Boolean(event?.is_gift),
55
+ })
56
+
57
+ /** A resub, where the point is the message the viewer wrote with it. */
58
+ const resub = (event) => ({
59
+ from: actor(event),
60
+ tier: tierOf(event?.tier),
61
+ months: event?.cumulative_months ?? null,
62
+ streak: event?.streak_months ?? null,
63
+ text: event?.message?.text ?? '',
64
+ fragments: event?.message?.fragments ?? [],
65
+ })
66
+
67
+ const gift = (event) => ({
68
+ // Anonymous gifts arrive with the user fields null rather than absent, which is
69
+ // the one case that will put "null" on air if nobody looks for it.
70
+ from: event?.is_anonymous ? null : actor(event),
71
+ anonymous: Boolean(event?.is_anonymous),
72
+ count: event?.total ?? 1,
73
+ tier: tierOf(event?.tier),
74
+ lifetime: event?.cumulative_total ?? null,
75
+ })
76
+
77
+ const cheer = (event) => ({
78
+ from: event?.is_anonymous ? null : actor(event),
79
+ anonymous: Boolean(event?.is_anonymous),
80
+ bits: event?.bits ?? 0,
81
+ text: event?.message ?? '',
82
+ })
83
+
84
+ const raid = (event) => ({
85
+ from: {
86
+ id: event?.from_broadcaster_user_id ?? null,
87
+ login: event?.from_broadcaster_user_login ?? null,
88
+ name: event?.from_broadcaster_user_name ?? event?.from_broadcaster_user_login ?? null,
89
+ },
90
+ viewers: event?.viewers ?? 0,
91
+ })
92
+
93
+ /**
94
+ * Twitch's subscription type, the event this plugin emits, and how to shape it.
95
+ *
96
+ * Keyed by the wire name so a notification is one lookup, and exported so the
97
+ * plugin can turn it into the subscription requests it has to create -- the same
98
+ * list doing both jobs, rather than two that can disagree.
99
+ */
100
+ export const EVENTS = {
101
+ 'channel.chat.message': { emit: 'chat', shape: chat, scope: 'user:read:chat' },
102
+ 'channel.follow': { emit: 'follow', shape: follow, scope: 'moderator:read:followers', version: '2' },
103
+ 'channel.subscribe': { emit: 'subscribe', shape: subscribe, scope: 'channel:read:subscriptions' },
104
+ 'channel.subscription.message': { emit: 'resub', shape: resub, scope: 'channel:read:subscriptions' },
105
+ 'channel.subscription.gift': { emit: 'gift', shape: gift, scope: 'channel:read:subscriptions' },
106
+ 'channel.cheer': { emit: 'cheer', shape: cheer, scope: 'bits:read' },
107
+ 'channel.raid': { emit: 'raid', shape: raid, scope: null },
108
+ }
109
+
110
+ /** Every scope the configured events need, deduped. */
111
+ export const scopesFor = (types = Object.keys(EVENTS)) => [...new Set(types.map((type) => EVENTS[type]?.scope).filter(Boolean))]
112
+
113
+ /**
114
+ * Turn one notification into the event a studio hears.
115
+ *
116
+ * Unknown types still come through, under their wire name and unshaped, rather than
117
+ * being dropped: Twitch adds subscription types faster than any plugin tracks them,
118
+ * and a studio that wants a new one should not have to wait for a release.
119
+ *
120
+ * @param {string} type
121
+ * @param {unknown} event
122
+ * @returns {{ name: string, payload: object }}
123
+ */
124
+ export function normalise(type, event) {
125
+ const known = EVENTS[type]
126
+
127
+ return {
128
+ name: known?.emit ?? type,
129
+ // `raw` on everything, always. The shaped fields are the ones a show usually
130
+ // wants; the escape hatch is what stops a missing field being a blocker.
131
+ payload: { ...(known ? known.shape(event) : {}), raw: event },
132
+ }
133
+ }
package/src/index.js ADDED
@@ -0,0 +1,273 @@
1
+ import { definePlugin, PluginHandler, SocketService } from '@single-studio/core/worker'
2
+
3
+ import { EVENTS, normalise } from './events.js'
4
+ import { Protocol } from './protocol.js'
5
+
6
+ export { EVENTS, normalise, scopesFor } from './events.js'
7
+ export { Protocol } from './protocol.js'
8
+
9
+ const EVENTSUB = 'wss://eventsub.wss.twitch.tv/ws'
10
+ const HELIX = 'https://api.twitch.tv/helix/eventsub/subscriptions'
11
+
12
+ /**
13
+ * Twitch chat, follows, subs, gifts, cheers and raids, in the SharedWorker.
14
+ *
15
+ * In the worker rather than on a page for the reason every ingress is: one socket
16
+ * for the whole studio. A chat overlay, a board, and an alert graphic are three
17
+ * pages, and three sockets would be three copies of every message and three sets of
18
+ * Twitch's rate limits to spend.
19
+ *
20
+ * Extends `Service`, so reconnection, exponential backoff and the ownership
21
+ * predicate come from the framework. What is here is the part that is Twitch's:
22
+ * the session handshake, the keepalive watchdog, and the subscriptions that have to
23
+ * be created after connecting rather than before.
24
+ */
25
+ class Twitch extends SocketService {
26
+ static serviceName = 'twitch'
27
+
28
+ /** The socket being handed over to, during a reconnect. */
29
+ #next = null
30
+
31
+ #protocol = new Protocol()
32
+
33
+ /** Not usable until subscribed, so `open()` waits for that rather than the socket. */
34
+ get readyOnOpen() {
35
+ return false
36
+ }
37
+
38
+ /**
39
+ * Longer than the default, because `ready()` here is much further away than a
40
+ * socket coming up.
41
+ *
42
+ * Between the two are a welcome frame and then one HTTP round trip per event
43
+ * type, in a row -- seven by default. Ten seconds is comfortable for a handshake
44
+ * and is not obviously comfortable for that, and being wrong is not a slow
45
+ * connection but a retry loop that fails at the same place every time. The
46
+ * deadline is still worth having: it is bounded, and the failure it exists for
47
+ * -- a socket accepted and then abandoned -- is not made likelier by a bigger
48
+ * number.
49
+ */
50
+ get connectBudgetMs() {
51
+ return 30_000
52
+ }
53
+
54
+ get url() {
55
+ return EVENTSUB
56
+ }
57
+
58
+ /** Sized by what Twitch said in the welcome rather than by a guess. */
59
+ get silenceBudgetMs() {
60
+ return this.#protocol.silenceBudgetMs
61
+ }
62
+
63
+ /** The events this studio asked for, or all of them. */
64
+ get types() {
65
+ const asked = String(this.config.events ?? '')
66
+ .split(',')
67
+ .map((type) => type.trim())
68
+ .filter(Boolean)
69
+
70
+ return asked.length ? asked : Object.keys(EVENTS)
71
+ }
72
+
73
+ open() {
74
+ if (!this.config.token) return Promise.reject(new Error('Not signed in to Twitch yet.'))
75
+ if (!this.config.clientId) return Promise.reject(new Error('A Twitch application Client ID is needed.'))
76
+ if (!this.config.broadcasterId) return Promise.reject(new Error('The broadcaster user id is needed.'))
77
+
78
+ return super.open()
79
+ }
80
+
81
+ async receive(raw, socket) {
82
+ const action = this.#protocol.handle(raw)
83
+
84
+ switch (action.do) {
85
+ case 'subscribe': {
86
+ // A welcome on the incoming socket during a handover: that one is now the
87
+ // live one, and the old can go.
88
+ if (socket === this.#next) {
89
+ // Subscriptions belong to the session, and the new session already has
90
+ // them -- Twitch carries them across a reconnect. Nothing to create.
91
+ this.adopt(socket)
92
+ this.#next = null
93
+
94
+ return
95
+ }
96
+
97
+ try {
98
+ await this.#subscribe(action.session)
99
+ this.ready()
100
+ } catch (error) {
101
+ this.fail(error)
102
+ }
103
+
104
+ return
105
+ }
106
+
107
+ case 'deliver': {
108
+ const { name, payload } = normalise(action.type, action.event)
109
+
110
+ this.emit(name, payload)
111
+ this.emit('*', name, payload)
112
+
113
+ return
114
+ }
115
+
116
+ case 'reconnect':
117
+ // Twitch hands over a URL rather than closing, so the old socket keeps
118
+ // delivering until the new one has welcomed. Nothing is missed.
119
+ this.#next = this.connect(action.url)
120
+ this.#next.addEventListener('message', (event) => {
121
+ this.pet()
122
+
123
+ try {
124
+ this.receive(JSON.parse(event.data), this.#next)
125
+ } catch {
126
+ // Not JSON, so not this protocol.
127
+ }
128
+ })
129
+
130
+ return
131
+
132
+ case 'revoked':
133
+ // Otherwise the events simply stop and the overlay looks fine.
134
+ this.emit('revoked', { type: action.type, reason: action.reason })
135
+ console.warn(`[twitch] ${action.type} was revoked: ${action.reason}`)
136
+
137
+ return
138
+
139
+ default:
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Create the subscriptions for this session.
145
+ *
146
+ * After the welcome rather than before it: the session id is what ties a
147
+ * subscription to this socket, and it does not exist until Twitch says so.
148
+ */
149
+ async #subscribe(session) {
150
+ const failures = []
151
+
152
+ for (const type of this.types) {
153
+ const known = EVENTS[type]
154
+ const condition = { broadcaster_user_id: String(this.config.broadcasterId) }
155
+
156
+ // Each type words its condition differently, and a wrong one is rejected as a
157
+ // 400 that reads like a scope problem.
158
+ if (type === 'channel.chat.message') condition.user_id = String(this.config.userId || this.config.broadcasterId)
159
+ if (type === 'channel.follow') condition.moderator_user_id = String(this.config.userId || this.config.broadcasterId)
160
+ if (type === 'channel.raid') {
161
+ delete condition.broadcaster_user_id
162
+ condition.to_broadcaster_user_id = String(this.config.broadcasterId)
163
+ }
164
+
165
+ const response = await fetch(HELIX, {
166
+ method: 'POST',
167
+ headers: {
168
+ Authorization: `Bearer ${this.config.token}`,
169
+ 'Client-Id': this.config.clientId,
170
+ 'Content-Type': 'application/json',
171
+ },
172
+ body: JSON.stringify({
173
+ type,
174
+ version: known?.version ?? '1',
175
+ condition,
176
+ transport: { method: 'websocket', session_id: session },
177
+ }),
178
+ })
179
+
180
+ if (!response.ok) failures.push(`${type} (${response.status})`)
181
+ }
182
+
183
+ // Some working is better than none: a studio missing `bits:read` should still
184
+ // get chat rather than a dead plugin.
185
+ if (failures.length === this.types.length) throw new Error(`Twitch refused every subscription: ${failures.join(', ')}`)
186
+ if (failures.length) console.warn(`[twitch] some subscriptions were refused: ${failures.join(', ')}`)
187
+ }
188
+
189
+ async close() {
190
+ this.#next?.close()
191
+ this.#next = null
192
+ await super.close()
193
+ }
194
+ }
195
+
196
+ /** The skeleton a studio fills in. One method per event, all no-ops. */
197
+ export class TwitchHandler extends PluginHandler {
198
+ static handles = {
199
+ chat: 'onChat',
200
+ follow: 'onFollow',
201
+ subscribe: 'onSubscribe',
202
+ resub: 'onResub',
203
+ gift: 'onGift',
204
+ cheer: 'onCheer',
205
+ raid: 'onRaid',
206
+ revoked: 'onRevoked',
207
+ }
208
+
209
+ onChat() {}
210
+
211
+ onFollow() {}
212
+
213
+ onSubscribe() {}
214
+
215
+ onResub() {}
216
+
217
+ onGift() {}
218
+
219
+ onCheer() {}
220
+
221
+ onRaid() {}
222
+
223
+ onRevoked() {}
224
+ }
225
+
226
+ /**
227
+ * @param {typeof TwitchHandler} [Handler] The studio's subclass.
228
+ */
229
+ export const twitch = (Handler = TwitchHandler) =>
230
+ definePlugin({
231
+ name: 'twitch',
232
+ label: 'Twitch',
233
+ summary: 'Chat, follows, subs, gifts, cheers and raids, straight into the show.',
234
+ help: [
235
+ {
236
+ type: 'note',
237
+ text: 'Signing in properly is not built yet, so for now this needs an access token pasted in by hand. Tokens expire, so expect to redo this.',
238
+ },
239
+ {
240
+ type: 'steps',
241
+ items: [
242
+ 'Register an application at dev.twitch.tv/console/apps. The Client ID it gives you is public — paste it above.',
243
+ 'Find your channel’s numeric user id (any "Twitch username to user id" tool will do) and paste it into both id fields.',
244
+ 'Generate a user access token with the scopes below and paste it into Access token.',
245
+ 'Save and reconnect.',
246
+ ],
247
+ },
248
+ { type: 'text', text: 'Scopes needed, depending on which events you want:' },
249
+ {
250
+ type: 'code',
251
+ text: 'user:read:chat chat messages\nmoderator:read:followers follows\nchannel:read:subscriptions subs, resubs and gifts\nbits:read cheers',
252
+ },
253
+ { type: 'link', href: 'https://dev.twitch.tv/console/apps', label: 'Twitch developer console' },
254
+ {
255
+ type: 'text',
256
+ text: 'Leave Events blank for all of them, or list the ones you want — a studio that only shows chat need not ask for subscription scopes at all.',
257
+ },
258
+ ],
259
+ config: [
260
+ { key: 'clientId', label: 'Client ID', help: 'From your app at dev.twitch.tv/console/apps. Public, not a secret.' },
261
+ { key: 'broadcasterId', label: 'Channel user id', help: 'The numeric id of the channel to watch.' },
262
+ { key: 'userId', label: 'Your user id', help: 'Usually the same as the channel. Differs when a moderator runs the board.' },
263
+ { key: 'token', label: 'Access token', type: 'secret' },
264
+ { key: 'events', label: 'Events', help: 'Comma separated. Blank for all of them.' },
265
+ ],
266
+ create: (context) => {
267
+ const plugin = new Twitch(context)
268
+
269
+ new Handler({ ...context, plugin }).attach(plugin.events)
270
+
271
+ return plugin
272
+ },
273
+ })
@@ -0,0 +1,144 @@
1
+ // The EventSub WebSocket protocol, as a state machine with no socket in it.
2
+ //
3
+ // Twitch's transport has four behaviours that are easy to get subtly wrong and
4
+ // impossible to notice until a show: a keepalive watchdog, a reconnect that hands
5
+ // you a new URL and expects an overlap, replay protection, and revocation. Each is
6
+ // a rule about *when* to act rather than about parsing, so all four are here,
7
+ // away from the socket, where a test can drive them by hand.
8
+ //
9
+ // The socket wrapper feeds messages in and does what `handle` says. That split is
10
+ // what lets the whole protocol be tested in Node with no network, no credentials,
11
+ // and no waiting.
12
+
13
+ /** How long a message id is worth remembering, and how many. */
14
+ const SEEN_LIMIT = 600
15
+
16
+ /**
17
+ * Twitch asks clients to ignore anything older than ten minutes, because a replayed
18
+ * notification is indistinguishable from a real one to everything downstream -- a
19
+ * subscriber alert firing again an hour later is on air before anybody can stop it.
20
+ */
21
+ const MAX_AGE_MS = 10 * 60 * 1000
22
+
23
+ export const WELCOME = 'session_welcome'
24
+ export const KEEPALIVE = 'session_keepalive'
25
+ export const NOTIFICATION = 'notification'
26
+ export const RECONNECT = 'session_reconnect'
27
+ export const REVOCATION = 'revocation'
28
+
29
+ /**
30
+ * What the caller should do about a message.
31
+ *
32
+ * @typedef {object} Action
33
+ * @property {'ignore'|'subscribe'|'alive'|'deliver'|'reconnect'|'revoked'} do
34
+ * @property {string} [session] Session id, on `subscribe`.
35
+ * @property {number} [keepalive] Seconds, on `subscribe`.
36
+ * @property {string} [url] Where to reconnect, on `reconnect`.
37
+ * @property {string} [type] Subscription type, on `deliver` and `revoked`.
38
+ * @property {unknown} [event] The payload, on `deliver`.
39
+ * @property {string} [reason] Why, on `revoked` and on an ignore worth explaining.
40
+ */
41
+
42
+ export class Protocol {
43
+ /** Message ids already handled, oldest first. */
44
+ #seen = new Set()
45
+
46
+ /** @param {() => number} [now] Injectable so a test can move time without waiting. */
47
+ constructor(now = () => Date.now()) {
48
+ this.now = now
49
+ this.sessionId = null
50
+ this.keepaliveSeconds = null
51
+ }
52
+
53
+ /**
54
+ * Has this id been handled before?
55
+ *
56
+ * Twitch may redeliver, and a bounded set is the right shape: unbounded would
57
+ * grow for the length of a stream, and a stream is measured in hours.
58
+ */
59
+ #fresh(id) {
60
+ if (this.#seen.has(id)) return false
61
+
62
+ this.#seen.add(id)
63
+
64
+ // Sets iterate in insertion order, so the first key is the oldest.
65
+ if (this.#seen.size > SEEN_LIMIT) this.#seen.delete(this.#seen.values().next().value)
66
+
67
+ return true
68
+ }
69
+
70
+ /**
71
+ * Decide what a raw message means.
72
+ *
73
+ * @param {unknown} raw Already JSON.parse'd.
74
+ * @returns {Action}
75
+ */
76
+ handle(raw) {
77
+ const metadata = raw?.metadata
78
+ const payload = raw?.payload
79
+
80
+ if (!metadata?.message_type) return { do: 'ignore', reason: 'not an EventSub message' }
81
+
82
+ const id = metadata.message_id
83
+ const type = metadata.message_type
84
+
85
+ // Replay protection before anything else, so a duplicate reconnect or
86
+ // revocation is dropped as surely as a duplicate notification.
87
+ if (id && !this.#fresh(id)) return { do: 'ignore', reason: 'already handled' }
88
+
89
+ const at = Date.parse(metadata.message_timestamp ?? '')
90
+
91
+ if (Number.isFinite(at) && this.now() - at > MAX_AGE_MS) {
92
+ return { do: 'ignore', reason: 'older than ten minutes' }
93
+ }
94
+
95
+ switch (type) {
96
+ case WELCOME: {
97
+ this.sessionId = payload?.session?.id ?? null
98
+ // Twitch closes the connection if nothing is subscribed inside this
99
+ // window, which is short: ten seconds unless asked for otherwise.
100
+ this.keepaliveSeconds = payload?.session?.keepalive_timeout_seconds ?? null
101
+
102
+ return { do: 'subscribe', session: this.sessionId, keepalive: this.keepaliveSeconds }
103
+ }
104
+
105
+ case KEEPALIVE:
106
+ return { do: 'alive' }
107
+
108
+ case NOTIFICATION:
109
+ return {
110
+ do: 'deliver',
111
+ type: payload?.subscription?.type ?? metadata.subscription_type ?? null,
112
+ event: payload?.event ?? null,
113
+ }
114
+
115
+ case RECONNECT:
116
+ // The old socket keeps delivering until the new one has welcomed, which is
117
+ // the whole point of Twitch handing over a URL rather than just closing.
118
+ return { do: 'reconnect', url: payload?.session?.reconnect_url ?? null }
119
+
120
+ case REVOCATION:
121
+ return {
122
+ do: 'revoked',
123
+ type: payload?.subscription?.type ?? null,
124
+ reason: payload?.subscription?.status ?? 'revoked',
125
+ }
126
+
127
+ default:
128
+ return { do: 'ignore', reason: `unknown message type "${type}"` }
129
+ }
130
+ }
131
+
132
+ /**
133
+ * How long to wait for *anything* before deciding the connection is dead.
134
+ *
135
+ * Twitch sends a keepalive whenever it has sent nothing else, so silence past
136
+ * this is the connection being gone without a close frame -- which is the
137
+ * failure that leaves a chat overlay looking fine and frozen.
138
+ *
139
+ * A margin, because a keepalive that arrives exactly on time is on time.
140
+ */
141
+ get silenceBudgetMs() {
142
+ return ((this.keepaliveSeconds ?? 10) + 5) * 1000
143
+ }
144
+ }