A dependency-free client for Kick's realtime gateway, meant to be shared across browser extensions instead of each one reimplementing the transport.
Kick left the hosted Pusher cloud. The old endpoint with the public app key now answers 4001 App key not in this cluster on every cluster, so any extension still pointing there silently stops connecting.
The current gateway is wss://websockets.kick.com/viewer/v1/connect?token=..., self-hosted by Kick, and it still speaks the Pusher protocol: same pusher:subscribe frames, same chatrooms.<id>.v2 channels, same App\Events\ChatMessageEvent payloads. Only the transport moved, so existing parsing code stays valid.
The token endpoint answers a GET carrying the session cookies plus a small set of headers, and returns {"data":{"token":"01K..."},"message":"OK"}. Accept alone is not enough. One of those headers, X-CLIENT-TOKEN, is a public constant the web client sends on every request, in the same spirit as the old public Pusher app key. It was captured from a live session and was identical across reconnects.
If tokens start coming back 4xx, re-capture that constant before suspecting anything else.
The viewer token is single-use and must be refetched on every reconnect. Its endpoint is blocked by Cloudflare outside a browser, including behind a Chrome TLS fingerprint, and by CORS from a kick.com page.
That leaves exactly one place this module works: an extension service worker declaring websockets.kick.com in host_permissions. Not a content script, not a server. For server-side use, Kick's public API and its webhooks are the supported path.
An MV3 service worker is torn down when Chrome judges it idle, and a torn-down worker does not come back because a setTimeout was pending: the timers die with it, gateway.js alone stops existing right along with its socket. That part is a platform limitation, no code changes it.
What persistence.js (added afterwards) does instead: persist which chatrooms were subscribed to chrome.storage.session, and register a chrome.alarms heartbeat that wakes the worker back up periodically. On each wake, a consumer checks gw.isOpen() and, if the connection didn't survive, resubscribes from the saved list. This does not make the connection continuous, nothing is listening in the gap between teardown and the next alarm firing, it turns an unbounded silent outage into a bounded, self-healing one. verify/sw.js wires this end to end and is the reference for how to use it in a real extension. See Surviving a worker teardown below.
An earlier version of this file claimed the lifecycle problem was fully handled by gateway.js alone. It was not, and still isn't by any single module, persistence.js narrows the gap, it doesn't close it.
There is no package to install. gateway.js is one file with no dependencies: copy it into your extension and import it.
curl -O https://raw.githubusercontent.com/Pkkls/kick-core/main/gateway.jsYour manifest.json needs the host permission, or every connection fails before it starts:
{ "host_permissions": ["https://websockets.kick.com/*", "https://kick.com/*"] }import { KickGateway } from './gateway.js';
const gw = new KickGateway({
onMessage: (m) => console.log(m.username, m.content),
onState: (state) => console.log('gateway', state),
});
gw.subscribeChatroom(5389830);Reconnection, backoff, keepalive pings and resubscription are handled internally.
| Option | Purpose |
|---|---|
onMessage(msg) |
one normalized chat message |
onEvent(evt) |
every other frame, as { event, channel, data } |
onState(state, detail) |
transport state changed |
Only App\Events\ChatMessageEvent becomes a msg. Everything else Kick sends on a subscribed channel reaches onEvent untouched, which is where to look for anything this module does not model.
A message carries id, content, username (lowercased), chatroomId, timestampMs, badges and isBot.
| Method | Purpose |
|---|---|
subscribeChatroom(id) |
join a chatroom, safe to call before or after connect |
unsubscribeChatroom(id) |
leave one chatroom, the socket stays up |
subscribe(channel) / unsubscribe(channel) |
same, on a raw Pusher channel name |
connect() |
open the socket, called for you on the first subscribe |
isOpen() |
whether the socket is currently open, useful before deciding to resume |
stop() |
close the socket and end the retry loop for good |
stop() is final. It is not a pause: a stopped instance ignores connect() forever, so resuming means constructing a new one.
onState reports four transport states and one parser state:
| State | Meaning |
|---|---|
open |
socket up, subscriptions replayed |
closed |
socket gone, a retry is already scheduled |
error |
second argument is the cause, a retry is already scheduled |
stale |
60 s with nothing inbound, closing and reconnecting |
unparsable |
20 consecutive chat frames parsed to nothing, second argument is { consecutive, hint } |
stale is the one worth wiring to an alarm. A socket whose peer went away stays readyState === OPEN and simply never speaks again, so close and error never fire and a dead channel is indistinguishable from a quiet one. Pings go out every 25 s, and 60 s of total silence is treated as death.
unparsable is the other half of the same idea, pointed upstream: a busy channel produces the odd frame this parser rejects on purpose, twenty in a row is Kick having changed shape. It fires once per run, not once per frame.
Backoff on reconnect is exponential from 1 s, capped at 30 s. Every reconnect fetches a fresh viewer token, because the previous one is already spent.
The chatroom id is not the channel name. Read it from https://kick.com/api/v2/channels/<slug> under chatroom.id.
import { KickGateway } from './gateway.js';
import { saveSubscriptions, loadSubscriptions, registerKeepalive } from './persistence.js';
const gw = new KickGateway({ onMessage: (m) => console.log(m.username, m.content) });
async function resume() {
const saved = await loadSubscriptions();
for (const id of saved) gw.subscribeChatroom(id);
}
// Register at the TOP LEVEL of the service worker module, not inside an
// async function or event handler — an MV3 requirement for the listener to
// reliably fire.
registerKeepalive({
intervalMinutes: 1,
onWake: () => { if (!gw.isOpen()) resume(); },
});
resume(); // also try on cold start, in case this worker instance is itself a resumeCall saveSubscriptions([...ids]) whenever the set of subscribed chatrooms changes, so the next wake has something to resume. manifest.json needs both "storage" and "alarms" in permissions for this to work, CI checks for both.
Requires chrome.storage.session (Chrome 102+) and chrome.alarms.
node selftest.mjs
node persistence.selftest.mjsselftest.mjs covers parsing, filtering of non-chat types, subscribing while connected, reconnection fetching a fresh token rather than replaying one, and stop() ending the retry loop. persistence.selftest.mjs covers the save/load/clear round trip, the alarm-name filter (an unrelated alarm firing must not trigger a resume), and both APIs being required to exist before use.
For a live check, load this folder as an unpacked extension and open the service worker console. It picks a live channel and logs real chat messages, and now also demonstrates the resume path, see the comment at the top of verify/sw.js for how to force a teardown and watch it recover.
- kick-chat-translator, live chat translation, the extension this transport was extracted for
- kick-ad-blocker, blocks Kick's pre-roll and overlay ads
- kickbus, the server-side answer to the same problem: official webhooks relayed over SSE
- kick-drops-miner, Windows app that progresses Kick drop watch-time
MIT