Skip to content

Repository files navigation

kick-core

ci

A dependency-free client for Kick's realtime gateway, meant to be shared across browser extensions instead of each one reimplementing the transport.

What changed on Kick's side

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 request

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.

Where this can run

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.

The service worker lifecycle

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.

Usage

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.js

Your 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.

Reference

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.

Surviving a worker teardown

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 resume

Call 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.

Verifying

node selftest.mjs
node persistence.selftest.mjs

selftest.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.

Related projects

  • 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

License

MIT

About

Dependency-free client for Kick's current realtime gateway, for browser extensions.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages